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
715 lines
24 KiB
Go
715 lines
24 KiB
Go
// 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 }
|