package oidc import ( "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/json" "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 := effectiveTenant(user) claims["tenant"] = tenant claims["principal_type"] = "human" claims["groups"] = nonNilStrings(user.Groups) claims["roles"] = nonNilStrings(user.Roles) claims["assurance"] = assuranceClaim(sess.MFAVerified, 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, "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 } // 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. func assuranceClaim(mfaVerified bool, at time.Time) map[string]interface{} { 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 }