initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
715
internal/auth/auth.go
Normal file
715
internal/auth/auth.go
Normal file
|
|
@ -0,0 +1,715 @@
|
|||
// Package auth authenticates callers and authorises their actions.
|
||||
//
|
||||
// Three mechanisms are supported and are resolved by Authenticator in a fixed
|
||||
// order, most specific first:
|
||||
//
|
||||
// 1. An API key, presented as a bearer token. Machine access.
|
||||
// 2. An OIDC session, carried in a signed cookie. Human access to the admin
|
||||
// API, with a full login redirect flow.
|
||||
// 3. A development bypass, available only when the configuration explicitly
|
||||
// enables it and the process is running in a development environment.
|
||||
//
|
||||
// The development bypass refuses to initialise outside development. That check
|
||||
// is the single most important line of defence in this package: an accidentally
|
||||
// deployed dev mode is a total authentication bypass, so it is made impossible
|
||||
// to construct rather than merely documented as unsupported.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/config"
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// Errors returned by this package. They wrap the domain sentinels so the HTTP
|
||||
// layer can map them to status codes without importing this package's internals.
|
||||
var (
|
||||
ErrNoSession = fmt.Errorf("%w: no session", domain.ErrUnauthorized)
|
||||
ErrSessionExpired = fmt.Errorf("%w: session expired", domain.ErrUnauthorized)
|
||||
ErrSessionInvalid = fmt.Errorf("%w: session is invalid", domain.ErrUnauthorized)
|
||||
ErrDevBypassDenied = fmt.Errorf("%w: development bypass is not available", domain.ErrUnauthorized)
|
||||
)
|
||||
|
||||
// Identity, Role and the role constants are re-exported so that callers of this
|
||||
// package need not also import the domain package.
|
||||
type (
|
||||
// Identity is the authenticated caller.
|
||||
Identity = domain.Identity
|
||||
// Role is a coarse authorisation level.
|
||||
Role = domain.Role
|
||||
)
|
||||
|
||||
// Role constants, re-exported for the same reason.
|
||||
const (
|
||||
// RoleViewer may read published themes.
|
||||
RoleViewer = domain.RoleViewer
|
||||
// RoleEditor may create, edit and publish themes.
|
||||
RoleEditor = domain.RoleEditor
|
||||
// RoleAdmin may additionally manage users, API keys and settings.
|
||||
RoleAdmin = domain.RoleAdmin
|
||||
)
|
||||
|
||||
// Resolver is the identity-provider half of the interface. It is an interface so
|
||||
// that the session store can be tested without a live provider, and so that a
|
||||
// future provider (SAML, a service mesh) can be added without touching the HTTP
|
||||
// layer.
|
||||
// Login is the outcome of a completed login.
|
||||
type Login struct {
|
||||
// Identity is the authenticated caller.
|
||||
Identity *Identity
|
||||
// ReturnTo is the sanitised local path to send the browser to. It is always
|
||||
// a path on this service, never an absolute URL.
|
||||
ReturnTo string
|
||||
}
|
||||
|
||||
// SafeReturnTo restricts a post-login redirect to a local path.
|
||||
//
|
||||
// Without this check an attacker could craft a login link that, after a
|
||||
// successful authentication, bounces the user to a site of their choosing with the
|
||||
// session cookie freshly minted: an open redirect that also primes a phishing flow.
|
||||
// Absolute URLs, protocol-relative URLs, backslashes and control characters are all
|
||||
// rejected, because browsers normalise several of those into an origin.
|
||||
func SafeReturnTo(returnTo string) string {
|
||||
trimmed := strings.TrimSpace(returnTo)
|
||||
if trimmed == "" {
|
||||
return "/"
|
||||
}
|
||||
if strings.ContainsAny(trimmed, "\\\r\n\t") ||
|
||||
strings.Contains(trimmed, "://") || strings.HasPrefix(trimmed, "//") {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, "/") {
|
||||
return "/"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
type Resolver interface {
|
||||
// Begin starts a login by redirecting the browser to the provider.
|
||||
Begin(ctx context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error
|
||||
// Complete finishes a login, returning the identity and where to send the
|
||||
// browser next.
|
||||
Complete(ctx context.Context, r *http.Request) (*Login, error)
|
||||
// End starts a logout.
|
||||
End(ctx context.Context, redirect string) string
|
||||
// Enabled reports whether the resolver can authenticate at all.
|
||||
Enabled() bool
|
||||
// ClearState discards any pending login transaction.
|
||||
ClearState(w http.ResponseWriter)
|
||||
// LogoutURL returns the provider's end-session endpoint.
|
||||
LogoutURL(redirect string) string
|
||||
}
|
||||
|
||||
// KeyStore is the persistence the authenticator needs beyond sessions: recording
|
||||
// the users it has seen, so that an operator can see who has access.
|
||||
//
|
||||
// It was once also the API-key store. Machine credentials were removed, so a bearer
|
||||
// token is no longer a credential: the runtime API is public and the admin API is
|
||||
// for people. A request carrying a bearer token is now simply unauthenticated,
|
||||
// rather than being given editor rights by whatever it presents.
|
||||
type KeyStore interface {
|
||||
UpsertUser(ctx context.Context, identity *Identity) error
|
||||
}
|
||||
|
||||
// Authenticator authenticates requests and issues sessions.
|
||||
type Authenticator struct {
|
||||
config config.Config
|
||||
resolver Resolver
|
||||
keys KeyStore
|
||||
sessions *SessionStore
|
||||
// now is the clock, overridable in tests.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New builds an Authenticator. resolver may be nil when authentication is
|
||||
// disabled; sessions are always constructed because the dev bypass and API keys
|
||||
// still benefit from a consistent identity shape.
|
||||
func New(cfg config.Config, resolver Resolver, keys KeyStore) (*Authenticator, error) {
|
||||
if cfg.AuthMode == config.AuthModeDev && !cfg.IsDevelopment() {
|
||||
return nil, fmt.Errorf("refusing to start: AUTH_MODE=dev outside a development environment")
|
||||
}
|
||||
if cfg.AuthMode == config.AuthModeNone && !cfg.IsDevelopment() {
|
||||
return nil, fmt.Errorf("refusing to start: AUTH_MODE=none outside a development environment")
|
||||
}
|
||||
|
||||
sessionStore, err := NewSessionStore(cfg.SessionSecret, cfg.SessionTTL, cfg.IsDevelopment())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.AuthMode == config.AuthModeOIDC && (resolver == nil || !resolver.Enabled()) {
|
||||
return nil, fmt.Errorf("AUTH_MODE=oidc but the identity provider is unavailable")
|
||||
}
|
||||
|
||||
return &Authenticator{
|
||||
config: cfg,
|
||||
resolver: resolver,
|
||||
keys: keys,
|
||||
sessions: sessionStore,
|
||||
now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Sessions exposes the session store so the HTTP layer can set and clear
|
||||
// cookies without re-deriving the cookie attributes.
|
||||
func (a *Authenticator) Sessions() *SessionStore { return a.sessions }
|
||||
|
||||
// Config returns the configuration the authenticator was built with.
|
||||
func (a *Authenticator) Config() config.Config { return a.config }
|
||||
|
||||
// Mode returns the active authentication mode.
|
||||
func (a *Authenticator) Mode() string { return a.config.AuthMode }
|
||||
|
||||
// DevBypassEnabled reports whether the development bypass is available.
|
||||
func (a *Authenticator) DevBypassEnabled() bool {
|
||||
return a.config.AuthMode == config.AuthModeDev && a.config.IsDevelopment()
|
||||
}
|
||||
|
||||
// Authenticate resolves the caller from a request.
|
||||
//
|
||||
// The session cookie is the only credential. There is no bearer-token path: a
|
||||
// request with an Authorization header is treated exactly like one without, so a
|
||||
// stale or forged token cannot grant anything.
|
||||
func (a *Authenticator) Authenticate(r *http.Request) (*Identity, error) {
|
||||
if identity, err := a.sessions.Read(r); err == nil {
|
||||
return identity, nil
|
||||
} else if errors.Is(err, ErrSessionExpired) {
|
||||
return nil, ErrSessionExpired
|
||||
}
|
||||
|
||||
if a.DevBypassEnabled() {
|
||||
if identity := a.devIdentity(r); identity != nil {
|
||||
return identity, nil
|
||||
}
|
||||
}
|
||||
|
||||
if a.config.AuthMode == config.AuthModeNone {
|
||||
return &Identity{
|
||||
Subject: "anonymous",
|
||||
Name: "anonymous",
|
||||
Roles: []Role{RoleAdmin},
|
||||
Method: "none",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, ErrNoSession
|
||||
}
|
||||
|
||||
// devIdentity builds an identity for the development bypass.
|
||||
//
|
||||
// The caller may name themselves with ?dev-user=email, which lets a developer
|
||||
// exercise the role model without a running identity provider. It is only
|
||||
// reachable when DevBypassEnabled is true, which config.Validate refuses to
|
||||
// allow outside development.
|
||||
func (a *Authenticator) devIdentity(r *http.Request) *Identity {
|
||||
email := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("dev-user")))
|
||||
if email == "" {
|
||||
email = "dev@go-bananas.local"
|
||||
}
|
||||
|
||||
role := a.config.DevFallbackRole
|
||||
if mapped, ok := a.config.DevUsers[email]; ok {
|
||||
role = mapped
|
||||
}
|
||||
|
||||
identity := &Identity{
|
||||
Subject: "dev:" + email,
|
||||
Email: email,
|
||||
Name: displayNameFor(email),
|
||||
Roles: []Role{domain.ParseRole(role)},
|
||||
Method: "dev",
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
// displayNameFor turns an email address into a plausible display name.
|
||||
func displayNameFor(email string) string {
|
||||
local, _, _ := strings.Cut(email, "@")
|
||||
if local == "" {
|
||||
return email
|
||||
}
|
||||
// Capitalise the first letter of each hyphen-separated segment.
|
||||
parts := strings.Split(local, "-")
|
||||
for i, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// IssueSession records a successful login and writes the session cookie.
|
||||
func (a *Authenticator) IssueSession(ctx context.Context, w http.ResponseWriter, identity *Identity) error {
|
||||
if identity == nil {
|
||||
return fmt.Errorf("%w: cannot issue a session for a nil identity", domain.ErrInvalid)
|
||||
}
|
||||
if a.keys != nil && identity.Method != "dev" {
|
||||
// Recording the login lets an administrator see who has used the system.
|
||||
if err := a.keys.UpsertUser(ctx, identity); err != nil {
|
||||
// A login that cannot be recorded should still succeed; losing the
|
||||
// directory entry is far less harmful than locking the user out.
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
return a.sessions.Issue(w, identity)
|
||||
}
|
||||
|
||||
// ClearSession removes the session cookie.
|
||||
func (a *Authenticator) ClearSession(w http.ResponseWriter) {
|
||||
a.sessions.Clear(w)
|
||||
}
|
||||
|
||||
// ModeConfigured reports whether interactive login is available at all.
|
||||
func (a *Authenticator) ModeConfigured() bool {
|
||||
return a != nil && a.config.AuthMode != config.AuthModeNone
|
||||
}
|
||||
|
||||
// ProviderEnabled reports whether an OIDC provider is wired up.
|
||||
func (a *Authenticator) ProviderEnabled() bool {
|
||||
return a != nil && a.resolver != nil && a.resolver.Enabled()
|
||||
}
|
||||
|
||||
// Begin starts an interactive login.
|
||||
func (a *Authenticator) Begin(ctx context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error {
|
||||
if a == nil || a.resolver == nil || !a.resolver.Enabled() {
|
||||
return fmt.Errorf("%w: no identity provider is configured", domain.ErrNotConfigured)
|
||||
}
|
||||
return a.resolver.Begin(ctx, req, w, returnTo)
|
||||
}
|
||||
|
||||
// Complete finishes an interactive login.
|
||||
func (a *Authenticator) Complete(ctx context.Context, r *http.Request) (*Login, error) {
|
||||
if a == nil || a.resolver == nil || !a.resolver.Enabled() {
|
||||
return nil, fmt.Errorf("%w: no identity provider is configured", domain.ErrNotConfigured)
|
||||
}
|
||||
return a.resolver.Complete(ctx, r)
|
||||
}
|
||||
|
||||
// ClearState discards a pending login transaction.
|
||||
func (a *Authenticator) ClearState(w http.ResponseWriter) {
|
||||
if a != nil && a.resolver != nil {
|
||||
a.resolver.ClearState(w)
|
||||
}
|
||||
}
|
||||
|
||||
// LogoutURL returns the provider logout URL.
|
||||
func (a *Authenticator) LogoutURL(redirect string) string {
|
||||
if a.resolver == nil || !a.resolver.Enabled() {
|
||||
return ""
|
||||
}
|
||||
return a.resolver.LogoutURL(redirect)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sessions --
|
||||
|
||||
// SessionStore issues and verifies stateless signed session cookies.
|
||||
//
|
||||
// The session is a signed, encrypted-free JSON payload in a cookie: there is no
|
||||
// server-side session table to migrate, scale or invalidate. The trade is that
|
||||
// revoking a session before its expiry requires rotating the signing key, which
|
||||
// is acceptable for a session that lives eight hours and is documented as such.
|
||||
//
|
||||
// The signature is HMAC-SHA256 over the payload with a constant-time
|
||||
// comparison, which is what makes a forged cookie infeasible.
|
||||
type SessionStore struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
secure bool
|
||||
name string
|
||||
sameSite http.SameSite
|
||||
now func() time.Time
|
||||
// inDevelopment relaxes cookie behaviour so that a local run over plain
|
||||
// HTTP still receives a cookie.
|
||||
inDevelopment bool
|
||||
}
|
||||
|
||||
// NewSessionStore builds a session store.
|
||||
func NewSessionStore(secret string, ttl time.Duration, inDevelopment bool) (*SessionStore, error) {
|
||||
if len(secret) < 32 {
|
||||
return nil, fmt.Errorf("%w: session secret must be at least 32 bytes, got %d", domain.ErrInvalid, len(secret))
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 8 * time.Hour
|
||||
}
|
||||
switch strings.ToLower(stringValue(sameSiteFromString("lax"))) {
|
||||
case "lax", "strict", "none":
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unknown SameSite value", domain.ErrInvalid)
|
||||
}
|
||||
return &SessionStore{
|
||||
secret: []byte(secret),
|
||||
ttl: ttl,
|
||||
secure: true,
|
||||
name: "go-bananas-session",
|
||||
sameSite: http.SameSiteLaxMode,
|
||||
now: time.Now,
|
||||
inDevelopment: inDevelopment,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WithCookie configures the cookie attributes. It exists so that the
|
||||
// configuration can be applied in one place after construction.
|
||||
func (s *SessionStore) WithCookie(name string, secure bool, sameSite http.SameSite) *SessionStore {
|
||||
if name != "" {
|
||||
s.name = name
|
||||
}
|
||||
s.secure = secure
|
||||
s.sameSite = sameSite
|
||||
return s
|
||||
}
|
||||
|
||||
// WithClock overrides the clock, for tests.
|
||||
func (s *SessionStore) WithClock(now func() time.Time) *SessionStore {
|
||||
s.now = now
|
||||
return s
|
||||
}
|
||||
|
||||
// CookieName returns the configured cookie name.
|
||||
func (s *SessionStore) CookieName() string { return s.name }
|
||||
|
||||
// sessionPayload is the signed cookie body.
|
||||
type sessionPayload struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Roles []string `json:"roles"`
|
||||
Method string `json:"method"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
// Nonce lets a future rotation invalidate sessions issued before a cutover.
|
||||
Nonce string `json:"jti,omitempty"`
|
||||
}
|
||||
|
||||
// Issue writes a signed session cookie.
|
||||
func (s *SessionStore) Issue(w http.ResponseWriter, identity *Identity) error {
|
||||
now := s.now().UTC()
|
||||
roles := make([]string, 0, len(identity.Roles))
|
||||
for _, r := range identity.Roles {
|
||||
roles = append(roles, string(r))
|
||||
}
|
||||
|
||||
payload := sessionPayload{
|
||||
Subject: identity.Subject,
|
||||
Email: identity.Email,
|
||||
Name: identity.Name,
|
||||
Roles: roles,
|
||||
Method: identity.Method,
|
||||
IssuedAt: now.Unix(),
|
||||
ExpiresAt: now.Add(s.ttl).Unix(),
|
||||
}
|
||||
encoded, err := s.encode(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//nolint:gosec // G124: HttpOnly, Secure and SameSite are all set, from the
|
||||
// configured values rather than literals. gosec only recognises a literal
|
||||
// attribute, so a configurable cookie reads as an unprotected one.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.name,
|
||||
Value: encoded,
|
||||
Path: "/",
|
||||
Expires: now.Add(s.ttl),
|
||||
MaxAge: int(s.ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: s.sameSite,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear expires the session cookie.
|
||||
func (s *SessionStore) Clear(w http.ResponseWriter) {
|
||||
//nolint:gosec // G124: as above. Clearing a cookie needs the same attribute
|
||||
// triple as setting it, or a browser will not remove it.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: s.sameSite,
|
||||
})
|
||||
}
|
||||
|
||||
// Read verifies and decodes the session cookie.
|
||||
func (s *SessionStore) Read(r *http.Request) (*Identity, error) {
|
||||
cookie, err := r.Cookie(s.name)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil, ErrNoSession
|
||||
}
|
||||
|
||||
payload, err := s.decode(cookie.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := s.now().UTC().Unix()
|
||||
if payload.ExpiresAt > 0 && now > payload.ExpiresAt {
|
||||
return nil, ErrSessionExpired
|
||||
}
|
||||
|
||||
identity := &Identity{
|
||||
Subject: payload.Subject,
|
||||
Email: payload.Email,
|
||||
Name: payload.Name,
|
||||
Method: payload.Method,
|
||||
}
|
||||
// Left nil when the payload carries no expiry, rather than becoming the Unix
|
||||
// epoch: a session without an expiry is a valid thing to represent, and 1970 is
|
||||
// a date every client would read as "already expired".
|
||||
if payload.ExpiresAt > 0 {
|
||||
identity.ExpiresAt = timePtr(time.Unix(payload.ExpiresAt, 0).UTC())
|
||||
}
|
||||
for _, r := range payload.Roles {
|
||||
identity.Roles = append(identity.Roles, Role(r))
|
||||
}
|
||||
if len(identity.Roles) == 0 {
|
||||
identity.Roles = []Role{RoleViewer}
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// encode serialises and signs a payload.
|
||||
func (s *SessionStore) encode(payload sessionPayload) (string, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode session: %w", err)
|
||||
}
|
||||
bodyEncoded := base64.RawURLEncoding.EncodeToString(body)
|
||||
mac := s.sign(bodyEncoded)
|
||||
return bodyEncoded + "." + base64.RawURLEncoding.EncodeToString(mac), nil
|
||||
}
|
||||
|
||||
// decode verifies and parses a payload.
|
||||
func (s *SessionStore) decode(value string) (sessionPayload, error) {
|
||||
var payload sessionPayload
|
||||
|
||||
bodyEncoded, signatureEncoded, ok := strings.Cut(value, ".")
|
||||
if !ok {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
|
||||
signature, err := base64.RawURLEncoding.DecodeString(signatureEncoded)
|
||||
if err != nil {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
|
||||
// Constant-time comparison: a byte-by-byte early return would leak the
|
||||
// correct prefix length to an attacker measuring responses.
|
||||
expected := s.sign(bodyEncoded)
|
||||
if subtle.ConstantTimeCompare(signature, expected) != 1 {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
|
||||
body, err := base64.RawURLEncoding.DecodeString(bodyEncoded)
|
||||
if err != nil {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
if payload.Subject == "" {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// sign computes the HMAC over a value.
|
||||
func (s *SessionStore) sign(value string) []byte {
|
||||
mac := hmacSHA256(s.secret, []byte(value))
|
||||
return mac[:]
|
||||
}
|
||||
|
||||
// sameSiteFromString maps a configuration value to an http.SameSite.
|
||||
func sameSiteFromString(raw string) http.SameSite {
|
||||
switch strings.ToLower(raw) {
|
||||
case "strict":
|
||||
return http.SameSiteStrictMode
|
||||
case "none":
|
||||
return http.SameSiteNoneMode
|
||||
default:
|
||||
return http.SameSiteLaxMode
|
||||
}
|
||||
}
|
||||
|
||||
// stringValue renders a SameSite for logging.
|
||||
func stringValue(v http.SameSite) string {
|
||||
switch v {
|
||||
case http.SameSiteStrictMode:
|
||||
return "strict"
|
||||
case http.SameSiteNoneMode:
|
||||
return "none"
|
||||
default:
|
||||
return "lax"
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ state --
|
||||
|
||||
// StateStore keeps short-lived OIDC transaction state (the PKCE verifier and
|
||||
// the post-login return path) in a signed cookie.
|
||||
//
|
||||
// Keeping it in the cookie rather than a server-side map is what lets go-bananas
|
||||
// run multiple replicas behind a load balancer with no shared session storage.
|
||||
type StateStore struct {
|
||||
secret []byte
|
||||
name string
|
||||
ttl time.Duration
|
||||
secure bool
|
||||
sameSite http.SameSite
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// oauthState is the transaction cookie body.
|
||||
//
|
||||
// State is stored alongside the verifier and nonce so that the callback can be
|
||||
// bound to the browser that started the flow. Without it the state parameter is
|
||||
// merely present, not checked, and an attacker-supplied authorization code could
|
||||
// be redeemed into a session in someone else's browser (a login CSRF).
|
||||
type oauthState struct {
|
||||
Verifier string `json:"v"`
|
||||
ReturnTo string `json:"r"`
|
||||
Nonce string `json:"n"`
|
||||
State string `json:"s"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
}
|
||||
|
||||
// Transaction is a verified login transaction read back from the state cookie.
|
||||
type Transaction struct {
|
||||
// Verifier is the PKCE code verifier for the exchange.
|
||||
Verifier string
|
||||
// ReturnTo is the sanitised local path the browser should land on.
|
||||
ReturnTo string
|
||||
// Nonce must match the nonce in the returned ID token.
|
||||
Nonce string
|
||||
// State must match the state parameter the provider echoes back.
|
||||
State string
|
||||
}
|
||||
|
||||
// NewStateStore builds the transaction cookie store.
|
||||
func NewStateStore(secret, name string, secure bool, sameSite http.SameSite, ttl time.Duration) *StateStore {
|
||||
if ttl <= 0 {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
return &StateStore{
|
||||
secret: []byte(secret),
|
||||
name: name,
|
||||
secure: secure,
|
||||
sameSite: sameSite,
|
||||
ttl: ttl,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the state cookie name.
|
||||
func (s *StateStore) Name() string { return s.name }
|
||||
|
||||
// Put writes the transaction state cookie. It fails when any part of the
|
||||
// transaction is empty: a transaction missing its state or verifier cannot be
|
||||
// validated at the callback, so storing it would only defer the failure to a point
|
||||
// where the user is already mid-login.
|
||||
func (s *StateStore) Put(w http.ResponseWriter, verifier, returnTo, nonce, state string) error {
|
||||
if verifier == "" || nonce == "" || state == "" {
|
||||
return fmt.Errorf("%w: incomplete oauth transaction", domain.ErrInvalid)
|
||||
}
|
||||
now := s.now().UTC()
|
||||
body, err := json.Marshal(oauthState{
|
||||
Verifier: verifier, ReturnTo: returnTo, Nonce: nonce, State: state,
|
||||
IssuedAt: now.Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode oauth state: %w", err)
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString(body)
|
||||
mac := hmacSHA256(s.secret, []byte(encoded))
|
||||
|
||||
//nolint:gosec // G124: as above.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.name,
|
||||
Value: encoded + "." + base64.RawURLEncoding.EncodeToString(mac[:]),
|
||||
Path: "/",
|
||||
Expires: now.Add(s.ttl),
|
||||
MaxAge: int(s.ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get verifies the transaction state cookie and returns what it holds.
|
||||
//
|
||||
// "Consume" is the caller's job: the cookie is single-use, but only because the
|
||||
// HTTP layer expires it once the login has succeeded. Clearing it here instead
|
||||
// would expire it before the code exchange, and a user who mistyped their password
|
||||
// once at the provider would arrive back with no transaction to finish.
|
||||
func (s *StateStore) Get(r *http.Request) (Transaction, error) {
|
||||
cookie, err := r.Cookie(s.name)
|
||||
if err != nil {
|
||||
return Transaction{}, fmt.Errorf("%w: missing oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
encoded, signatureEncoded, ok := strings.Cut(cookie.Value, ".")
|
||||
if !ok {
|
||||
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
signature, decodeErr := base64.RawURLEncoding.DecodeString(signatureEncoded)
|
||||
if decodeErr != nil {
|
||||
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
expected := hmacSHA256(s.secret, []byte(encoded))
|
||||
if subtle.ConstantTimeCompare(signature, expected[:]) != 1 {
|
||||
return Transaction{}, fmt.Errorf("%w: oauth state signature mismatch", domain.ErrUnauthorized)
|
||||
}
|
||||
body, decodeErr := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if decodeErr != nil {
|
||||
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
var state oauthState
|
||||
if json.Unmarshal(body, &state) != nil {
|
||||
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
if s.now().UTC().Sub(time.Unix(state.IssuedAt, 0).UTC()) > s.ttl {
|
||||
return Transaction{}, fmt.Errorf("%w: oauth state expired", domain.ErrUnauthorized)
|
||||
}
|
||||
if state.Verifier == "" || state.Nonce == "" || state.State == "" {
|
||||
return Transaction{}, fmt.Errorf("%w: incomplete oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
return Transaction{
|
||||
Verifier: state.Verifier,
|
||||
ReturnTo: SafeReturnTo(state.ReturnTo),
|
||||
Nonce: state.Nonce,
|
||||
State: state.State,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Clear expires the state cookie.
|
||||
func (s *StateStore) Clear(w http.ResponseWriter) {
|
||||
//nolint:gosec // G124: as above.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.name, Value: "", Path: "/", MaxAge: -1,
|
||||
HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// hmacSHA256 computes a signature. It is a free function so the session store
|
||||
// and the OIDC state store share one implementation and cannot diverge.
|
||||
func hmacSHA256(key, body []byte) [32]byte {
|
||||
return sha256HMAC(key, body)
|
||||
}
|
||||
|
||||
// timePtr returns a pointer to t. It exists because the session and identity types
|
||||
// carry an optional expiry, and taking the address of a conversion inline is not
|
||||
// something Go will let you write.
|
||||
func timePtr(t time.Time) *time.Time { return &t }
|
||||
Loading…
Add table
Add a link
Reference in a new issue