Files
pad/internal/server/middleware_auth.go
T
xarmian d36f27c29f fix(server): auth-perimeter hardening — B6–B9 from the IDEA-1927 audit (TASK-1932) (#811)
* fix(server): stop autoCreateWorkspace from swallowing member-add errors (B6, TASK-1932)

A failed AddWorkspaceMember after workspace creation used to be silently
discarded, leaving a workspace that's completely unreachable (owner_id
alone grants no access) and invisible in the console forever. Retry once,
then clean up the orphaned workspace and log loudly on continued failure
so on-call can act on it.

* fix(server): fail fast when cloud mode runs without secure cookies (B7, TASK-1932)

SetCloudMode never forced secureCookies on, so PAD_CLOUD=true without
PAD_SECURE_COOKIES was an unenforced ops contract: OAuth's __Host-prefixed
session cookie is silently invisible to pad's own cookie reader without
Secure set, producing a "logged in but appears logged out" failure mode.
Add Config.ValidateCloudSecureCookies and check it at server startup,
next to the existing PAD_CLOUD_SECRET requirement, so the misconfiguration
is a startup error instead of a runtime mystery.

* fix(server): align OAuth session TTL with web session TTL (B9, TASK-1932)

handleOAuthLogin minted a 30-day session while every other web login used
the 7-day webSessionTTL. createAuthSession derives the store session row,
session cookie MaxAge, and CSRF cookie MaxAge all from one ttl argument,
so the longer OAuth cookie outlived its own server-side session — the
browser kept presenting a cookie whose session had already expired,
producing silent 401s. Use webSessionTTL for OAuth logins too.

* fix(server): narrow the /api/v1/auth/* CSRF exemption to anonymous endpoints (B8, TASK-1932)

The CSRF middleware exempted the entire /api/v1/auth/ prefix, which also
covered mutating cookie-authenticated endpoints: PATCH /me, oauth-unlink,
2FA setup/verify/disable, delete-account, token create/delete/rotate, CLI-
session approve, and logout. Replace the prefix bypass with an exact-path
allowlist of the endpoints that are genuinely pre-session (login, register,
bootstrap, password reset, verify-email, resend-verification, 2FA login
challenge, CLI session create) or authenticate purely via a cloud secret
rather than a cookie (oauth-login, oauth-link — never touch the session,
so CSRF isn't a meaningful threat model for them and the sidecar has no
CSRF cookie to send). Everything else now requires the double-submit token
like any other authenticated mutation; the web client already sends it on
every non-GET/HEAD request, so no frontend change is needed.

* docs(server): pin the deliberate CSRF-cookie legacy-fallback asymmetry (TASK-1932)

Codex review (round 1) flagged that SessionAuth falls back from the
__Host-pad_session cookie to the legacy unprefixed name, but the CSRF
cookie lookup has no equivalent fallback — meaning a browser holding
pre-secure-cookies-flip legacy cookies stays authenticated but gets 403'd
on B8's newly CSRF-required endpoints until it re-logs-in.

This asymmetry is deliberate, not a bug: the session cookie's value is an
unguessable secret regardless of which name carries it, but the CSRF
cookie's security property depends on the attacker being unable to set
the cookie itself — an unprefixed name is settable from a sibling
subdomain, which is exactly the hole __Host- exists to close. Restoring
"symmetry" here would silently reopen it. Document the reasoning at the
cookie lookup so a future maintainer doesn't "fix" it, and add a pinning
test that exercises the exact scenario (secureCookies=true, legacy
session + CSRF cookies, CSRF-required endpoint) end to end.

* fix(server): require CSRF for session-authenticated requests to exempt auth paths (TASK-1932)

Codex round 2 found a P1: handleRegister has an admin-session branch (an
already-logged-in admin can create a verified account with no invitation
code), but /api/v1/auth/register was unconditionally CSRF-exempt by path.
A cross-site POST could ride the admin's cookie into that branch with no
CSRF token — the same class of hole as the oauth-unlink case B8 already
closed, just missed because register's other paths are genuinely
anonymous.

Fix generically rather than register-specifically: gate the
authCSRFExemptPaths exemption on currentUser(r) == nil. SessionAuth runs
before CSRFProtect, so a request that resolved to a real session falls
through to the normal double-submit check instead of the early exemption,
while a genuinely anonymous request keeps it. This also covers any future
session-authenticated branch a handler on this list grows, with no
handler changes. Bearer/PAT and cloud-secret (oauth-login/oauth-link)
callers are unaffected — they have their own unconditional exemptions
later in the same function.

* fix(server): require validated Bearer/cloud-secret auth for CSRF exemption (TASK-1932)

Codex round 3 found that CSRFProtect's Bearer and X-Cloud-Secret exemptions
fired on header/marker PRESENCE, not validation. TokenAuth deliberately
falls through (rejectInvalidBearer) instead of 401ing invalid Bearers on
/api/v1/auth/* paths to support CLI-token recovery, so a cross-site request
carrying a victim's real session cookie plus a garbage Bearer header could
ride the cookie past CSRF on any newly-CSRF-required endpoint. The same
presence-only pattern in the X-Cloud-Secret exemption is concretely
exploitable too: handleSetPlan (and similarly-shaped handlers) accept an
admin cookie session as an alternative to the secret, so a garbage
X-Cloud-Secret plus a stolen admin cookie could set an arbitrary user's
plan with no CSRF token at all.

Add ctxValidatedSessionBearer (set by TokenAuth only on successful
ValidateSession for CLI session-bearer tokens) alongside the existing
ctxIsAPIToken, and a combined isValidatedBearerAuth() helper. CSRFProtect
now exempts unconditionally only on validated Bearer auth; an unvalidated
Bearer header or cloud-secret marker is exempt only when no session was
also resolved for the request (currentUser(r) == nil), preserving the
CLI-recovery contract (stale token, no cookie -> 401 from auth, not
csrf_error) while closing the cookie-riding case.

* fix(server): split CSRF auth-exempt allowlist by session sensitivity (TASK-1932)

Codex round 2 gated the entire authCSRFExemptPaths allowlist on
currentUser(r) == nil to close handleRegister's admin-session branch, but
that gate applied to every anonymous endpoint on the list, not just
register. CI's E2E suite caught the regression: the harness bootstraps an
admin (minting a session cookie) then POSTs /login to re-authenticate,
and the ambient cookie stripped /login of its exemption, producing a
spurious 403 csrf_error.

login/bootstrap/forgot-password/reset-password/local-reset/verify-email/
resend-verification/2fa-login-verify/oauth-login/oauth-link/cli-sessions-
create derive their authority entirely from the request body (credentials,
a token, a shared secret), never from the ambient cookie, and pad mints
the CSRF cookie AT LOGIN — a pre-session endpoint categorically cannot
require a token that doesn't exist yet. Split the allowlist:
authCSRFUnconditionalExemptPaths (everything above, exempt regardless of
cookie) and authCSRFSessionGatedExemptPaths (register only, exempt only
when currentUser(r) == nil, since it alone has a session-privileged
admin-account-creation branch). The round-2 security property (admin
session + register + no CSRF -> still blocked) and round-3's Bearer/
cloud-secret validated-vs-present composite are unaffected.
2026-07-04 11:33:18 -04:00

1095 lines
44 KiB
Go

package server
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log/slog"
"net"
"net/http"
"strings"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
"github.com/go-chi/chi/v5"
)
type contextKey string
const (
// ctxTokenWorkspaceID is set when a valid API token is present.
ctxTokenWorkspaceID contextKey = "token_workspace_id"
// ctxCurrentUser is set when an authenticated user is resolved.
ctxCurrentUser contextKey = "current_user"
// ctxWorkspaceRole is set by RequireWorkspaceAccess with the user's role.
ctxWorkspaceRole contextKey = "workspace_role"
// ctxIsAPIToken is set to true when the request is authenticated via an API token
// (as opposed to a session cookie or CLI session token).
ctxIsAPIToken contextKey = "is_api_token"
// ctxTokenScopes carries the JSON-encoded scopes string from the
// validated API token (e.g. `["read"]`, `["*"]`). Stashed by
// MCPBearerAuth so the in-process MCP dispatcher can re-check
// scopes per synthesized tool call (the dispatcher bypasses
// TokenAuth's chain-level scope check by setting WithCurrentUser
// directly). See WithTokenScopes / TokenScopesFromContext in
// context.go and the per-tool gate in internal/mcp/dispatch_http.go.
ctxTokenScopes contextKey = "token_scopes"
// ctxResolvedWorkspaceID is set by RequireWorkspaceAccess after resolving
// the workspace slug/ID. Avoids redundant lookups in handlers.
ctxResolvedWorkspaceID contextKey = "resolved_workspace_id"
// ctxTokenAllowedWorkspaces carries the OAuth token's workspace
// allow-list set at consent time (TASK-952). Either a list of
// slugs or `["*"]` (wildcard). Read by RequireWorkspaceAccess to
// reject requests against workspaces the user didn't include in
// the consent (TASK-953). nil → no token-level workspace
// constraint (PAT auth, or pre-TASK-952 OAuth tokens).
ctxTokenAllowedWorkspaces contextKey = "token_allowed_workspaces"
// ctxMCPTokenKind / ctxMCPTokenRef carry the bearer's identity for
// MCP audit logging (TASK-960). Stashed by MCPBearerAuth so the
// audit middleware (middleware_mcp_audit.go) can record which
// connection (OAuth request_id chain) or which PAT (api_tokens.id)
// drove the call. Empty when no MCP-auth happened (i.e. the request
// didn't go through MCPBearerAuth). The ref values aren't sensitive
// — they're internal IDs / chain identifiers, never the raw bearer
// — so leaking via context is fine.
ctxMCPTokenKind contextKey = "mcp_token_kind"
ctxMCPTokenRef contextKey = "mcp_token_ref"
// ctxValidatedSessionBearer is set to true when the request was
// authenticated via a validated CLI session-bearer token
// (Authorization: Bearer padsess_...), as distinct from ctxIsAPIToken
// (a long-lived PAT) and from a session cookie. Deliberately a
// separate key rather than reusing ctxIsAPIToken: ctxIsAPIToken also
// gates PAT-vs-interactive-session behavior elsewhere (e.g. 2FA
// management requires an interactive session and explicitly rejects
// isAPITokenAuth callers) — a CLI session-bearer token IS an
// interactive session and must not trip that gate the way a PAT
// does. CSRFProtect reads this (via isValidatedBearerAuth) to tell
// "TokenAuth validated this Bearer credential" apart from "the
// request merely carries an Authorization: Bearer header TokenAuth
// never checked or rejected" — see the codex-round-3 fix note there
// (TASK-1932).
ctxValidatedSessionBearer contextKey = "validated_session_bearer"
)
// TokenAuth middleware checks for an Authorization: Bearer pad_xxx header.
// If a valid token is found, the associated workspace ID is stored in the
// request context. If no token header is present the request passes through
// unchanged (existing localhost behaviour). Invalid or expired tokens
// receive a 401 response — UNLESS the request targets a path in
// isPublicAPIPath (login, /auth/session, password reset, share links,
// health), in which case the request falls through unauthenticated so
// the caller can recover from a stale token. Without that fallthrough a
// developer with a stale `~/.pad/credentials.json` (e.g. after wiping a
// test DB) would see "Invalid or expired session" on every CLI command,
// including the very endpoints needed to log in again. See BUG-1227 and
// the matching IP-change-revoked branch below for the same pattern.
func (s *Server) TokenAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth == "" {
// No token provided — allow through (localhost access).
next.ServeHTTP(w, r)
return
}
// Expect "Bearer pad_<64 hex chars>" or "Bearer padsess_<64 hex chars>"
if !strings.HasPrefix(auth, "Bearer ") {
rejectInvalidBearer(w, r, next, "unauthorized", "Invalid authorization header format")
return
}
token := strings.TrimPrefix(auth, "Bearer ")
token = strings.TrimSpace(token)
// Session token (from CLI login)
if strings.HasPrefix(token, "padsess_") {
session, err := s.store.ValidateSession(token)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Session validation failed")
return
}
if session == nil {
rejectInvalidBearer(w, r, next, "unauthorized", "Invalid or expired session")
return
}
// Session binding: log a User-Agent change for visibility but do
// NOT reject. The UA is client-supplied and trivially replayed by
// anyone who already stole the token, so hard-enforcing it adds
// little security while breaking legitimate clients (browser/WebView
// updates, DevTools device emulation, mobile-app rebuilds). This
// mirrors the default IP-change handling (logged, request allowed);
// strict rejection on IP change is still available via
// PAD_IP_CHANGE_ENFORCE=strict.
if session.UAHash != "" && sha256hex(r.UserAgent()) != session.UAHash {
slog.Warn("session binding mismatch: User-Agent changed (logged, request allowed)",
"session_ip", session.IPAddress,
"client_ip", clientIP(r))
}
// Session binding: detect IP changes. Log to audit log in all modes;
// reject only when PAD_IP_CHANGE_ENFORCE=strict.
switch s.handleSessionIPChange(w, r, session, token) {
case sessionIPChangeTerminated:
return
case sessionIPChangeRevoked:
// Session was destroyed. For public API paths (login,
// password reset, health, share links) pass through
// unauthenticated so the caller can recover. For
// authenticated-only paths, the Bearer flow has no SPA
// fallback — treat revocation as a hard 401.
if isPublicAPIPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
writeError(w, http.StatusUnauthorized, "session_ip_changed",
"Session client IP changed — please log in again.")
return
}
// Sliding renewal: extend the session's expiry on activity so an
// actively-used token never hits the fixed TTL cliff. Bearer
// clients hold the token directly (no cookie to re-issue) — just
// push the row's expiry forward. Best-effort: a renewal error must
// not fail an otherwise-valid request.
if _, _, err := s.store.RenewSessionIfStale(token); err != nil {
slog.Warn("session renewal failed (request allowed)", "error", err)
}
ctx := context.WithValue(r.Context(), ctxCurrentUser, session.User)
ctx = context.WithValue(ctx, ctxValidatedSessionBearer, true)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// API token
if !strings.HasPrefix(token, "pad_") || len(token) != 68 {
rejectInvalidBearer(w, r, next, "unauthorized", "Invalid token format")
return
}
apiToken, err := s.store.ValidateToken(token)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Token validation failed")
return
}
if apiToken == nil {
rejectInvalidBearer(w, r, next, "unauthorized", "Invalid or expired token")
return
}
// Enforce token scopes
if !tokenScopeAllows(apiToken.Scopes, r.Method, r.URL.Path) {
writeError(w, http.StatusForbidden, "forbidden", "Token scope does not permit this action")
return
}
// Add near-expiry warning headers
setTokenExpiryWarning(w, apiToken)
ctx := context.WithValue(r.Context(), ctxIsAPIToken, true)
// Resolve user from token's user_id (new user-owned tokens)
if apiToken.UserID != "" {
user, err := s.store.GetUser(apiToken.UserID)
if err == nil && user != nil {
ctx = context.WithValue(ctx, ctxCurrentUser, user)
}
}
// Store workspace ID from the token if workspace-scoped
if apiToken.WorkspaceID != "" {
ctx = context.WithValue(ctx, ctxTokenWorkspaceID, apiToken.WorkspaceID)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// SessionAuth middleware resolves session cookies into authenticated users.
// If a user was already resolved by TokenAuth, this is a no-op.
func (s *Server) SessionAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Already authenticated by TokenAuth — user-bound API tokens set
// currentUser, legacy workspace-scoped tokens set tokenWorkspaceID
// with no user. In either case we must short-circuit: otherwise a
// stale session cookie on the same request could trigger the
// IP-change strict-mode path and 401 the request even though the
// API token itself is valid.
if currentUser(r) != nil || tokenWorkspaceID(r) != "" {
next.ServeHTTP(w, r)
return
}
// Try session cookie (with fallback to unprefixed name for upgrade path)
cookie, err := r.Cookie(sessionCookieName(s.secureCookies))
if err != nil {
cookie, err = r.Cookie("pad_session")
if err != nil {
next.ServeHTTP(w, r)
return
}
}
session, err := s.store.ValidateSession(cookie.Value)
if err != nil || session == nil {
next.ServeHTTP(w, r)
return
}
// Session binding: log a User-Agent change for visibility but do NOT
// de-authenticate. See the matching note in TokenAuth above — UA is
// client-supplied, weak as a binding, and false-positives on routine
// client churn (browser/WebView updates, DevTools device emulation,
// mobile-app rebuilds). Logged-but-allowed, like the default IP path.
if session.UAHash != "" && sha256hex(r.UserAgent()) != session.UAHash {
slog.Warn("session binding mismatch: User-Agent changed (logged, request allowed)",
"session_ip", session.IPAddress,
"client_ip", clientIP(r))
}
// Session binding: detect IP changes. Log to audit log in all modes;
// reject only when PAD_IP_CHANGE_ENFORCE=strict.
switch s.handleSessionIPChange(w, r, session, cookie.Value) {
case sessionIPChangeTerminated:
return
case sessionIPChangeRevoked:
// Browser path: let the request through unauthenticated so
// the SPA renders its login flow instead of a JSON error.
next.ServeHTTP(w, r)
return
}
// Sliding renewal: extend the session's expiry on activity and re-issue
// the session cookie (plus CSRF, to keep their lifetimes in sync) with a
// fresh Max-Age so an actively-used session never expires out from under
// the user at the fixed TTL. Best-effort: a renewal error must not fail
// an otherwise-valid request. When this re-issues the CSRF cookie it
// also covers the "missing CSRF cookie" case below.
renewed := false
if newExpiry, didRenew, err := s.store.RenewSessionIfStale(cookie.Value); err != nil {
slog.Warn("session renewal failed (request allowed)", "error", err)
} else if didRenew {
if maxAge := int(time.Until(newExpiry).Seconds()); maxAge > 0 {
setSessionCookie(w, cookie.Value, maxAge, s.secureCookies)
if !strings.HasPrefix(r.URL.Path, "/api/v1/auth/") {
setCSRFCookie(w, maxAge, s.secureCookies)
}
renewed = true
}
}
// Re-issue CSRF cookie if the session is valid but the cookie is missing.
// This can happen when cookies expire at different times or are selectively cleared.
// Skip for auth endpoints — they manage their own CSRF cookies (login sets, logout clears).
// Skip when sliding renewal already re-issued the CSRF cookie above.
if !renewed && !strings.HasPrefix(r.URL.Path, "/api/v1/auth/") {
if _, csrfErr := r.Cookie(csrfCookieName(s.secureCookies)); csrfErr != nil {
setCSRFCookie(w, 7*24*60*60, s.secureCookies)
}
}
ctx := context.WithValue(r.Context(), ctxCurrentUser, session.User)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// rejectInvalidBearer writes a 401 with the given code/message UNLESS
// the request targets a path in isPublicAPIPath, in which case it falls
// through to the next handler unauthenticated. The fallthrough is the
// recovery hatch that lets a stale Bearer token still reach
// /api/v1/auth/session, /login, /forgot-password, etc. — without it a
// stale `~/.pad/credentials.json` (e.g. after wiping a test DB) would
// 401 every CLI command, including the ones needed to recover. Mirrors
// the existing isPublicAPIPath fallthrough in the IP-change-revoked
// branch (BUG-1227).
func rejectInvalidBearer(w http.ResponseWriter, r *http.Request, next http.Handler, code, message string) {
if isPublicAPIPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
writeError(w, http.StatusUnauthorized, code, message)
}
// isPublicAPIPath reports whether the given request path is an API
// endpoint that bypasses authentication entirely. These handlers must
// work even when the caller has no valid session (login, registration,
// password reset, health probes, public plan limits, share-link tokens).
// Shared between RequireAuth and the session-IP-change path so both stay
// in sync — in particular, strict IP-change enforcement must NOT 401
// these endpoints, otherwise a user with a stale cookie can't even
// recover by logging in again.
func isPublicAPIPath(path string) bool {
return strings.HasPrefix(path, "/api/v1/auth/") ||
path == "/api/v1/health" ||
strings.HasPrefix(path, "/api/v1/health/") ||
strings.HasPrefix(path, "/api/v1/s/") ||
path == "/api/v1/plan-limits" ||
// Non-consuming invitation preview (BUG-1934). A logged-out invitee
// hits /join/{code} before authenticating, and the page fetches the
// invited email + workspace name to prefill the form. Read-only and
// always-200 (enumeration-safe); the accept path (/accept) stays
// auth-gated. Matches only the trailing /preview segment so
// /invitations/{code}/accept is unaffected.
(strings.HasPrefix(path, "/api/v1/invitations/") && strings.HasSuffix(path, "/preview")) ||
// Server capability profile (TASK-878). The editor reads this
// before login on shared-item preview surfaces, and the
// handler is intentionally read-only (no DB writes, no
// per-user state). Keeping it public matches the route's
// register-time intent in server.go.
path == "/api/v1/server/capabilities"
}
// RequireAuth middleware blocks unauthenticated requests when users exist
// in the system. When no users exist (fresh install), all requests pass
// through to allow the setup flow.
func (s *Server) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Auth endpoints, share link resolution, health, and the public
// plan-limits endpoint are always exempt from auth.
if isPublicAPIPath(path) {
next.ServeHTTP(w, r)
return
}
// Cloud sidecar endpoints authenticate via cloud_secret (X-Cloud-Secret
// header, or legacy ?cloud_secret query-param). Only bypass the auth
// gate when BOTH conditions hold:
// 1. The request path is one of the three cloud admin endpoints.
// 2. The request carries a cloud-secret marker.
// The path gate is critical — without it, setting X-Cloud-Secret on
// any route (e.g. GET /api/v1/workspaces) would globally bypass auth.
// After the bypass, requireCloudMode (route-level) + validateCloudSecret
// (handler-level) still confirm cloud mode is actually on and that the
// secret matches.
if isCloudAdminPath(path) && hasCloudSecretMarker(r) {
next.ServeHTTP(w, r)
return
}
// Static assets are exempt
if strings.HasPrefix(path, "/_app/") ||
path == "/favicon.ico" ||
strings.HasSuffix(path, ".png") ||
strings.HasSuffix(path, ".svg") ||
strings.HasSuffix(path, ".ico") ||
strings.HasSuffix(path, ".webmanifest") ||
strings.HasSuffix(path, ".json") && !strings.HasPrefix(path, "/api/") {
next.ServeHTTP(w, r)
return
}
// If no users exist, allow everything (fresh install / setup mode)
count, err := s.store.UserCount()
if err != nil || count == 0 {
next.ServeHTTP(w, r)
return
}
// Already authenticated via token or session
if user := currentUser(r); user != nil {
if user.IsDisabled() {
writeError(w, http.StatusForbidden, "account_disabled", "Your account has been disabled. Contact an administrator.")
return
}
// Use a short-lived context so the write is cancelled if the DB is slow,
// preventing goroutine/connection buildup under load. Tracked via
// s.goAsync so Stop() can drain it before the DB is closed (BUG-842).
s.goAsync(func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
s.store.TouchUserActivity(ctx, user.ID)
})
next.ServeHTTP(w, r)
return
}
if tokenWorkspaceID(r) != "" {
next.ServeHTTP(w, r)
return
}
// Unauthenticated — return 401 for API, let SPA handle for browser
if strings.HasPrefix(path, "/api/") {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
// For browser page requests, serve the SPA (frontend will check auth and show login)
next.ServeHTTP(w, r)
})
}
// tokenWorkspaceID returns the workspace ID set by the TokenAuth middleware,
// or an empty string if no token was used.
func tokenWorkspaceID(r *http.Request) string {
v, _ := r.Context().Value(ctxTokenWorkspaceID).(string)
return v
}
// currentUser returns the authenticated user from the request context,
// or nil if no user is authenticated.
func currentUser(r *http.Request) *models.User {
u, _ := r.Context().Value(ctxCurrentUser).(*models.User)
return u
}
// isAPITokenAuth returns true if the request was authenticated via an API token
// (not a session cookie or CLI session). Use this to gate sensitive operations
// like 2FA enrollment that should require an interactive session.
func isAPITokenAuth(r *http.Request) bool {
v, _ := r.Context().Value(ctxIsAPIToken).(bool)
return v
}
// isValidatedSessionBearerAuth returns true if the request was authenticated
// via a validated CLI session-bearer token (Authorization: Bearer
// padsess_...) — set by TokenAuth only on successful ValidateSession, never
// on the rejectInvalidBearer fallthrough. See ctxValidatedSessionBearer.
func isValidatedSessionBearerAuth(r *http.Request) bool {
v, _ := r.Context().Value(ctxValidatedSessionBearer).(bool)
return v
}
// isValidatedBearerAuth reports whether the request was authenticated via a
// Bearer credential TokenAuth actually validated — a long-lived API token
// (isAPITokenAuth) or a CLI session-bearer token
// (isValidatedSessionBearerAuth) — as opposed to merely carrying an
// Authorization: Bearer header that TokenAuth never checked, or checked and
// rejected (the rejectInvalidBearer fallthrough on public API paths). Both
// validated forms are unguessable secrets a CSRF attacker can't forge, so
// CSRFProtect treats them as exempt regardless of whether a session cookie
// is also present on the same request (TASK-1932, codex round 3).
func isValidatedBearerAuth(r *http.Request) bool {
return isAPITokenAuth(r) || isValidatedSessionBearerAuth(r)
}
// isBearerAuth reports whether the request was authenticated via an
// Authorization: Bearer header rather than a browser session cookie.
// True for: PATs on /api/v1, PATs on /mcp, OAuth bearers on /mcp, AND
// CLI session-bearer tokens (padsess_* delivered via Authorization:
// Bearer, see TokenAuth's session-bearer branch at line 92-132).
// False for: cookie-borne SessionAuth (the web UI / SPA path).
//
// Mirrors the dual signal used by middleware_csrf.go (line 73-80) —
// either an Authorization: Bearer header on the live request OR a
// ctxIsAPIToken stash on context counts as "non-browser caller."
//
// Used by RequireWorkspaceAccess (BUG-1616) to suppress the
// platform-admin global bypass: admin identity carries owner-level
// authority over every workspace ONLY for the cookie-borne web UI
// surface. Bearer-borne callers (CLI, PAT, MCP) get the strict
// workspace_members check — a leaked admin token, or an MCP client
// that picked "all current workspaces" at consent time, must not
// silently widen access from "workspaces the admin joined" to
// "every workspace on the server."
func isBearerAuth(r *http.Request) bool {
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
return true
}
return isAPITokenAuth(r)
}
// currentUserID returns the authenticated user's ID, or empty string.
func currentUserID(r *http.Request) string {
if u := currentUser(r); u != nil {
return u.ID
}
return ""
}
// RequireWorkspaceAccess checks that the current user is a member of the
// workspace identified by the {slug} URL parameter. The user's workspace
// role is stored in the request context for downstream permission checks.
//
// When no users exist (fresh install), access is granted with an implicit
// "owner" role. Legacy API tokens (workspace-scoped, no user) are allowed
// if the token's workspace matches the requested workspace.
func (s *Server) RequireWorkspaceAccess(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slugOrID := chi.URLParam(r, "slug")
if slugOrID == "" {
next.ServeHTTP(w, r)
return
}
// Resolve workspace: supports both UUID and slug.
ws, err := s.resolveWorkspace(slugOrID, currentUser(r))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve workspace")
return
}
if ws == nil {
writeError(w, http.StatusNotFound, "not_found", "Workspace not found")
return
}
// OAuth token allow-list gate (TASK-953). The consent UI
// (TASK-952) lets the user pick which workspaces a token
// can access; MCPBearerAuth stashes that list in context
// via WithTokenAllowedWorkspaces. Reject any request hitting
// a workspace outside the list, even if the user is a
// member of it — the user explicitly chose not to grant the
// app that access.
//
// nil → no token-level constraint (PAT auth, or pre-TASK-952
// OAuth tokens that predate the consent UI). Wildcard `["*"]`
// → grant access to any membership the user has. Else: the
// resolved workspace's slug MUST appear in the list.
//
// Compares against the canonical slug (ws.Slug) because the
// consent UI persists slugs and the URL slugOrID may be a
// UUID which resolveWorkspace just translated. Slug-vs-slug
// is the right comparison.
if !tokenAllowedWorkspaceMatches(r.Context(), ws.Slug) {
// TASK-961: count workspace-allow-list denials so the
// MCP dashboard can flag tokens hitting workspaces outside
// their consent scope. Gated on MCP-origin requests only
// (presence of MCP token identity in context) so non-MCP
// /api/v1 traffic — which can't even reach this gate
// today, but might in a future PAT-with-allow-list world
// — doesn't pollute the MCP-specific counter.
s.recordMCPAuthzDenial(r, "workspace_not_in_allowlist")
writeError(w, http.StatusForbidden, "permission_denied",
"Token is not authorized for this workspace")
return
}
// Store resolved workspace ID in context for downstream handlers
ctx := context.WithValue(r.Context(), ctxResolvedWorkspaceID, ws.ID)
// Fresh install: no users → everyone gets owner access
count, _ := s.store.UserCount()
if count == 0 {
ctx = context.WithValue(ctx, ctxWorkspaceRole, "owner")
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Legacy API token: workspace-scoped token without user context
if tokenWsID := tokenWorkspaceID(r); tokenWsID != "" && currentUser(r) == nil {
if tokenWsID == ws.ID {
ctx = context.WithValue(ctx, ctxWorkspaceRole, "editor")
next.ServeHTTP(w, r.WithContext(ctx))
return
}
writeError(w, http.StatusForbidden, "forbidden", "Token not authorized for this workspace")
return
}
// Authenticated user: check workspace membership
user := currentUser(r)
if user == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
// Admin users get owner access to all workspaces — but ONLY for
// browser cookie session auth (the web UI / SPA surface). For
// every bearer-borne path (PATs on /api/v1, PATs and OAuth
// bearers on /mcp, AND padsess_* session bearers from the CLI),
// the platform-admin global bypass is suppressed and the admin
// falls through to the membership-only check below.
//
// Rationale (BUG-1616): a token — including a CLI session
// bearer — carries the user's identity, not the user's
// platform-admin authority. The OAuth consent UI's "All
// current workspaces" option, and the CLI's general affordance
// of "log in once and address every workspace by slug," are
// both meant to mean "every workspace the user is a member of"
// — not "every workspace on the entire server, by virtue of
// the user holding the platform admin role." A leaked admin
// token, an MCP client that picked the wide consent scope, or
// even a CLI session running on a compromised machine, would
// otherwise reach data the admin never joined.
//
// The cookie-borne web UI keeps the bypass because the UI
// shows the admin's chosen workspace explicitly and the
// /console/admin surface is the documented spot to act
// cross-workspace; the deliberate UI affordance + same-origin
// cookie binding make that a different threat model from the
// programmatic surfaces.
//
// isBearerAuth folds two signals (Authorization: Bearer header
// OR ctxIsAPIToken stash) so MCP-dispatcher synthesized
// requests and CLI session bearers are both covered — see
// the helper's comment for the full table.
isBearer := isBearerAuth(r)
if user.Role == "admin" && !isBearer {
ctx = context.WithValue(ctx, ctxWorkspaceRole, "owner")
next.ServeHTTP(w, r.WithContext(ctx))
return
}
member, err := s.store.GetWorkspaceMember(ws.ID, user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check workspace access")
return
}
if member == nil {
// Admin-via-bearer (BUG-1616): membership-only. Skip the
// guest-grants fallback so a platform admin who happens to
// hold a guest grant on some workspace still can't reach
// it via a bearer-borne caller (CLI / PAT / MCP) —
// strictly the workspaces they're actually a member of.
// Falls through to the same not_a_member denial non-admin
// non-members hit.
if user.Role == "admin" && isBearer {
s.recordMCPAuthzDenial(r, "not_a_member")
writeError(w, http.StatusForbidden, "forbidden", "You are not a member of this workspace")
return
}
// Not a member — check for guest access via grants
hasGrants, grantErr := s.store.UserHasGrantsInWorkspace(ws.ID, user.ID)
if grantErr != nil {
slog.Error("failed to check guest grants", "workspace_id", ws.ID, "user_id", user.ID, "error", grantErr)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check workspace access")
return
}
if !hasGrants {
// TASK-961: not_a_member denial — counts only when the
// request originated from the /mcp surface (otherwise
// the regular /api/v1 403s would inflate the MCP-
// specific counter).
s.recordMCPAuthzDenial(r, "not_a_member")
writeError(w, http.StatusForbidden, "forbidden", "You are not a member of this workspace")
return
}
ctx = context.WithValue(ctx, ctxWorkspaceRole, "guest")
next.ServeHTTP(w, r.WithContext(ctx))
return
}
ctx = context.WithValue(ctx, ctxWorkspaceRole, member.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// workspaceRole returns the user's role in the current workspace,
// as set by RequireWorkspaceAccess. Returns empty string if not set.
func workspaceRole(r *http.Request) string {
v, _ := r.Context().Value(ctxWorkspaceRole).(string)
return v
}
// RecordMCPTierMismatch bumps the pad_mcp_authz_denials_total counter
// with reason="tier_mismatch" (TASK-1119). Wired from
// internal/mcp/dispatch_http.go's OnScopeDenied callback — every
// dispatcher invocation is by construction MCP-origin (only the /mcp
// surface routes through HTTPHandlerDispatcher), so unlike
// recordMCPAuthzDenial below this helper does NOT need a context
// gate.
//
// Exported for cross-package wiring (cmd/pad attaches it as the
// dispatcher observer at startup). The (method, urlPath) arguments
// match the dispatcher's callback signature so callers can wire with
// a one-liner; we currently ignore them because the metric only
// labels by reason.
//
// No-op when metrics aren't wired (selfhost / tests).
func (s *Server) RecordMCPTierMismatch(method, urlPath string) {
_ = method
_ = urlPath
if s.metrics == nil {
return
}
s.metrics.MCPAuthzDenialsTotal.WithLabelValues("tier_mismatch").Inc()
}
// recordMCPAuthzDenial bumps the pad_mcp_authz_denials_total counter
// when the request originated from the MCP surface. The discriminator
// is the presence of an MCPTokenIdentity in the request context —
// MCPBearerAuth stashes that for every authenticated /mcp request, and
// no other entry point sets it.
//
// Called from middleware that runs DOWNSTREAM of MCPBearerAuth (the
// in-process MCP dispatcher routes through the API handler tree, so
// /api/v1/* middleware sees the same context). Called sites must pass
// a reason string from the documented vocabulary in
// internal/metrics/metrics.go's MCPAuthzDenialsTotal comment.
//
// No-op when metrics aren't wired (selfhost / tests) or when the
// request didn't come through MCP — keeps the metric MCP-specific
// without forcing every caller to repeat the gate.
func (s *Server) recordMCPAuthzDenial(r *http.Request, reason string) {
if s.metrics == nil {
return
}
if kind, _ := MCPTokenIdentityFromContext(r.Context()); kind == "" {
return
}
s.metrics.MCPAuthzDenialsTotal.WithLabelValues(reason).Inc()
}
// requireRole checks if the user's workspace role meets the minimum
// required level. Role hierarchy: owner > editor > viewer.
func requireRole(r *http.Request, minRole string) bool {
role := workspaceRole(r)
if role == "" {
return false
}
return roleLevel(role) >= roleLevel(minRole)
}
// requireMinRole checks role and writes a 403 if insufficient.
// Returns true if the request should continue, false if it was rejected.
func requireMinRole(w http.ResponseWriter, r *http.Request, minRole string) bool {
if requireRole(r, minRole) {
return true
}
writeError(w, http.StatusForbidden, "forbidden", "Insufficient permissions")
return false
}
// roleLevel returns a numeric level for role comparison.
// Higher values indicate more permissions.
func roleLevel(role string) int {
switch role {
case "owner":
return 3
case "editor":
return 2
case "viewer":
return 1
case "guest":
return 0 // Guests have grant-based access only, no role-based permissions
default:
return 0
}
}
// permissionLevel returns a numeric level for grant permission comparison.
func permissionLevel(perm string) int {
switch perm {
case "owner":
return 4
case "admin":
return 3
case "editor":
return 2
case "edit":
return 2
case "viewer":
return 1
case "view":
return 1
default:
return 0
}
}
// sha256hex returns the SHA-256 hex digest of a string.
func sha256hex(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
// canonicalIP returns the semantic (canonical text) form of an IP
// address string. For IPv6 this collapses different valid spellings
// (compressed vs expanded, uppercase vs lowercase, leading zeroes) to
// a single representation so comparing two strings by == reflects
// actual IP equality. For IPv4 it accepts both 4-byte and IPv4-in-IPv6
// forms. For inputs that don't parse as an IP the original string is
// returned so behavior stays predictable on malformed/debug values.
func canonicalIP(s string) string {
if s == "" {
return s
}
if ip := net.ParseIP(s); ip != nil {
return ip.String()
}
return s
}
// sessionIPChangeOutcome captures what the caller (TokenAuth / SessionAuth)
// should do after handleSessionIPChange inspected the session.
type sessionIPChangeOutcome int
const (
// sessionIPChangeContinue — happy path. IP matches (or no recorded IP).
// Caller proceeds to install the session user in context and call next.
sessionIPChangeContinue sessionIPChangeOutcome = iota
// sessionIPChangeAllowedLogged — IP differs, log-only mode. Session
// remains valid, caller proceeds as usual.
sessionIPChangeAllowedLogged
// sessionIPChangeRevoked — strict mode. The session was destroyed and
// the caller must NOT install the user in context. For browser (non-API)
// paths the request should continue unauthenticated so the SPA can
// render its login screen instead of a raw JSON error.
sessionIPChangeRevoked
// sessionIPChangeTerminated — strict mode + API path. A 401 has already
// been written; caller must return immediately.
sessionIPChangeTerminated
)
// handleSessionIPChange compares the client IP on this request to the IP
// recorded when the session was created.
//
// Behavior:
// - Session has no recorded IP (legacy pre-migration row): continue.
// - IP matches: continue.
// - Log-only mode (default): atomically rotate the stored IP via
// compare-and-set. The race winner emits exactly one
// ActionSessionIPChanged audit row per transition — concurrent
// requests that lose the CAS skip logging to avoid duplicate rows.
// - Strict mode (PAD_IP_CHANGE_ENFORCE=strict): do NOT rotate the
// stored IP. Instead use DeleteSessionIfExists as the CAS primitive
// — the request that actually deletes the row (a) logs the audit
// entry once, (b) returns 401 for API / revoked for browser paths.
// Rotating the IP first would be unsafe: if the subsequent
// DeleteSession failed (transient DB error) the session would remain
// valid rebound to the new IP, defeating strict enforcement. By
// deleting atomically, any failure leaves the session bound to the
// original IP so a follow-up request from the new IP still mismatches
// and is still rejected. If the delete errors outright, the request
// is rejected with 500 so the client can't proceed.
//
// Log-only mode breaks fewer legitimate clients (mobile roaming, VPN
// toggles, carrier NAT) while still giving operators a visible signal.
func (s *Server) handleSessionIPChange(w http.ResponseWriter, r *http.Request, session *store.SessionInfo, plainToken string) sessionIPChangeOutcome {
// Canonicalize both sides so equivalent IPv6 representations
// (compressed vs expanded, case, leading zeros) don't register as a
// spurious change. Raw trusted-proxy X-Forwarded-For values can
// arrive in any valid form.
storedIP := canonicalIP(session.IPAddress)
newIP := canonicalIP(clientIP(r))
if storedIP == "" || newIP == "" || storedIP == newIP {
return sessionIPChangeContinue
}
userID := ""
if session.User != nil {
userID = session.User.ID
}
if s.ipChangeEnforceStrict {
// Destroy the session atomically. Only the caller whose DELETE
// affected a row logs and issues the "session_ip_changed" response
// body. A failed delete means the session is still valid AND still
// bound to the old IP (we skipped the rotation), so a follow-up
// request from the new IP will hit this path again — safe.
deleted, err := s.store.DeleteSessionIfExists(plainToken)
if err != nil {
// Fail closed: we can't prove the session is gone, so refuse
// to let the request through. A retry will converge once the
// DB recovers.
slog.Error("failed to destroy session on IP-change in strict mode; failing closed",
"session_ip", storedIP,
"client_ip", newIP,
"user_id", userID,
"error", err)
writeError(w, http.StatusInternalServerError, "internal_error",
"Unable to validate session. Please try again.")
return sessionIPChangeTerminated
}
if deleted {
s.logAuditEventForUser(models.ActionSessionIPChanged, r, userID, auditMeta(map[string]string{
"old_ip": storedIP,
"new_ip": newIP,
}))
slog.Warn("session destroyed: IP changed (strict enforcement)",
"session_ip", storedIP,
"client_ip", newIP,
"user_id", userID)
}
// Clear client-side cookies regardless — even if another request
// already deleted the row, this client is still holding the now-
// invalid token.
clearSessionCookie(w, s.secureCookies)
clearCSRFCookie(w)
// Public API paths (login/register/password-reset, health, share
// links, plan-limits) must STILL work after the session was
// destroyed — a user with a stale cookie needs a way back in, and
// Prometheus health probes shouldn't 401 because some other tab
// left a stale session. Pass the request through unauthenticated.
if isPublicAPIPath(r.URL.Path) {
return sessionIPChangeRevoked
}
if strings.HasPrefix(r.URL.Path, "/api/") {
writeError(w, http.StatusUnauthorized, "session_ip_changed",
"Session client IP changed — please log in again.")
return sessionIPChangeTerminated
}
// Non-API (browser) path: caller must NOT install the destroyed
// session's user into the request context. The SPA will render its
// unauth state and the user will redirect to login.
return sessionIPChangeRevoked
}
// Log-only mode: CAS-rotate the stored IP so only the winner logs.
// The CAS compares against session.IPAddress (the raw value we read
// from the DB) and writes the canonical newIP so future comparisons
// are consistent. Err on the side of "winner" when the CAS errors
// — we'd rather log an extra row than miss the signal entirely.
// A failed rotate here is non-fatal: worst case the next request
// also gets an audit row.
won, err := s.store.UpdateSessionIPIfEquals(plainToken, session.IPAddress, newIP)
if err != nil {
slog.Warn("failed to rotate session ip after change", "error", err)
won = true
}
if won {
s.logAuditEventForUser(models.ActionSessionIPChanged, r, userID, auditMeta(map[string]string{
"old_ip": storedIP,
"new_ip": newIP,
}))
slog.Info("session client IP changed (logged, request allowed)",
"session_ip", storedIP,
"client_ip", newIP,
"user_id", userID)
}
return sessionIPChangeAllowedLogged
}
// clearSessionCookie expires the session cookie on the client so a
// subsequent request doesn't keep presenting a now-revoked token.
// setSessionCookie (re)issues the session cookie with the given value and
// max-age (seconds). Used by the sliding-renewal path to push the cookie's
// lifetime forward in lockstep with the extended server-side expiry. The
// attributes mirror createAuthSession and clearSessionCookie so the cookie's
// identity (name/path/flags) stays stable across issue, renew, and clear.
func setSessionCookie(w http.ResponseWriter, value string, maxAge int, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName(secure),
Value: value,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
func clearSessionCookie(w http.ResponseWriter, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName(secure),
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// tokenAllowedWorkspaceMatches reports whether the OAuth token's
// workspace allow-list (set at consent time, TASK-952) permits the
// given workspace slug. The three return-shape cases match
// TokenAllowedWorkspacesFromContext:
//
// - nil — no allow-list set (PAT auth, or pre-TASK-952 tokens) →
// allow.
// - ["*"] — wildcard consent → allow.
// - [slug-a, slug-b, ...] — explicit allow-list → require slug ∈
// list.
//
// Empty (non-nil) slice — the SetAllowedWorkspaces guard rejects
// nil → empty translation, and the consent flow rejects
// `allowed_workspaces` with no entries (handlers_oauth.go's
// parseConsentPayload). So an empty list shouldn't appear in
// practice; if it does, fail closed (no slug matches an empty list).
//
// Used by RequireWorkspaceAccess; package-private because the
// allow-list semantics are coupled to that middleware's flow.
func tokenAllowedWorkspaceMatches(ctx context.Context, slug string) bool {
allowed := TokenAllowedWorkspacesFromContext(ctx)
if allowed == nil {
return true // no token-level gate
}
for _, entry := range allowed {
if entry == "*" {
return true
}
if entry == slug {
return true
}
}
return false
}
// tokenScopeAllows checks if the token's scopes permit the given HTTP method
// and path. Scopes are stored as a JSON array of strings.
//
// Supported scopes (PAT vocabulary):
// - "*" — full access
// - "read" — GET/HEAD/OPTIONS only
// - "write" — all methods
//
// Supported scopes (OAuth 2.1 / RFC 6749 vocabulary, sub-PR E TASK-1027):
// - "pad:read" — equivalent to PAT "read"
// - "pad:write" — equivalent to PAT "write"
// - "pad:admin" — equivalent to PAT "*"
//
// MCPBearerAuth's OAuth introspection branch translates fosite's
// space-separated scope string into the same JSON-array stash form
// PAT auth uses, so the same policy applies to both transports. This
// keeps MCP tool authorization centralized: a `pad:read` OAuth token
// can drive read tools; a `pad:write` OAuth token can drive any.
//
// Policy (deny-by-default whitelist):
// - Empty scope string → allow (legacy DB rows where the column was never
// populated). These tokens pre-date scope enforcement.
// - Empty JSON array `[]` → allow (explicit "unrestricted" legacy form,
// equivalent to no scopes recorded).
// - Parseable array with at least one scope: allow iff a RECOGNIZED scope
// grants this method. Unrecognized scopes never contribute to allow
// and are logged once per request so operators can spot typos like
// "read-only" that would previously have fallen open.
// - Unparseable JSON → deny (data corruption or tampering). Previously
// fell open; that was the security concern behind TASK-667.
//
// Switching to deny-by-default closes the hole where a future scope name
// like "read-only" would silently grant full access.
func tokenScopeAllows(scopesJSON, method, path string) bool {
_ = path // reserved for future per-resource scopes
// Empty string (legacy DB rows where the column was never populated)
// → full access. Same fast path for the explicit wildcard.
if scopesJSON == "" || strings.TrimSpace(scopesJSON) == `["*"]` {
return true
}
var scopes []string
if err := json.Unmarshal([]byte(scopesJSON), &scopes); err != nil {
slog.Warn("token has unparseable scopes; denying request",
"method", method, "path", path, "error", err)
return false
}
// Distinguish JSON null (scopes == nil after unmarshal) from an
// explicit empty array (non-nil, len 0). null falls into the deny
// path because it signals a client-side serializer bug, not a
// documented legacy form. An explicit [] (including whitespace-
// padded "[ ]" or "[\n]") is the documented legacy-unrestricted
// form and keeps working.
if scopes == nil {
slog.Warn("token has null scopes; denying request",
"method", method, "path", path, "scopes_json", scopesJSON)
return false
}
if len(scopes) == 0 {
// Explicit empty array → unrestricted (legacy pre-enforcement).
return true
}
allowed := false
var unknown []string
for _, scope := range scopes {
switch scope {
case "*", "write", "pad:write", "pad:admin":
allowed = true
case "read", "pad:read":
if method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions {
allowed = true
}
default:
unknown = append(unknown, scope)
}
}
if len(unknown) > 0 {
slog.Warn("token has unrecognized scopes; treated as no-grant",
"method", method, "path", path, "unknown_scopes", unknown)
}
return allowed
}