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=. 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 }