package middleware import ( "context" "net/http" "strings" "github.com/google/uuid" ) // SessionResolver is the contract Auth depends on to translate a bearer // token into an authenticated user ID. Defining the dependency as an // interface here (rather than importing user.Service directly) keeps the // transport layer free of any concrete user-package import, which is // essential because the user module is implemented in a later phase and // this middleware must be writeable and testable without it. // // Implementations must: // - return (uid, true, nil) on a valid session, // - return (uuid.Nil, false, nil) for a syntactically valid but unknown // or expired token (the middleware treats this the same as an error // for the client: 401), // - return a non-nil error for unexpected failures (e.g. database // outage); the middleware also returns 401 to avoid leaking which // branch failed. type SessionResolver interface { ResolveSession(ctx context.Context, token string) (userID uuid.UUID, ok bool, err error) } // AuthOptions configures the Auth middleware. A zero value means // "authentication required". Optional=true is intended for endpoints // where an anonymous caller is allowed (e.g. a public landing page that // personalises content when a user happens to be signed in). // // The struct exists rather than a bare bool so future extensions // (RBAC scope checks, multi-realm support, etc.) can be added without // breaking call sites. type AuthOptions struct { // Optional, when true, lets requests without an Authorization header // pass through with no user_id in the context. Requests that DO carry // a token are still validated; an invalid token is rejected even when // Optional is set, so a forged token cannot bypass enforcement. Optional bool } // userIDKey is the context key used to attach the authenticated user ID // to the request context. It deliberately reuses the ctxKey type defined // in request_id.go (value 1) but with a distinct integer value (2) so // keys cannot collide while still keeping the type unexported to the // middleware package. const userIDKey ctxKey = 2 // Auth returns HTTP middleware that enforces bearer token authentication // using the supplied SessionResolver. On success the resolved user ID is // stored in the request context and downstream handlers can retrieve it // via UserIDFromContext. // // Failure modes all collapse to a 401 response with a small JSON body so // the client cannot distinguish between "missing token", "malformed // header", "unknown token" and "resolver failure" — this is intentional // to limit the information available to attackers probing for valid // tokens. func Auth(resolver SessionResolver, opt AuthOptions) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := extractBearer(r) if token == "" { if opt.Optional { next.ServeHTTP(w, r) return } http.Error(w, `{"code":"unauthorized","message":"missing token"}`, http.StatusUnauthorized) return } uid, ok, err := resolver.ResolveSession(r.Context(), token) if err != nil || !ok { http.Error(w, `{"code":"unauthorized","message":"invalid token"}`, http.StatusUnauthorized) return } ctx := context.WithValue(r.Context(), userIDKey, uid) next.ServeHTTP(w, r.WithContext(ctx)) }) } } // UserIDFromContext returns the authenticated user ID stored in ctx by // the Auth middleware. The boolean is false when no Auth middleware ran // upstream or the request was admitted via AuthOptions.Optional with no // token, so callers can distinguish "anonymous" from "logged-in user // whose ID happens to be the zero UUID". func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) { v, ok := ctx.Value(userIDKey).(uuid.UUID) return v, ok } // extractBearer parses an Authorization header of the form "Bearer " // and returns the trimmed token. It returns an empty string when the // header is missing or does not start with the canonical "Bearer " // prefix; case-insensitive matching is intentionally NOT performed // because RFC 6750 specifies the literal "Bearer" scheme name and being // strict here keeps the parsing surface small. // // For browser-native WebSocket connections, custom HTTP headers cannot be // sent during the handshake. Two fallbacks are supported, in order of // preference: // 1. Sec-WebSocket-Protocol: the client sends a subprotocol entry // "acw-token.". The token is extracted from the header, which is // not logged by proxies/servers as readily as a URL query parameter. // 2. ?token= query parameter (legacy): kept for backward compatibility. func extractBearer(r *http.Request) string { h := r.Header.Get("Authorization") const prefix = "Bearer " if strings.HasPrefix(h, prefix) { return strings.TrimSpace(h[len(prefix):]) } const wsTokenPrefix = "acw-token." for _, p := range strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",") { p = strings.TrimSpace(p) if strings.HasPrefix(p, wsTokenPrefix) { return p[len(wsTokenPrefix):] } } if q := r.URL.Query().Get("token"); q != "" { return q } return "" }