textmachine/platform/internal/auth/cookie.go

76 lines
2.5 KiB
Go

package auth
import (
"net/http"
"time"
)
// DevCookieName is the session cookie's name when Secure cannot be set. It is a DIFFERENT name on
// purpose: __Host- is not decoration a browser can be talked out of — a cookie with that prefix and
// no Secure attribute is simply rejected — so a "local development" profile that kept the name
// would fail in a way that looks like a broken login (PD-8).
const DevCookieName = "tm_session"
// LoginCookieName holds the OAuth state while the browser is away at the provider. Short-lived,
// single-use, and paired with a server-side row: the cookie proves the callback came back to the
// same browser that started, which is what stops a login-CSRF.
const (
LoginCookieName = "__Host-tm_login"
DevLoginCookieName = "tm_login"
)
// Cookies writes the browser's credentials. One switch, and it flips the name with the attributes.
type Cookies struct {
// Insecure serves plain HTTP: no Secure attribute, no __Host- prefix. Production never sets it.
Insecure bool
}
func (c Cookies) SessionName() string {
if c.Insecure {
return DevCookieName
}
return CookieName
}
func (c Cookies) LoginName() string {
if c.Insecure {
return DevLoginCookieName
}
return LoginCookieName
}
// SetSession writes the session cookie.
//
// SameSite=Lax rather than Strict: the browser returns from the identity provider by a top-level
// GET, and Strict would withhold the cookie on every arrival from an external link. Lax still
// withholds it from cross-site POSTs, and the CSRF layer covers the rest.
func (c Cookies) SetSession(w http.ResponseWriter, token string, ttl time.Duration) {
c.set(w, c.SessionName(), token, ttl)
}
// ClearSession removes it. Attributes must match the ones it was set with or the browser keeps it.
func (c Cookies) ClearSession(w http.ResponseWriter) { c.set(w, c.SessionName(), "", -time.Second) }
func (c Cookies) SetLogin(w http.ResponseWriter, state string, ttl time.Duration) {
c.set(w, c.LoginName(), state, ttl)
}
// ClearLogin removes it. The callback clears it whether it succeeded or not: a state cookie that
// outlives its round trip is a replay waiting for an accident.
func (c Cookies) ClearLogin(w http.ResponseWriter) { c.set(w, c.LoginName(), "", -time.Second) }
func (c Cookies) set(w http.ResponseWriter, name, value string, ttl time.Duration) {
maxAge := int(ttl.Seconds())
if ttl < 0 {
maxAge = -1
}
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
Secure: !c.Insecure,
SameSite: http.SameSiteLaxMode,
})
}