package oidc import ( "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "keycape/internal/adapters/tenantengine" "keycape/internal/domain" profileerrors "keycape/internal/errors" "keycape/internal/server/telemetry" ) // TokenHandler implements POST /token. type TokenHandler struct { ClientConfig map[string]*domain.Client Sessions *SessionStore Users domain.UserRepository SigningKey *rsa.PrivateKey Issuer string TokenLifetime time.Duration Emitter telemetry.Emitter // TenantEngine sources the optional tenant_roles claim (KEY-WP-0005-T02). // Nil disables it entirely -- token issuance never depends on it. TenantEngine *tenantengine.Client } // tokenResponse is the JSON body returned on a successful token exchange. type tokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` IDToken string `json:"id_token,omitempty"` } // ServeHTTP handles POST /token. func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if err := r.ParseForm(); err != nil { http.Error(w, "invalid form body", http.StatusBadRequest) return } grantType := r.FormValue("grant_type") if grantType == "client_credentials" { h.serveClientCredentials(w, r) return } clientID := r.FormValue("client_id") code := r.FormValue("code") codeVerifier := r.FormValue("code_verifier") // 1. Validate grant_type. if grantType != "authorization_code" { profileerrors.FeatureNotSupported( "only grant_type=authorization_code is supported", "grant_type="+grantType, ).Write(w, http.StatusBadRequest) return } // 2. Validate client exists and may use this grant. client, ok := h.ClientConfig[clientID] if !ok { profileerrors.InvalidProfileUsage("unknown client_id", "client_id"). Write(w, http.StatusBadRequest) return } // Grant-type eligibility, enforced equivalently to the service path // (KEY-WP-0016-T02). An empty grantTypes is an implicit authorization-code // client, matching config validation; a client_credentials-only client must // not reach the browser path. if len(client.GrantTypes) > 0 && !containsString(client.GrantTypes, "authorization_code") { profileerrors.InvalidProfileUsage( "client is not registered for grant_type=authorization_code", "grant_type", ).Write(w, http.StatusBadRequest) return } // Confidential authorization-code clients authenticate with their secret, // using the same credential sources as the service grant. A public client // must not be able to present a secret and be treated as authenticated. if client.ClientType == "confidential" { presentedID, secret, ok := basicClientCredentials(r) if !ok || presentedID != clientID || client.ClientSecret == "" || !secretsEqual(secret, client.ClientSecret) { profileerrors.InvalidProfileUsage( "client authentication failed", "Authorization", ).Write(w, http.StatusUnauthorized) return } } // 3. Consume the PKCE session. Single-use and atomic: see // SessionStore.Consume (KEY-WP-0016-T01). sess, ok := h.Sessions.Consume(code) if !ok { profileerrors.InvalidProfileUsage( "authorization code not found or expired", "code", ).Write(w, http.StatusBadRequest) return } // Verify client_id matches the session. if sess.ClientID != clientID { profileerrors.InvalidProfileUsage( "client_id does not match the authorization code", "client_id", ).Write(w, http.StatusBadRequest) return } // Bind the exchange to the redirect URI the code was issued for // (RFC 6749 section 4.1.3, KEY-WP-0016-T02). /authorize always records an // exactly-matched registered redirect, so the parameter is always required // here and must be identical. if redirectURI := r.FormValue("redirect_uri"); redirectURI != sess.RedirectURI { profileerrors.InvalidProfileUsage( "redirect_uri does not match the authorization request", "redirect_uri", ).Write(w, http.StatusBadRequest) return } // Recheck grants in case the client registration changed after authorization. for _, scope := range sess.Scopes { if !containsString(h.ClientConfig[clientID].AllowedScopes, scope) { profileerrors.InvalidProfileUsage("requested scope is not allowed", "scope").Write(w, http.StatusBadRequest) return } } // 4. Verify PKCE code_verifier. if !verifyPKCE(codeVerifier, sess.PKCEChallenge) { profileerrors.InvalidProfileUsage( "code_verifier does not match code_challenge", "code_verifier", ).Write(w, http.StatusBadRequest) return } // 5. Look up user. user, err := h.Users.LookupUser(ctx, sess.Username) if err != nil { http.Error(w, "user not found", http.StatusInternalServerError) return } if isSuspended(user) { profileerrors.RejectedForSafety( "account is suspended", "account_lifecycle", ).Write(w, http.StatusForbidden) return } // 6. Build JWT claims. now := time.Now() exp := now.Add(h.TokenLifetime) claims := map[string]interface{}{ "iss": h.Issuer, "sub": user.ID, "aud": clientID, "exp": exp.Unix(), "iat": now.Unix(), } if sess.Nonce != "" { claims["nonce"] = sess.Nonce } scopeSet := make(map[string]bool) for _, s := range sess.Scopes { scopeSet[s] = true } if scopeSet["profile"] { claims["preferred_username"] = user.Username } if scopeSet["email"] { claims["email"] = user.Email } // Core claims required by net-kingdom/canon/standards/iam-profile_v0.3.md // for every production token -- not scope-gated, unlike the recommended // human claims above (KEY-WP-0005-T01). tenant, tenantSource, err := humanTenant(h.ClientConfig[clientID], user) if err != nil { profileerrors.RejectedForSafety( "tenant binding conflict", "tenant_binding", ).Write(w, http.StatusForbidden) return } claims["tenant"] = tenant claims["tenant_source"] = tenantSource claims["principal_type"] = "human" claims["groups"] = nonNilStrings(user.Groups) claims["roles"] = nonNilStrings(user.Roles) claims["assurance"] = assuranceClaim(sess.MFAVerified, sess.AuthTime, now) // Optional cached tenant_roles claim (KEY-WP-0005-T02). Fails open -- // see internal/adapters/tenantengine's package doc for why this is the // one place in the whole tenant_roles design where that's correct. if roles, ok := h.TenantEngine.Roles(ctx, tenant); ok { claims["tenant_roles"] = roles } // 7. Sign JWT with RSA-SHA256. kid := "key-1" // static kid for v0.1 jwtToken, err := buildJWT(claims, kid, h.SigningKey) if err != nil { http.Error(w, "failed to build JWT", http.StatusInternalServerError) return } // Access tokens target the statically registered resource server. ID tokens // remain bound to the OIDC relying party. if audience := h.ClientConfig[clientID].Audience; audience != "" { claims["aud"] = audience } claims["scope"] = strings.Join(sess.Scopes, " ") accessToken, err := buildJWT(claims, kid, h.SigningKey) if err != nil { http.Error(w, "failed to build JWT", http.StatusInternalServerError) return } // 8. Build response. The session was already consumed at lookup, so no // separate replay-prevention delete is needed here. resp := tokenResponse{ AccessToken: accessToken, TokenType: "Bearer", ExpiresIn: int(h.TokenLifetime.Seconds()), IDToken: jwtToken, } // 10. Emit token_issued telemetry. h.Emitter.Emit(ctx, telemetry.Event{ Timestamp: time.Now(), EventType: telemetry.EventTokenIssued, ClientID: clientID, Endpoint: "/token", Result: "success", Scopes: sess.Scopes, GrantType: grantType, }) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(resp) } // basicClientCredentials reads client_secret_basic credentials, applying the // form-encoding decode RFC 6749 appendix B requires of both halves. Shared by // the service grant and confidential authorization-code client authentication. func basicClientCredentials(r *http.Request) (clientID, clientSecret string, ok bool) { clientID, clientSecret, ok = r.BasicAuth() if !ok { return "", "", false } decodedID, idErr := url.QueryUnescape(clientID) decodedSecret, secretErr := url.QueryUnescape(clientSecret) if idErr != nil || secretErr != nil { return "", "", false } return decodedID, decodedSecret, true } // secretsEqual compares two secrets in constant time. Digesting first keeps the // comparison length-independent, so a wrong-length secret is indistinguishable // from a wrong-value one. func secretsEqual(presented, expected string) bool { presentedDigest := sha256.Sum256([]byte(presented)) expectedDigest := sha256.Sum256([]byte(expected)) return subtle.ConstantTimeCompare(presentedDigest[:], expectedDigest[:]) == 1 } func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Request) { ctx := r.Context() clientID, clientSecret, ok := r.BasicAuth() if !ok { profileerrors.InvalidProfileUsage("client_secret_basic authentication required", "Authorization"). Write(w, http.StatusUnauthorized) return } clientID, idErr := url.QueryUnescape(clientID) clientSecret, secretErr := url.QueryUnescape(clientSecret) if idErr != nil || secretErr != nil { profileerrors.InvalidProfileUsage("invalid client authentication encoding", "Authorization").Write(w, http.StatusUnauthorized) return } client, ok := h.ClientConfig[clientID] if !ok || client.ClientType != "confidential" || !containsString(client.GrantTypes, "client_credentials") { profileerrors.InvalidProfileUsage("invalid confidential client", "client_id"). Write(w, http.StatusUnauthorized) return } presentedDigest := sha256.Sum256([]byte(clientSecret)) expectedDigest := sha256.Sum256([]byte(client.ClientSecret)) if client.ClientSecret == "" || subtle.ConstantTimeCompare(presentedDigest[:], expectedDigest[:]) != 1 { profileerrors.InvalidProfileUsage("invalid client authentication", "Authorization"). Write(w, http.StatusUnauthorized) return } requestedScopes := strings.Fields(r.FormValue("scope")) if len(requestedScopes) == 0 { requestedScopes = append([]string(nil), client.AllowedScopes...) } for _, scope := range requestedScopes { if !containsString(client.AllowedScopes, scope) { profileerrors.InvalidProfileUsage("requested scope is not allowed", "scope"). Write(w, http.StatusBadRequest) return } } now := time.Now() tokenLifetime := h.TokenLifetime if client.TokenLifetime > 0 { tokenLifetime = client.TokenLifetime } claims := map[string]interface{}{ "iss": h.Issuer, "sub": client.ServiceSubject, "aud": accessAudience(client), "exp": now.Add(tokenLifetime).Unix(), "iat": now.Unix(), "tenant": client.Tenant, // A service client's tenant is always registration-supplied: there is no // directory principal behind it to assert one (GH-DEC-2026-013 §5). "tenant_source": TenantSourceRegistration, "principal_type": "service", "groups": []string{}, "roles": nonNilStrings(client.Roles), "scope": strings.Join(requestedScopes, " "), "assurance": map[string]interface{}{ "level": "aal1", "methods": []string{"client_secret"}, "mfa": false, "source": "key-cape", "at": now.Unix(), }, } if roles, ok := h.TenantEngine.Roles(ctx, client.Tenant); ok { claims["tenant_roles"] = roles } jwtToken, err := buildJWT(claims, "key-1", h.SigningKey) if err != nil { http.Error(w, "failed to build JWT", http.StatusInternalServerError) return } h.Emitter.Emit(ctx, telemetry.Event{ Timestamp: now, EventType: telemetry.EventTokenIssued, ClientID: clientID, Endpoint: "/token", Result: "success", Scopes: requestedScopes, GrantType: "client_credentials", }) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(tokenResponse{ AccessToken: jwtToken, TokenType: "Bearer", ExpiresIn: int(tokenLifetime.Seconds()), }) } func containsString(values []string, wanted string) bool { for _, value := range values { if value == wanted { return true } } return false } func isSuspended(user *domain.User) bool { return containsString(user.Groups, "netkingdom-suspended") } // --------------------------------------------------------------------------- // IAM Profile core claims (KEY-WP-0005-T01) // --------------------------------------------------------------------------- // defaultTenant is the fallback tenant claim for users with no explicit // Tenant assignment yet. This workstation currently operates a single // tenant (tenant:coulomb, ADR-0006); later tenants (e.g. tenant:friendly:binky, // ADR-0013) require an explicit domain.User.Tenant value -- this default // never silently assigns a user to a tenant other than the platform's // original one. const defaultTenant = "tenant:coulomb" // effectiveTenant resolves the tenant claim for a user, falling back to // defaultTenant when the user has no explicit tenant assignment. The IAM // Profile requires a non-empty tenant claim on every token. func effectiveTenant(user *domain.User) string { if user.Tenant != "" { return user.Tenant } return defaultTenant } // humanTenant resolves the tenant claim for a human token (KEY-WP-0013-T05). // // A human's tenant is normally a property of the person, read from the // directory. But the approval chain is bound to the landlord zone by decision // 5ed3fb35-eca9-413a-82b9-95171ba85bf6, and approval-engine compares the claim // by exact string equality, so an approver client has to be able to state the // zone it issues into. Without this every human token fell back to // defaultTenant and would have been refused downstream -- as a failed approval // rather than as a registration defect. // // The rule is deliberately not an override: // // - no client tenant declared -> the directory answer, unchanged; // - declared, user unassigned -> the declared zone; // - declared and equal -> agreement, no ambiguity; // - declared and different -> refuse to issue. // // So a registration can bind a zone for users the directory has not placed, and // can never relabel a user the directory HAS placed into a different one. That // last case fails closed rather than picking a winner, because either answer // would be a silent cross-tenant assertion. It also means this stays correct if // the directory later populates Tenant: the same code turns from supplying the // zone into enforcing agreement with it, with no second migration. // // This is only safe because client registrations are static and // deployment-owned; KeyCape excludes dynamic client registration by design. A // self-service client that could name its users' tenant would be an escalation. func humanTenant(client *domain.Client, user *domain.User) (string, string, error) { if client == nil || client.Tenant == "" { if user.Tenant != "" { return user.Tenant, TenantSourceDirectory, nil } return defaultTenant, TenantSourceDefault, nil } if user.Tenant != "" { if user.Tenant != client.Tenant { return "", "", fmt.Errorf("client %q binds tenant %q but the directory assigns this user a different tenant", client.ClientID, client.Tenant) } // Agreement: the directory did assert this about the person, so the // stronger provenance is the true one. return user.Tenant, TenantSourceDirectory, nil } return client.Tenant, TenantSourceRegistration, nil } // Tenant provenance values for the tenant_source claim (GH-DEC-2026-013 §5). // // A bare tenant string cannot tell a consumer whether the identity layer // asserted the zone about this PERSON or a registration supplied it about the // CLIENT they came through. approval-engine admits an approver by exact-matching // that string while its contract reads as though it relies on the first, so the // check is sound and the property a reader infers from it is absent. The ruling // requires the claim to carry its provenance and forbids a consumer treating the // two as equivalent for any decision turning on a fact about the person. It names // the property; the field is ours. // // Three values, not the two the ruling names, and the third is the point. A // tenant nobody asserted -- neither directory nor registration, just the // profile's non-empty default -- is not directory-asserted, and labelling it so // would reintroduce the same defect one level down: a consumer would read // "directory" for a fact the directory never stated. That is the unknown-versus- // absent distinction the ruling cites from GH-DEC-2026-011 §3, applied to our own // fallback rather than only to the case we were asked about. const ( // TenantSourceDirectory: the identity layer asserted this zone about this // person. Includes the agreement case, where a registration declared the // same zone the directory did -- the directory still asserted it. TenantSourceDirectory = "directory" // TenantSourceRegistration: supplied by the client registration for a person // the directory has placed nowhere. A fact about the client, not the person. TenantSourceRegistration = "registration" // TenantSourceDefault: nobody asserted a zone; this is the profile default, // emitted because the profile requires a non-empty tenant. Weaker than both. TenantSourceDefault = "default" ) // nonNilStrings returns s, or an empty (non-nil) slice if s is nil, so the // claim always serializes as `[]`, never `null` -- the profile requires // groups/roles to be present, "possibly empty", not absent. func nonNilStrings(s []string) []string { if s == nil { return []string{} } return s } // assuranceClaim builds the profile's `assurance` object from whether MFA // was actually verified during this authorization (session.MFAVerified), // not from static enrollment state -- a user who has MFA enrolled but // wasn't challenged for it in this particular flow gets aal1, not aal2. // // `at` is the time the user authenticated, not the time this token was // minted. Those differ whenever a browser session is reused, and the gap is // the whole point: approval-engine stores this object verbatim as the only // downstream record that MFA occurred (KEY-WP-0013-T05), so mint time would // overstate how recently the person proved anything. issuedAt is the // fallback for a session predating this field. func assuranceClaim(mfaVerified bool, authTime, issuedAt time.Time) map[string]interface{} { at := authTime if at.IsZero() { at = issuedAt } level := "aal1" methods := []string{"pwd"} if mfaVerified { level = "aal2" methods = append(methods, "otp") } return map[string]interface{}{ "level": level, "methods": methods, "mfa": mfaVerified, "source": "key-cape", "at": at.Unix(), } } // --------------------------------------------------------------------------- // PKCE verification // --------------------------------------------------------------------------- // verifyPKCE checks BASE64URL(SHA256(verifier)) == challenge (S256 method). func verifyPKCE(verifier, challenge string) bool { h := sha256.New() h.Write([]byte(verifier)) computed := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) return computed == challenge } // --------------------------------------------------------------------------- // JWT construction (stdlib only — no external JWT library) // --------------------------------------------------------------------------- type jwtHeader struct { Alg string `json:"alg"` Typ string `json:"typ"` Kid string `json:"kid"` } // buildJWT constructs and signs a JWT using RSA-SHA256 with the standard library. // Format: base64url(header) + "." + base64url(payload) + "." + base64url(signature) func buildJWT(claims map[string]interface{}, kid string, key *rsa.PrivateKey) (string, error) { // Header. hdr := jwtHeader{Alg: "RS256", Typ: "JWT", Kid: kid} hdrJSON, err := json.Marshal(hdr) if err != nil { return "", err } hdrB64 := base64.RawURLEncoding.EncodeToString(hdrJSON) // Payload. payloadJSON, err := json.Marshal(claims) if err != nil { return "", err } payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) // Signing input. signingInput := hdrB64 + "." + payloadB64 // Digest. digest := sha256.Sum256([]byte(signingInput)) // Sign with PKCS1v15 / SHA256. sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:]) if err != nil { return "", err } sigB64 := base64.RawURLEncoding.EncodeToString(sig) return strings.Join([]string{hdrB64, payloadB64, sigB64}, "."), nil } // accessAudience is configured by the issuer, never selected by request input. func accessAudience(client *domain.Client) string { if client.Audience != "" { return client.Audience } return client.ClientID }