package oidc import ( "context" "errors" "html/template" "net/http" "net/url" "strconv" "strings" "sync" "time" "keycape/internal/domain" profileerrors "keycape/internal/errors" "keycape/internal/server/telemetry" ) // PendingState holds the authorization request parameters while the user is // being authenticated by the upstream provider (e.g. Authelia). It is keyed // by the opaque state value that is round-tripped through the upstream. type PendingState struct { ClientID string RedirectURI string PKCEChallenge string PKCEChallengeMethod string State string Nonce string Scopes []string ExpiresAt time.Time AuthenticatedUser string ACRValues []string TenantHint string MaxAge *time.Duration PromptLogin bool } // pendingStateStore is a thread-safe map of state → PendingState. type pendingStateStore struct { mu sync.Mutex store map[string]*PendingState } func newPendingStateStore() *pendingStateStore { return &pendingStateStore{store: make(map[string]*PendingState)} } func (p *pendingStateStore) Store(state string, ps *PendingState) { p.mu.Lock() p.store[state] = ps p.mu.Unlock() } func (p *pendingStateStore) Load(state string) (*PendingState, bool) { p.mu.Lock() ps, ok := p.store[state] p.mu.Unlock() return ps, ok } func (p *pendingStateStore) Delete(state string) { p.mu.Lock() delete(p.store, state) p.mu.Unlock() } // AuthorizeHandler implements GET /authorize and GET /authorize/callback. type AuthorizeHandler struct { ClientConfig map[string]*domain.Client Auth domain.AuthProvider MFA domain.MFAProvider Sessions *SessionStore Logins *LoginSessionStore Handoffs *HandoffStore Issuer string Emitter telemetry.Emitter pending *pendingStateStore once sync.Once } // PendingStates returns the underlying pending-state store so tests can seed it. func (h *AuthorizeHandler) PendingStates() *pendingStateStore { h.init() return h.pending } func (h *AuthorizeHandler) init() { h.once.Do(func() { if h.pending == nil { h.pending = newPendingStateStore() } if h.Logins == nil { h.Logins = NewLoginSessionStore() } if h.Handoffs == nil { h.Handoffs = NewHandoffStore() } }) } // ServeHTTP dispatches to the authorize or callback handler based on path. func (h *AuthorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.init() switch { case strings.HasSuffix(r.URL.Path, "/callback"): h.ServeHTTPCallback(w, r) case strings.HasSuffix(r.URL.Path, "/return"): h.serveHandoffReturn(w, r) case strings.HasSuffix(r.URL.Path, "/register"): h.serveRegisterFromPending(w, r) default: h.serveAuthorize(w, r) } } // serveAuthorize handles the initial GET /authorize request. func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request) { ctx := r.Context() q := r.URL.Query() clientID := q.Get("client_id") redirectURI := q.Get("redirect_uri") responseType := q.Get("response_type") scope := q.Get("scope") state := q.Get("state") nonce := q.Get("nonce") codeChallenge := q.Get("code_challenge") codeChallengeMethod := q.Get("code_challenge_method") acrValues := strings.Fields(q.Get("acr_values")) tenantHint := firstNonEmpty(q.Get("tenant_hint"), q.Get("tenant")) promptCreate, promptLogin := parsePrompt(q.Get("prompt")) maxAge, maxAgeErr := parseMaxAge(q.Get("max_age")) if maxAgeErr != nil { profileerrors.InvalidProfileUsage("max_age must be a non-negative integer", "max_age"). Write(w, http.StatusBadRequest) return } // Emit auth_start telemetry immediately. h.Emitter.Emit(ctx, telemetry.Event{ Timestamp: time.Now(), EventType: telemetry.EventAuthStart, ClientID: clientID, Endpoint: "/authorize", Result: "pending", }) // 1. Validate client_id. client, ok := h.ClientConfig[clientID] if !ok { profileerrors.InvalidProfileUsage("unknown client_id", "client_id"). Write(w, http.StatusBadRequest) return } // 2. Validate redirect_uri — check for wildcards first, then exact match. for _, registered := range client.RedirectURIs { if strings.ContainsAny(registered, "*?") { profileerrors.RejectedForSafety( "wildcard redirect URIs are not permitted", "redirect_uri", ).Write(w, http.StatusBadRequest) return } } if !uriRegistered(client.RedirectURIs, redirectURI) { profileerrors.InvalidProfileUsage( "redirect_uri does not match any registered URI", "redirect_uri", ).Write(w, http.StatusBadRequest) return } // 3. Validate response_type. if responseType != "code" { profileerrors.FeatureNotSupported( "only response_type=code is supported", "response_type="+responseType, ).Write(w, http.StatusBadRequest) return } // 4. Validate scope contains openid. if !scopeContains(scope, "openid") { profileerrors.InvalidProfileUsage( "scope must include openid", "scope", ).Write(w, http.StatusBadRequest) return } for _, requestedScope := range strings.Fields(scope) { if !containsString(client.AllowedScopes, requestedScope) { profileerrors.InvalidProfileUsage("requested scope is not allowed", "scope").Write(w, http.StatusBadRequest) return } } // 5. Validate code_challenge is present. if codeChallenge == "" { profileerrors.InvalidProfileUsage( "code_challenge is required (PKCE S256)", "code_challenge", ).Write(w, http.StatusBadRequest) return } // 6. Validate code_challenge_method. if codeChallengeMethod == "plain" { profileerrors.RejectedForSafety( "code_challenge_method=plain is rejected for security; use S256", "code_challenge_method", ).Write(w, http.StatusBadRequest) return } if codeChallengeMethod != "S256" { profileerrors.InvalidProfileUsage( "code_challenge_method must be S256", "code_challenge_method", ).Write(w, http.StatusBadRequest) return } // Store pending state so the callback can reconstruct the session. ps := &PendingState{ ClientID: clientID, RedirectURI: redirectURI, PKCEChallenge: codeChallenge, PKCEChallengeMethod: codeChallengeMethod, State: state, Nonce: nonce, Scopes: strings.Fields(scope), ACRValues: acrValues, TenantHint: tenantHint, MaxAge: maxAge, PromptLogin: promptLogin, ExpiresAt: time.Now().Add(10 * time.Minute), } h.pending.Store(state, ps) if promptCreate { h.startHandoff(w, r, ps, HandoffRegister) return } // Delegate to Auth provider. authURL, err := h.Auth.AuthorizeURL(ctx, domain.AuthRequest{ ClientID: clientID, RedirectURI: redirectURI, State: state, Scopes: strings.Fields(scope), PKCEChallenge: codeChallenge, PKCEChallengeMethod: codeChallengeMethod, }) if err != nil { http.Error(w, "upstream auth provider error", http.StatusBadGateway) return } http.Redirect(w, r, authURL, http.StatusFound) } // ServeHTTPCallback handles GET /authorize/callback. func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Request) { h.init() ctx := r.Context() if r.Method == http.MethodPost { h.serveMFASubmission(w, r) return } if r.Method != http.MethodGet { w.Header().Set("Allow", "GET, POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } q := r.URL.Query() state := q.Get("state") code := q.Get("code") mfaToken := q.Get("mfa_token") // Recover pending state keyed by state param. ps, ok := h.pending.Load(state) if !ok { http.Error(w, "unknown or expired state", http.StatusBadRequest) return } if time.Now().After(ps.ExpiresAt) { h.pending.Delete(state) http.Error(w, "authorization request expired", http.StatusBadRequest) return } // Handle upstream callback. result, err := h.Auth.HandleCallback(ctx, domain.CallbackParams{ Code: code, State: state, }) if err != nil || result == nil || result.Username == "" { h.Emitter.Emit(ctx, telemetry.Event{ Timestamp: time.Now(), EventType: telemetry.EventAuthFailure, ClientID: ps.ClientID, Endpoint: "/authorize/callback", Result: "failure", ErrorType: "auth_failed", }) if h.clientEligible(ps.ClientID, HandoffRegister) { h.renderUnknownUserSignup(w, ps) return } h.pending.Delete(state) http.Error(w, "authentication failed", http.StatusUnauthorized) return } decision, err := h.decideAssurance(ctx, ps, result.Username, h.Logins.fromRequest(r)) if err != nil { h.Emitter.Emit(ctx, telemetry.Event{ Timestamp: time.Now(), EventType: telemetry.EventAuthFailure, ClientID: ps.ClientID, Endpoint: "/authorize/callback", Result: "failure", ErrorType: "mfa_check_error", }) http.Error(w, "mfa check error", http.StatusInternalServerError) return } if decision.RequireMFA { if handed, herr := h.maybeEnrollmentHandoff(ctx, w, r, ps, result.Username); herr != nil { http.Error(w, "enrollment check error", http.StatusInternalServerError) return } else if handed { return } if mfaToken == "" { ps.AuthenticatedUser = result.Username h.pending.Store(state, ps) h.renderMFAChallenge(w, ps, "") return } if err := h.MFA.ValidateMFAToken(ctx, result.Username, mfaToken); err != nil { if errors.Is(err, domain.ErrMFANotEnrolled) { if handed, herr := h.maybeEnrollmentHandoff(ctx, w, r, ps, result.Username); herr != nil { http.Error(w, "enrollment check error", http.StatusInternalServerError) return } else if handed { return } } h.pending.Delete(state) h.emitMFAFailure(ctx, ps.ClientID) http.Error(w, "MFA validation failed", http.StatusUnauthorized) return } h.pending.Delete(state) h.completeAuthorization(w, r, ps, result.Username, true) return } h.pending.Delete(state) h.completeAuthorization(w, r, ps, result.Username, decision.MFAVerified) } func (h *AuthorizeHandler) decideAssurance(ctx context.Context, ps *PendingState, username string, login *LoginSession) (domain.AssuranceDecision, error) { client := h.ClientConfig[ps.ClientID] providerRequired := false if !domain.ACRRequiresAAL2(ps.ACRValues) && (client == nil || client.MFARequired == nil) { var err error providerRequired, err = h.MFA.CheckMFARequired(ctx, username) if err != nil { return domain.AssuranceDecision{}, err } } in := domain.AssuranceInput{ Client: client, ACRValues: ps.ACRValues, ProviderRequired: providerRequired, RequestUser: username, PromptLogin: ps.PromptLogin, MaxAge: ps.MaxAge, } if login != nil { in.SessionLevel = login.Level in.SessionUser = login.Username in.SessionIssuedAt = login.IssuedAt } return domain.DecideAssurance(in), nil } func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if err := r.ParseForm(); err != nil { http.Error(w, "invalid form", http.StatusBadRequest) return } state := r.Form.Get("state") mfaToken := r.Form.Get("mfa_token") ps, ok := h.pending.Load(state) if !ok { http.Error(w, "unknown or expired state", http.StatusBadRequest) return } if time.Now().After(ps.ExpiresAt) { h.pending.Delete(state) http.Error(w, "authorization request expired", http.StatusBadRequest) return } if ps.AuthenticatedUser == "" { h.pending.Delete(state) http.Error(w, "mfa challenge not active", http.StatusBadRequest) return } if strings.TrimSpace(mfaToken) == "" { h.renderMFAChallenge(w, ps, "Enter the one-time code.") return } if err := h.MFA.ValidateMFAToken(ctx, ps.AuthenticatedUser, mfaToken); err != nil { h.pending.Delete(state) h.emitMFAFailure(ctx, ps.ClientID) http.Error(w, "MFA validation failed", http.StatusUnauthorized) return } h.pending.Delete(state) // Reached only after ValidateMFAToken succeeded above -- MFA was // required and passed, unlike the callback path where mfaRequired may // be false. h.completeAuthorization(w, r, ps, ps.AuthenticatedUser, true) } func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.Request, ps *PendingState, username string, mfaVerified bool) { level := domain.AssuranceAAL1 if mfaVerified { level = domain.AssuranceAAL2 } if login := h.Logins.Create(username, level); login != nil { writeLoginCookie(w, login, issuerIsHTTPS(h.Issuer)) } // Generate authorization code and store PKCE session. sess := &PKCESession{ ClientID: ps.ClientID, RedirectURI: ps.RedirectURI, PKCEChallenge: ps.PKCEChallenge, PKCEChallengeMethod: ps.PKCEChallengeMethod, State: ps.State, Nonce: ps.Nonce, Username: username, Scopes: ps.Scopes, ExpiresAt: time.Now().Add(10 * time.Minute), MFAVerified: mfaVerified, } authCode := h.Sessions.Create(sess) h.Emitter.Emit(r.Context(), telemetry.Event{ Timestamp: time.Now(), EventType: telemetry.EventAuthSuccess, ClientID: ps.ClientID, Endpoint: "/authorize/callback", Result: "success", Scopes: ps.Scopes, }) // Redirect to client with code and state. redirectTo, err := url.Parse(ps.RedirectURI) if err != nil { http.Error(w, "invalid redirect_uri", http.StatusInternalServerError) return } q := redirectTo.Query() q.Set("code", authCode) q.Set("state", ps.State) redirectTo.RawQuery = q.Encode() http.Redirect(w, r, redirectTo.String(), http.StatusFound) } func (h *AuthorizeHandler) startHandoff(w http.ResponseWriter, r *http.Request, ps *PendingState, kind HandoffKind) { client, ok := h.ClientConfig[ps.ClientID] if !ok { profileerrors.InvalidProfileUsage("unknown client_id", "client_id"). Write(w, http.StatusBadRequest) return } dest := client.RegistrationURL if kind == HandoffEnroll { dest = client.EnrollmentURL } if dest == "" { profileerrors.RejectedForSafety( "client is not eligible for this handoff", string(kind), ).Write(w, http.StatusBadRequest) return } token, err := h.Handoffs.Issue(kind, ps) if err != nil { http.Error(w, "handoff error", http.StatusInternalServerError) return } loc, err := appendHandoff(dest, token) if err != nil { profileerrors.RejectedForSafety("handoff destination is not a valid URL", string(kind)). Write(w, http.StatusBadRequest) return } http.Redirect(w, r, loc, http.StatusFound) } func (h *AuthorizeHandler) serveRegisterFromPending(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.Header().Set("Allow", "GET") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } state := r.URL.Query().Get("state") ps, ok := h.pending.Load(state) if !ok { http.Error(w, "unknown or expired state", http.StatusBadRequest) return } if time.Now().After(ps.ExpiresAt) { h.pending.Delete(state) http.Error(w, "authorization request expired", http.StatusBadRequest) return } h.startHandoff(w, r, ps, HandoffRegister) } func (h *AuthorizeHandler) serveHandoffReturn(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.Header().Set("Allow", "GET") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } token := r.URL.Query().Get("kc_handoff") env, err := h.Handoffs.Consume(token) switch { case errors.Is(err, errHandoffExpired): http.Error(w, "handoff expired", http.StatusBadRequest) return case errors.Is(err, errHandoffReplay): http.Error(w, "handoff already used", http.StatusBadRequest) return case err != nil: http.Error(w, "invalid handoff", http.StatusBadRequest) return } client, ok := h.ClientConfig[env.ClientID] if !ok { profileerrors.InvalidProfileUsage("unknown client_id", "client_id"). Write(w, http.StatusBadRequest) return } if !uriRegistered(client.RedirectURIs, env.RedirectURI) { profileerrors.RejectedForSafety( "handoff redirect_uri does not match the registered client", "redirect_uri", ).Write(w, http.StatusBadRequest) return } restart := url.Values{} restart.Set("client_id", env.ClientID) restart.Set("redirect_uri", env.RedirectURI) restart.Set("response_type", "code") restart.Set("scope", strings.Join(env.Scopes, " ")) restart.Set("state", env.State) restart.Set("code_challenge", env.PKCEChallenge) restart.Set("code_challenge_method", env.PKCEChallengeMethod) if env.Nonce != "" { restart.Set("nonce", env.Nonce) } if env.TenantHint != "" { restart.Set("tenant_hint", env.TenantHint) } http.Redirect(w, r, "/authorize?"+restart.Encode(), http.StatusFound) } func (h *AuthorizeHandler) maybeEnrollmentHandoff(ctx context.Context, w http.ResponseWriter, r *http.Request, ps *PendingState, username string) (bool, error) { if !h.clientEligible(ps.ClientID, HandoffEnroll) { return false, nil } enrolled, err := h.MFA.HasEnrolledFactor(ctx, username) if err != nil { return false, err } if enrolled { return false, nil } ps.AuthenticatedUser = username h.pending.Store(ps.State, ps) h.startHandoff(w, r, ps, HandoffEnroll) return true, nil } func (h *AuthorizeHandler) clientEligible(clientID string, kind HandoffKind) bool { client, ok := h.ClientConfig[clientID] if !ok { return false } switch kind { case HandoffRegister: return client.RegistrationURL != "" case HandoffEnroll: return client.EnrollmentURL != "" default: return false } } func (h *AuthorizeHandler) renderUnknownUserSignup(w http.ResponseWriter, ps *PendingState) { clientName := ps.ClientID if client, ok := h.ClientConfig[ps.ClientID]; ok && client.DisplayName != "" { clientName = client.DisplayName } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusUnauthorized) _ = unknownUserTemplate.Execute(w, struct { State string ClientName string }{ State: ps.State, ClientName: clientName, }) } func (h *AuthorizeHandler) emitMFAFailure(ctx context.Context, clientID string) { h.Emitter.Emit(ctx, telemetry.Event{ Timestamp: time.Now(), EventType: telemetry.EventAuthFailure, ClientID: clientID, Endpoint: "/authorize/callback", Result: "failure", ErrorType: "mfa_failed", }) } func (h *AuthorizeHandler) renderMFAChallenge(w http.ResponseWriter, ps *PendingState, errorMessage string) { clientName := ps.ClientID if client, ok := h.ClientConfig[ps.ClientID]; ok && client.DisplayName != "" { clientName = client.DisplayName } status := http.StatusOK if errorMessage != "" { status = http.StatusBadRequest } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) _ = mfaChallengeTemplate.Execute(w, struct { State string Username string ClientName string ErrorMessage string }{ State: ps.State, Username: ps.AuthenticatedUser, ClientName: clientName, ErrorMessage: errorMessage, }) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- var mfaChallengeTemplate = template.Must(template.New("mfa-challenge").Parse(` KeyCape MFA

Verify sign-in

{{.Username}} for {{.ClientName}}

{{if .ErrorMessage}}

{{.ErrorMessage}}

{{end}}
`)) var unknownUserTemplate = template.Must(template.New("unknown-user").Parse(` KeyCape sign-in

Account not found

No KeyCape identity is available for this {{.ClientName}} sign-in. Create an account to continue. This does not issue a token.

Create account
`)) func parsePrompt(raw string) (create, login bool) { for _, part := range strings.Fields(raw) { switch strings.ToLower(part) { case "create": create = true case "login": login = true } } return create, login } func parseMaxAge(raw string) (*time.Duration, error) { if strings.TrimSpace(raw) == "" { return nil, nil } secs, err := strconv.Atoi(raw) if err != nil || secs < 0 { return nil, errors.New("invalid max_age") } d := time.Duration(secs) * time.Second return &d, nil } func firstNonEmpty(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { return v } } return "" } func uriRegistered(registered []string, target string) bool { for _, u := range registered { if u == target { return true } } return false } func scopeContains(scope, want string) bool { for _, s := range strings.Fields(scope) { if s == want { return true } } return false }