ProofedMesh/services/admin-api/internal/auth/pocket_id.go

848 lines
24 KiB
Go
Raw Permalink Normal View History

2026-10-25 18:06:51 +00:00
package auth
// Pocket-ID OIDC Authorization-Code middleware for admin-api.
//
// Flow:
// 1. Unauthenticated request to a protected path → 302 to /login?next=…
// 2. /login builds state + nonce + PKCE verifier, drops them in a short-lived
// encrypted "oidc_state" cookie, 302s to Pocket-ID's authorize endpoint.
// 3. Pocket-ID redirects back to /callback?code=…&state=… — we match state,
// exchange code at the token endpoint (with the PKCE verifier), parse the
// ID token, verify its RS256 signature against the cached JWKS, and
// enforce membership in RequiredGroup.
// 4. On success we drop an encrypted session cookie carrying {sub, sid, exp,
// groups} and 302 to the original "next" URL.
//
// Protect(h) wraps a handler so each request must present a valid session
// cookie; it decrypts the cookie and stamps WithPrincipal / WithSession on the
// request context. Missing/expired/tampered cookies send a 302 back to /login.
//
// The verifier is stdlib-only: `crypto/rsa` + `crypto/sha256` for RS256 JWS
// verification, `crypto/aes` + `crypto/cipher` (AES-GCM) for cookie sealing.
// OIDC discovery (`/.well-known/openid-configuration`) and the JWKS are
// fetched lazily and cached for DiscoveryTTL.
import (
"context"
"crypto"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/big"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// Config configures the Pocket-ID middleware. All fields except *Client* and
// *Logger* and *Secure* are required for production use; the tests fill them
// against an httptest-mock Pocket-ID.
type Config struct {
// Issuer is the Pocket-ID base URL (e.g. "https://auth.basicstack.de").
// Discovery is loaded from Issuer + "/.well-known/openid-configuration".
Issuer string
// ClientID / ClientSecret identify the admin-api OIDC client registered
// in Pocket-ID. ClientSecret is sent via basic-auth to the token endpoint.
ClientID string
ClientSecret string
// RedirectURL must match the Pocket-ID client's registered callback and
// should resolve to the mounted CallbackPath on this service.
RedirectURL string
// RequiredGroup is the group a user MUST be in to pass the middleware.
// Default: "proofedmesh-admin".
RequiredGroup string
// CookieName is the browser session cookie name.
// Default: "proofedmesh_admin_session".
CookieName string
// StateCookieName is the transient OIDC-state cookie name.
// Default: "proofedmesh_admin_oidc_state".
StateCookieName string
// CookieSecret is a 32-byte AES-256 key used to seal cookies.
CookieSecret []byte
// SessionTTL bounds how long a session cookie is accepted.
// Default: 8h.
SessionTTL time.Duration
// StateTTL bounds how long the OIDC state cookie is accepted.
// Default: 10m.
StateTTL time.Duration
// DiscoveryTTL bounds how long OIDC discovery + JWKS stay cached before
// a background refresh. Default: 10m.
DiscoveryTTL time.Duration
// HTTPClient is used to call discovery / JWKS / token endpoints.
HTTPClient *http.Client
// Now is the clock. Default: time.Now.
Now func() time.Time
// Secure=true sets the Secure flag on cookies (prod); tests leave it false.
Secure bool
// LoginPath and CallbackPath are where Register() mounts the handlers.
// Defaults: /login and /callback.
LoginPath string
CallbackPath string
// DefaultNext is where a successful login redirects when no ?next was set.
// Default: "/admin/per-diem-rates/".
DefaultNext string
Logger *slog.Logger
}
// Middleware is the Pocket-ID verifier + handler bundle. Construct with New.
type Middleware struct {
cfg Config
aead cipher.AEAD
mu sync.Mutex
discovery discoveryDoc
jwks map[string]*rsa.PublicKey
lastRefresh time.Time
}
type discoveryDoc struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSURI string `json:"jwks_uri"`
}
type jwk struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
Alg string `json:"alg"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
}
type jwksDoc struct {
Keys []jwk `json:"keys"`
}
// Session is the payload sealed into the session cookie. Groups is retained so
// policy middlewares downstream can enforce finer-grained checks without a
// round-trip to Pocket-ID.
type Session struct {
Sub string `json:"sub"`
SID string `json:"sid"`
Groups []string `json:"grp,omitempty"`
ExpiresAt int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
}
// oidcState is the payload sealed into the short-lived state cookie between
// /login and /callback. It carries everything we need to validate the callback
// without server-side state.
type oidcState struct {
State string `json:"s"`
Nonce string `json:"n"`
CodeVerifier string `json:"v"`
Next string `json:"nx,omitempty"`
CreatedAt int64 `json:"iat"`
}
// New constructs a Middleware. It validates required Config fields and
// derives the AEAD from CookieSecret. Discovery/JWKS are loaded lazily on the
// first request.
func New(cfg Config) (*Middleware, error) {
if cfg.Issuer == "" {
return nil, errors.New("auth: Issuer is required")
}
if cfg.ClientID == "" {
return nil, errors.New("auth: ClientID is required")
}
if cfg.ClientSecret == "" {
return nil, errors.New("auth: ClientSecret is required")
}
if cfg.RedirectURL == "" {
return nil, errors.New("auth: RedirectURL is required")
}
if len(cfg.CookieSecret) != 32 {
return nil, errors.New("auth: CookieSecret must be exactly 32 bytes (AES-256)")
}
if cfg.RequiredGroup == "" {
cfg.RequiredGroup = "proofedmesh-admin"
}
if cfg.CookieName == "" {
cfg.CookieName = "proofedmesh_admin_session"
}
if cfg.StateCookieName == "" {
cfg.StateCookieName = "proofedmesh_admin_oidc_state"
}
if cfg.SessionTTL == 0 {
cfg.SessionTTL = 8 * time.Hour
}
if cfg.StateTTL == 0 {
cfg.StateTTL = 10 * time.Minute
}
if cfg.DiscoveryTTL == 0 {
cfg.DiscoveryTTL = 10 * time.Minute
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: 10 * time.Second}
}
if cfg.Now == nil {
cfg.Now = time.Now
}
if cfg.LoginPath == "" {
cfg.LoginPath = "/login"
}
if cfg.CallbackPath == "" {
cfg.CallbackPath = "/callback"
}
if cfg.DefaultNext == "" {
cfg.DefaultNext = "/admin/per-diem-rates/"
}
block, err := aes.NewCipher(cfg.CookieSecret)
if err != nil {
return nil, fmt.Errorf("auth: cookie cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("auth: cookie AEAD: %w", err)
}
return &Middleware{cfg: cfg, aead: aead}, nil
}
// Register wires LoginPath + CallbackPath onto mux.
func (m *Middleware) Register(mux *http.ServeMux) {
mux.HandleFunc("GET "+m.cfg.LoginPath, m.handleLogin)
mux.HandleFunc("GET "+m.cfg.CallbackPath, m.handleCallback)
}
// Protect wraps h so every request must present a valid session cookie that
// decrypts, has not expired, and whose groups include RequiredGroup. On any
// failure the response is a 302 to /login?next=<original path+query>.
func (m *Middleware) Protect(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, err := m.sessionFromCookie(r)
if err != nil {
m.redirectToLogin(w, r)
return
}
if !containsString(sess.Groups, m.cfg.RequiredGroup) {
// Session is otherwise valid but group membership has been
// revoked since login; force re-authentication rather than
// returning a stale 403.
m.redirectToLogin(w, r)
return
}
ctx := WithPrincipal(r.Context(), sess.Sub)
ctx = WithSession(ctx, sess.SID)
h.ServeHTTP(w, r.WithContext(ctx))
})
}
// SessionFromRequest exposes the authenticated session for callers that need
// the raw groups/claims (e.g. for logging). Returns nil + error on any
// validation failure.
func (m *Middleware) SessionFromRequest(r *http.Request) (*Session, error) {
return m.sessionFromCookie(r)
}
func (m *Middleware) handleLogin(w http.ResponseWriter, r *http.Request) {
disc, _, err := m.ensureDiscovery(r.Context())
if err != nil {
m.logError("discovery failed", err)
http.Error(w, "auth backend unavailable", http.StatusBadGateway)
return
}
state, err := randomURLString(24)
if err != nil {
http.Error(w, "state gen failed", http.StatusInternalServerError)
return
}
nonce, err := randomURLString(24)
if err != nil {
http.Error(w, "nonce gen failed", http.StatusInternalServerError)
return
}
codeVerifier, err := randomURLString(48)
if err != nil {
http.Error(w, "pkce gen failed", http.StatusInternalServerError)
return
}
sum := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(sum[:])
next := sanitizeNext(r.URL.Query().Get("next"), m.cfg.DefaultNext)
stPayload := oidcState{
State: state,
Nonce: nonce,
CodeVerifier: codeVerifier,
Next: next,
CreatedAt: m.cfg.Now().Unix(),
}
raw, err := json.Marshal(stPayload)
if err != nil {
http.Error(w, "state marshal failed", http.StatusInternalServerError)
return
}
sealed, err := m.seal(raw)
if err != nil {
http.Error(w, "state seal failed", http.StatusInternalServerError)
return
}
http.SetCookie(w, m.makeCookie(m.cfg.StateCookieName, sealed, int(m.cfg.StateTTL.Seconds())))
q := url.Values{
"client_id": {m.cfg.ClientID},
"redirect_uri": {m.cfg.RedirectURL},
"response_type": {"code"},
"scope": {"openid profile email groups"},
"state": {state},
"nonce": {nonce},
"code_challenge": {codeChallenge},
"code_challenge_method": {"S256"},
}
http.Redirect(w, r, disc.AuthorizationEndpoint+"?"+q.Encode(), http.StatusFound)
}
func (m *Middleware) handleCallback(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if errCode := q.Get("error"); errCode != "" {
http.Error(w, "oidc error: "+errCode, http.StatusUnauthorized)
return
}
stateParam := q.Get("state")
code := q.Get("code")
if stateParam == "" || code == "" {
http.Error(w, "missing state or code", http.StatusBadRequest)
return
}
ck, err := r.Cookie(m.cfg.StateCookieName)
if err != nil {
http.Error(w, "state cookie missing", http.StatusBadRequest)
return
}
raw, err := m.open(ck.Value)
if err != nil {
http.Error(w, "state cookie invalid", http.StatusBadRequest)
return
}
var st oidcState
if err := json.Unmarshal(raw, &st); err != nil {
http.Error(w, "state cookie invalid", http.StatusBadRequest)
return
}
if subtle.ConstantTimeCompare([]byte(stateParam), []byte(st.State)) != 1 {
http.Error(w, "state mismatch", http.StatusBadRequest)
return
}
now := m.cfg.Now()
if now.Unix()-st.CreatedAt > int64(m.cfg.StateTTL.Seconds()) {
http.Error(w, "state expired", http.StatusBadRequest)
return
}
// clear the state cookie now that it's been consumed
http.SetCookie(w, m.clearCookie(m.cfg.StateCookieName))
disc, keys, err := m.ensureDiscovery(r.Context())
if err != nil {
m.logError("discovery failed", err)
http.Error(w, "auth backend unavailable", http.StatusBadGateway)
return
}
idToken, err := m.exchangeCode(r.Context(), disc.TokenEndpoint, code, st.CodeVerifier)
if err != nil {
m.logError("token exchange failed", err)
http.Error(w, "token exchange failed", http.StatusBadGateway)
return
}
claims, err := verifyIDToken(idToken, keys, m.cfg.Issuer, m.cfg.ClientID, st.Nonce, now)
if err != nil {
m.logError("id_token verify failed", err)
http.Error(w, "id_token invalid", http.StatusUnauthorized)
return
}
groups := extractGroups(claims)
if !containsString(groups, m.cfg.RequiredGroup) {
http.Error(w, "forbidden: missing group "+m.cfg.RequiredGroup, http.StatusForbidden)
return
}
sub, _ := claims["sub"].(string)
sid, _ := claims["sid"].(string)
if sub == "" {
http.Error(w, "id_token missing sub", http.StatusUnauthorized)
return
}
if sid == "" {
// Pocket-ID emits `sid` for session binding, but a conforming OP MAY
// omit it. Synthesize a stable-enough id so attestations can group
// changes made under the same browser login.
sid = "local-" + randomStateFallback()
}
session := Session{
Sub: sub,
SID: sid,
Groups: groups,
IssuedAt: now.Unix(),
ExpiresAt: now.Add(m.cfg.SessionTTL).Unix(),
}
sessRaw, err := json.Marshal(session)
if err != nil {
http.Error(w, "session marshal failed", http.StatusInternalServerError)
return
}
sealed, err := m.seal(sessRaw)
if err != nil {
http.Error(w, "session seal failed", http.StatusInternalServerError)
return
}
http.SetCookie(w, m.makeCookie(m.cfg.CookieName, sealed, int(m.cfg.SessionTTL.Seconds())))
next := sanitizeNext(st.Next, m.cfg.DefaultNext)
http.Redirect(w, r, next, http.StatusFound)
}
func (m *Middleware) redirectToLogin(w http.ResponseWriter, r *http.Request) {
q := url.Values{}
nx := r.URL.RequestURI()
if nx != "" && nx != "/" {
q.Set("next", nx)
}
target := m.cfg.LoginPath
if len(q) > 0 {
target += "?" + q.Encode()
}
http.Redirect(w, r, target, http.StatusFound)
}
func (m *Middleware) sessionFromCookie(r *http.Request) (*Session, error) {
ck, err := r.Cookie(m.cfg.CookieName)
if err != nil {
return nil, err
}
raw, err := m.open(ck.Value)
if err != nil {
return nil, err
}
var s Session
if err := json.Unmarshal(raw, &s); err != nil {
return nil, err
}
if m.cfg.Now().Unix() >= s.ExpiresAt {
return nil, errors.New("session expired")
}
return &s, nil
}
// exchangeCode calls the OP's token endpoint with client credentials via basic
// auth, PKCE verifier, and the authorization code. Returns the raw ID token.
func (m *Middleware) exchangeCode(ctx context.Context, tokenEndpoint, code, verifier string) (string, error) {
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {m.cfg.RedirectURL},
"code_verifier": {verifier},
}
req, err := http.NewRequestWithContext(ctx, "POST", tokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
req.SetBasicAuth(m.cfg.ClientID, m.cfg.ClientSecret)
resp, err := m.cfg.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("token endpoint %d: %s", resp.StatusCode, truncate(body, 256))
}
var tr struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
}
if err := json.Unmarshal(body, &tr); err != nil {
return "", fmt.Errorf("token response: %w", err)
}
if tr.IDToken == "" {
return "", errors.New("token response missing id_token")
}
return tr.IDToken, nil
}
// ensureDiscovery lazily (re)loads the OIDC discovery doc + JWKS. Callers get
// a snapshot of both so they do not need to hold the mutex themselves.
func (m *Middleware) ensureDiscovery(ctx context.Context) (discoveryDoc, map[string]*rsa.PublicKey, error) {
m.mu.Lock()
defer m.mu.Unlock()
now := m.cfg.Now()
if m.discovery.Issuer != "" && now.Sub(m.lastRefresh) < m.cfg.DiscoveryTTL {
return m.discovery, m.jwks, nil
}
disc, err := m.fetchDiscovery(ctx)
if err != nil {
return discoveryDoc{}, nil, err
}
keys, err := m.fetchJWKS(ctx, disc.JWKSURI)
if err != nil {
return discoveryDoc{}, nil, err
}
m.discovery = disc
m.jwks = keys
m.lastRefresh = now
return disc, keys, nil
}
func (m *Middleware) fetchDiscovery(ctx context.Context) (discoveryDoc, error) {
url := strings.TrimRight(m.cfg.Issuer, "/") + "/.well-known/openid-configuration"
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return discoveryDoc{}, err
}
resp, err := m.cfg.HTTPClient.Do(req)
if err != nil {
return discoveryDoc{}, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return discoveryDoc{}, fmt.Errorf("discovery %d", resp.StatusCode)
}
var doc discoveryDoc
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&doc); err != nil {
return discoveryDoc{}, err
}
if doc.AuthorizationEndpoint == "" || doc.TokenEndpoint == "" || doc.JWKSURI == "" {
return discoveryDoc{}, errors.New("discovery missing required endpoints")
}
// Enforce the OP's self-reported issuer matches what we were configured
// against, so a token minted by a *different* Pocket-ID never passes.
if doc.Issuer != "" && !strings.EqualFold(doc.Issuer, m.cfg.Issuer) {
return discoveryDoc{}, fmt.Errorf("discovery issuer %q != configured %q", doc.Issuer, m.cfg.Issuer)
}
return doc, nil
}
func (m *Middleware) fetchJWKS(ctx context.Context, jwksURL string) (map[string]*rsa.PublicKey, error) {
req, err := http.NewRequestWithContext(ctx, "GET", jwksURL, nil)
if err != nil {
return nil, err
}
resp, err := m.cfg.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("jwks %d", resp.StatusCode)
}
var doc jwksDoc
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&doc); err != nil {
return nil, err
}
out := make(map[string]*rsa.PublicKey, len(doc.Keys))
for _, k := range doc.Keys {
if k.Kty != "RSA" {
continue
}
n, err := base64.RawURLEncoding.DecodeString(k.N)
if err != nil {
continue
}
e, err := base64.RawURLEncoding.DecodeString(k.E)
if err != nil {
continue
}
eInt := 0
for _, b := range e {
eInt = eInt<<8 | int(b)
}
if eInt == 0 {
continue
}
out[k.Kid] = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: eInt}
}
if len(out) == 0 {
return nil, errors.New("jwks: no usable RSA keys")
}
return out, nil
}
// verifyIDToken parses + validates a compact JWS ID token. It enforces alg
// RS256, matches kid against keys, verifies the signature, and asserts
// iss, aud, exp, nbf, and nonce. The returned map is the decoded payload.
func verifyIDToken(idToken string, keys map[string]*rsa.PublicKey, issuer, clientID, expectedNonce string, now time.Time) (map[string]any, error) {
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return nil, errors.New("jwt: not three segments")
}
headerRaw, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("jwt header b64: %w", err)
}
payloadRaw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("jwt payload b64: %w", err)
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("jwt sig b64: %w", err)
}
var header struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
Typ string `json:"typ"`
}
if err := json.Unmarshal(headerRaw, &header); err != nil {
return nil, fmt.Errorf("jwt header: %w", err)
}
if header.Alg != "RS256" {
return nil, fmt.Errorf("jwt alg %q not supported", header.Alg)
}
key, ok := keys[header.Kid]
if !ok {
return nil, fmt.Errorf("jwt kid %q not found", header.Kid)
}
signingInput := []byte(parts[0] + "." + parts[1])
sum := sha256.Sum256(signingInput)
if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], sig); err != nil {
return nil, fmt.Errorf("jwt signature: %w", err)
}
var claims map[string]any
if err := json.Unmarshal(payloadRaw, &claims); err != nil {
return nil, fmt.Errorf("jwt payload: %w", err)
}
if iss, _ := claims["iss"].(string); !strings.EqualFold(iss, issuer) {
return nil, fmt.Errorf("jwt iss %q != %q", iss, issuer)
}
if !audienceContains(claims["aud"], clientID) {
return nil, fmt.Errorf("jwt aud missing %q", clientID)
}
if expectedNonce != "" {
if nonce, _ := claims["nonce"].(string); nonce != expectedNonce {
return nil, errors.New("jwt nonce mismatch")
}
}
nowU := now.Unix()
// 30s skew in each direction keeps modestly out-of-sync IdPs usable.
const skew int64 = 30
if exp, ok := numericClaim(claims["exp"]); ok {
if nowU-skew >= exp {
return nil, errors.New("jwt expired")
}
} else {
return nil, errors.New("jwt missing exp")
}
if nbf, ok := numericClaim(claims["nbf"]); ok {
if nowU+skew < nbf {
return nil, errors.New("jwt not yet valid")
}
}
if iat, ok := numericClaim(claims["iat"]); ok {
if nowU+skew < iat {
return nil, errors.New("jwt iat in future")
}
}
return claims, nil
}
// --- helpers --------------------------------------------------------------
// numericClaim accepts both the JSON number and string-wrapped variants that
// some IdPs emit for `exp`/`iat` and returns the Unix timestamp.
func numericClaim(v any) (int64, bool) {
switch x := v.(type) {
case float64:
return int64(x), true
case int64:
return x, true
case int:
return int64(x), true
case string:
var n int64
if _, err := fmt.Sscanf(x, "%d", &n); err == nil {
return n, true
}
}
return 0, false
}
// audienceContains handles aud as either a single string or an array.
func audienceContains(v any, want string) bool {
switch x := v.(type) {
case string:
return x == want
case []any:
for _, a := range x {
if s, ok := a.(string); ok && s == want {
return true
}
}
}
return false
}
// extractGroups collects the groups claim. Pocket-ID emits `groups` as a
// JSON array of strings per the custom-claim mapping configured in the admin
// UI. We also accept the Keycloak-style `roles` fallback so operators who
// re-use an existing OIDC client still work.
func extractGroups(claims map[string]any) []string {
for _, key := range []string{"groups", "roles"} {
raw, ok := claims[key]
if !ok {
continue
}
arr, ok := raw.([]any)
if !ok {
continue
}
out := make([]string, 0, len(arr))
for _, v := range arr {
if s, ok := v.(string); ok {
out = append(out, s)
}
}
if len(out) > 0 {
return out
}
}
return nil
}
func containsString(xs []string, want string) bool {
for _, x := range xs {
if x == want {
return true
}
}
return false
}
func truncate(b []byte, n int) string {
if len(b) <= n {
return string(b)
}
return string(b[:n]) + "…"
}
// sanitizeNext filters the ?next= hint so an attacker cannot smuggle an
// absolute off-domain URL through the login redirect. Only in-site paths that
// start with a single leading slash and no scheme/host are accepted.
func sanitizeNext(raw, fallback string) string {
if raw == "" {
return fallback
}
// reject protocol-relative (//evil.com/...) and schemed URLs outright.
if strings.HasPrefix(raw, "//") {
return fallback
}
if !strings.HasPrefix(raw, "/") {
return fallback
}
u, err := url.Parse(raw)
if err != nil {
return fallback
}
if u.Scheme != "" || u.Host != "" {
return fallback
}
return raw
}
// --- cookie sealing -------------------------------------------------------
func (m *Middleware) seal(plain []byte) (string, error) {
nonce := make([]byte, m.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ct := m.aead.Seal(nil, nonce, plain, nil)
buf := make([]byte, 0, 2+len(nonce)+len(ct))
// 2-byte big-endian nonce length prefix keeps us robust if NonceSize ever
// changes under a future cipher upgrade.
lp := make([]byte, 2)
binary.BigEndian.PutUint16(lp, uint16(len(nonce)))
buf = append(buf, lp...)
buf = append(buf, nonce...)
buf = append(buf, ct...)
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func (m *Middleware) open(enc string) ([]byte, error) {
raw, err := base64.RawURLEncoding.DecodeString(enc)
if err != nil {
return nil, err
}
if len(raw) < 2 {
return nil, errors.New("cookie: truncated")
}
nl := int(binary.BigEndian.Uint16(raw[:2]))
if len(raw) < 2+nl {
return nil, errors.New("cookie: truncated nonce")
}
nonce := raw[2 : 2+nl]
ct := raw[2+nl:]
return m.aead.Open(nil, nonce, ct, nil)
}
func (m *Middleware) makeCookie(name, value string, maxAgeSec int) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: maxAgeSec,
HttpOnly: true,
Secure: m.cfg.Secure,
SameSite: http.SameSiteLaxMode,
}
}
func (m *Middleware) clearCookie(name string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: m.cfg.Secure,
SameSite: http.SameSiteLaxMode,
}
}
func (m *Middleware) logError(msg string, err error) {
if m.cfg.Logger != nil {
m.cfg.Logger.Error("auth: "+msg, "err", err)
}
}
func randomURLString(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func randomStateFallback() string {
s, err := randomURLString(16)
if err != nil {
return "x"
}
return s
}