// Package profile_test contains integration-style tests for the complete OIDC // profile (Scenario A from the Acceptance Test Matrix, spec §7). All handler // implementations are real; only the auth backend adapters are mocked. package profile_test import ( "context" "crypto/rand" "crypto/rsa" "crypto/sha256" "encoding/base64" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "keycape/internal/domain" "keycape/internal/server/errors" "keycape/internal/server/oidc" "keycape/internal/server/telemetry" ) // --------------------------------------------------------------------------- // Mock adapters // --------------------------------------------------------------------------- // mockAuth implements domain.AuthProvider for tests. type mockAuth struct { authorizeURL string callbackUser string callbackErr error } func (m *mockAuth) AuthorizeURL(_ context.Context, req domain.AuthRequest) (string, error) { if m.authorizeURL != "" { return m.authorizeURL, nil } return "https://authelia.example.com/auth?state=" + req.State, nil } func (m *mockAuth) HandleCallback(_ context.Context, params domain.CallbackParams) (*domain.AuthResult, error) { if m.callbackErr != nil { return nil, m.callbackErr } username := m.callbackUser if username == "" { username = "testuser" } return &domain.AuthResult{Username: username}, nil } // mockMFA implements domain.MFAProvider for tests. type mockMFA struct { required bool checkErr error mfaErr error } func (m *mockMFA) CheckMFARequired(_ context.Context, _ string) (bool, error) { return m.required, m.checkErr } func (m *mockMFA) HasEnrolledFactor(_ context.Context, _ string) (bool, error) { return m.required, m.checkErr } func (m *mockMFA) ValidateMFAToken(_ context.Context, _, _ string) error { return m.mfaErr } // mockUsers implements domain.UserRepository for tests. type mockUsers struct { users map[string]*domain.User } func newMockUsers() *mockUsers { return &mockUsers{users: map[string]*domain.User{ "testuser": { ID: "uid-001", Username: "testuser", DisplayName: "Test User", Email: "testuser@example.com", Groups: []string{"developers"}, Enabled: true, }, }} } func (m *mockUsers) LookupUser(_ context.Context, username string) (*domain.User, error) { u, ok := m.users[username] if !ok { return nil, domain.ErrUserNotFound } return u, nil } func (m *mockUsers) LookupGroups(_ context.Context, _ string) ([]domain.Group, error) { return nil, nil } func (m *mockUsers) ValidatePassword(_ context.Context, _, _ string) (bool, error) { return false, nil } func (m *mockUsers) ListUsers(_ context.Context) ([]domain.User, error) { return nil, nil } // --------------------------------------------------------------------------- // TestServer // --------------------------------------------------------------------------- // TestServer wraps an httptest.Server with all the wired-up handlers. type TestServer struct { Server *httptest.Server PrivateKey *rsa.PrivateKey Sessions *oidc.SessionStore AuthMock *mockAuth Clients map[string]*domain.Client } func newTestServer(t *testing.T) *TestServer { t.Helper() // Generate RSA key pair. privateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("generate RSA key: %v", err) } issuer := "http://localhost" // will be overridden with actual server URL after start // Create test client registry. clients := map[string]*domain.Client{ "demo-app": { ClientID: "demo-app", DisplayName: "Demo Application", RedirectURIs: []string{"http://localhost:3000/callback"}, AllowedScopes: []string{"openid", "profile", "email", "groups"}, GrantTypes: []string{"authorization_code"}, ClientType: "public", }, } // Create mock adapters. authMock := &mockAuth{} mfaMock := &mockMFA{required: false} usersMock := newMockUsers() // Session store. sessions := oidc.NewSessionStore() // Telemetry — noop for tests. emitter := telemetry.NoopEmitter{} // Key set. ks := oidc.NewKeySet() ks.AddKey("key-1", &privateKey.PublicKey) // Enforcement registry. reg := errors.DefaultRegistry() mux := http.NewServeMux() // Discovery handler. mux.Handle("/.well-known/openid-configuration", oidc.NewDiscoveryHandler(oidc.DiscoveryConfig{ Issuer: issuer, AuthorizationEndpoint: issuer + "/authorize", TokenEndpoint: issuer + "/token", JWKSUri: issuer + "/jwks", UserinfoEndpoint: issuer + "/userinfo", })) // JWKS handler. mux.Handle("/jwks", oidc.NewJWKSHandler(ks)) // Authorize handler (with enforcement middleware). authorizeHandler := &oidc.AuthorizeHandler{ ClientConfig: clients, Auth: authMock, MFA: mfaMock, Sessions: sessions, Emitter: emitter, } mux.Handle("/authorize", reg.Middleware(authorizeHandler)) mux.Handle("/authorize/callback", authorizeHandler) // Token handler (with enforcement middleware). tokenHandler := &oidc.TokenHandler{ ClientConfig: clients, Sessions: sessions, Users: usersMock, SigningKey: privateKey, Issuer: issuer, TokenLifetime: 15 * time.Minute, Emitter: emitter, } mux.Handle("/token", reg.Middleware(tokenHandler)) // Userinfo handler. mux.Handle("/userinfo", &oidc.UserinfoHandler{ Users: usersMock, SigningKey: &privateKey.PublicKey, Issuer: issuer, Emitter: emitter, }) // Healthz handler. mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"status":"ok","version":"0.1.0"}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return &TestServer{ Server: srv, PrivateKey: privateKey, Sessions: sessions, AuthMock: authMock, Clients: clients, } } // --------------------------------------------------------------------------- // PKCE helpers // --------------------------------------------------------------------------- func generatePKCE(t *testing.T) (verifier, challenge string) { t.Helper() b := make([]byte, 32) if _, err := rand.Read(b); err != nil { t.Fatalf("generate PKCE verifier: %v", err) } verifier = base64.RawURLEncoding.EncodeToString(b) h := sha256.Sum256([]byte(verifier)) challenge = base64.RawURLEncoding.EncodeToString(h[:]) return } // --------------------------------------------------------------------------- // Test cases // --------------------------------------------------------------------------- // 1. Discovery test. func TestDiscovery(t *testing.T) { ts := newTestServer(t) resp, err := http.Get(ts.Server.URL + "/.well-known/openid-configuration") if err != nil { t.Fatalf("GET /.well-known/openid-configuration: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("status: want 200, got %d", resp.StatusCode) } ct := resp.Header.Get("Content-Type") if !strings.HasPrefix(ct, "application/json") { t.Errorf("Content-Type: want application/json, got %q", ct) } var doc map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { t.Fatalf("decode discovery doc: %v", err) } requiredFields := []string{ "issuer", "authorization_endpoint", "token_endpoint", "jwks_uri", "response_types_supported", "grant_types_supported", "code_challenge_methods_supported", "id_token_signing_alg_values_supported", "scopes_supported", } for _, f := range requiredFields { if _, ok := doc[f]; !ok { t.Errorf("discovery doc missing field %q", f) } } // registration_endpoint must be absent. if _, ok := doc["registration_endpoint"]; ok { t.Error("discovery doc must not contain registration_endpoint") } // scopes_supported must include openid. scopes, ok := doc["scopes_supported"].([]interface{}) if !ok { t.Fatal("scopes_supported is not an array") } found := false for _, s := range scopes { if s == "openid" { found = true break } } if !found { t.Error("scopes_supported must include openid") } } // 2. JWKS test. func TestJWKS(t *testing.T) { ts := newTestServer(t) resp, err := http.Get(ts.Server.URL + "/jwks") if err != nil { t.Fatalf("GET /jwks: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("status: want 200, got %d", resp.StatusCode) } var jwks struct { Keys []struct { Kty string `json:"kty"` Alg string `json:"alg"` Use string `json:"use"` Kid string `json:"kid"` N string `json:"n"` E string `json:"e"` } `json:"keys"` } if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { t.Fatalf("decode JWKS: %v", err) } if len(jwks.Keys) == 0 { t.Fatal("JWKS must contain at least one key") } key := jwks.Keys[0] if key.Kty != "RSA" { t.Errorf("kty: want RSA, got %q", key.Kty) } if key.Alg != "RS256" { t.Errorf("alg: want RS256, got %q", key.Alg) } if key.N == "" { t.Error("n (modulus) must not be empty") } if key.E == "" { t.Error("e (exponent) must not be empty") } } // 3. Authorization redirect test — valid PKCE params → 302 redirect. func TestAuthorize_Redirect(t *testing.T) { ts := newTestServer(t) _, challenge := generatePKCE(t) q := url.Values{} q.Set("client_id", "demo-app") q.Set("redirect_uri", "http://localhost:3000/callback") q.Set("response_type", "code") q.Set("scope", "openid profile") q.Set("state", "test-state-123") q.Set("code_challenge", challenge) q.Set("code_challenge_method", "S256") client := &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse // don't follow redirect }} resp, err := client.Get(ts.Server.URL + "/authorize?" + q.Encode()) if err != nil { t.Fatalf("GET /authorize: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusFound { t.Errorf("status: want 302, got %d", resp.StatusCode) } location := resp.Header.Get("Location") if location == "" { t.Error("Location header must be set on redirect") } } // 4. Invalid client test — unknown client_id → invalid_profile_usage. func TestAuthorize_InvalidClient(t *testing.T) { ts := newTestServer(t) _, challenge := generatePKCE(t) q := url.Values{} q.Set("client_id", "unknown-client") q.Set("redirect_uri", "http://localhost:3000/callback") q.Set("response_type", "code") q.Set("scope", "openid") q.Set("state", "s") q.Set("code_challenge", challenge) q.Set("code_challenge_method", "S256") resp, err := http.Get(ts.Server.URL + "/authorize?" + q.Encode()) if err != nil { t.Fatalf("GET /authorize: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Errorf("status: want 400, got %d", resp.StatusCode) } var pe map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&pe); err != nil { t.Fatalf("decode error response: %v", err) } errType, _ := pe["error"].(string) if errType != "invalid_profile_usage" { t.Errorf("error: want invalid_profile_usage, got %q", errType) } } // 5. Wildcard redirect URI → rejected_for_profile_safety (caught by enforcement middleware). func TestAuthorize_WildcardRedirectURI(t *testing.T) { ts := newTestServer(t) q := url.Values{} q.Set("client_id", "demo-app") q.Set("redirect_uri", "https://evil.com/*") q.Set("response_type", "code") q.Set("scope", "openid") q.Set("state", "s") q.Set("code_challenge", "abc") q.Set("code_challenge_method", "S256") resp, err := http.Get(ts.Server.URL + "/authorize?" + q.Encode()) if err != nil { t.Fatalf("GET /authorize: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("status: want 403, got %d", resp.StatusCode) } var pe map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&pe) errType, _ := pe["error"].(string) if errType != "rejected_for_profile_safety" { t.Errorf("error: want rejected_for_profile_safety, got %q", errType) } } // 6. Missing PKCE test — no code_challenge → invalid_profile_usage (enforcement middleware). func TestAuthorize_MissingPKCE(t *testing.T) { ts := newTestServer(t) q := url.Values{} q.Set("client_id", "demo-app") q.Set("redirect_uri", "http://localhost:3000/callback") q.Set("response_type", "code") q.Set("scope", "openid") q.Set("state", "s") // No code_challenge resp, err := http.Get(ts.Server.URL + "/authorize?" + q.Encode()) if err != nil { t.Fatalf("GET /authorize: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Errorf("status: want 400, got %d", resp.StatusCode) } var pe map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&pe) errType, _ := pe["error"].(string) if errType != "invalid_profile_usage" { t.Errorf("error: want invalid_profile_usage, got %q", errType) } } // 7. Healthz test. func TestHealthz(t *testing.T) { ts := newTestServer(t) resp, err := http.Get(ts.Server.URL + "/healthz") if err != nil { t.Fatalf("GET /healthz: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("status: want 200, got %d", resp.StatusCode) } var body map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("decode /healthz response: %v", err) } if body["status"] != "ok" { t.Errorf("status field: want ok, got %v", body["status"]) } } // 8. Complete token flow test — auth callback + token exchange → valid JWT. func TestCompleteTokenFlow(t *testing.T) { ts := newTestServer(t) verifier, challenge := generatePKCE(t) // Step 1: Simulate the callback by seeding a pending state and triggering callback. // We do this by first calling /authorize to create the pending state, then calling // /authorize/callback with state and a mock code. q := url.Values{} q.Set("client_id", "demo-app") q.Set("redirect_uri", "http://localhost:3000/callback") q.Set("response_type", "code") q.Set("scope", "openid profile email groups") q.Set("state", "flow-state-xyz") q.Set("code_challenge", challenge) q.Set("code_challenge_method", "S256") noRedirectClient := &http.Client{ CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, } // /authorize → 302 to upstream auth. authResp, err := noRedirectClient.Get(ts.Server.URL + "/authorize?" + q.Encode()) if err != nil { t.Fatalf("GET /authorize: %v", err) } authResp.Body.Close() if authResp.StatusCode != http.StatusFound { t.Fatalf("authorize: want 302, got %d", authResp.StatusCode) } // Step 2: Simulate the upstream callback returning code + state. cbQ := url.Values{} cbQ.Set("code", "upstream-auth-code") cbQ.Set("state", "flow-state-xyz") cbResp, err := noRedirectClient.Get(ts.Server.URL + "/authorize/callback?" + cbQ.Encode()) if err != nil { t.Fatalf("GET /authorize/callback: %v", err) } cbResp.Body.Close() if cbResp.StatusCode != http.StatusFound { t.Fatalf("callback: want 302, got %d", cbResp.StatusCode) } // Extract the auth code from the Location redirect to our client. location := cbResp.Header.Get("Location") if location == "" { t.Fatal("callback: no Location header") } locURL, err := url.Parse(location) if err != nil { t.Fatalf("parse Location URL: %v", err) } authCode := locURL.Query().Get("code") if authCode == "" { t.Fatalf("no code in callback redirect: %q", location) } // Step 3: Exchange the auth code for a token. tokenForm := url.Values{} tokenForm.Set("grant_type", "authorization_code") tokenForm.Set("client_id", "demo-app") tokenForm.Set("code", authCode) tokenForm.Set("code_verifier", verifier) // The exchange must repeat the redirect URI the code was issued for. tokenForm.Set("redirect_uri", "http://localhost:3000/callback") tokenResp, err := http.Post( ts.Server.URL+"/token", "application/x-www-form-urlencoded", strings.NewReader(tokenForm.Encode()), ) if err != nil { t.Fatalf("POST /token: %v", err) } defer tokenResp.Body.Close() if tokenResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(tokenResp.Body) t.Fatalf("token: want 200, got %d; body: %s", tokenResp.StatusCode, body) } var tr struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` IDToken string `json:"id_token"` } if err := json.NewDecoder(tokenResp.Body).Decode(&tr); err != nil { t.Fatalf("decode token response: %v", err) } if tr.AccessToken == "" { t.Error("access_token must not be empty") } if tr.TokenType != "Bearer" { t.Errorf("token_type: want Bearer, got %q", tr.TokenType) } if tr.IDToken == "" { t.Error("id_token must not be empty") } // Verify JWT has 3 parts (header.payload.signature). parts := strings.Split(tr.IDToken, ".") if len(parts) != 3 { t.Errorf("id_token: expected 3 JWT parts, got %d", len(parts)) } // Decode payload and check required claims. payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { t.Fatalf("decode JWT payload: %v", err) } var claims map[string]interface{} if err := json.Unmarshal(payloadJSON, &claims); err != nil { t.Fatalf("parse JWT claims: %v", err) } requiredClaims := []string{ "iss", "sub", "aud", "exp", "iat", // IAM Profile v0.3 core claims (KEY-WP-0005-T01) -- required on // every production token, not scope-gated. "tenant", "principal_type", "groups", "roles", "assurance", } for _, c := range requiredClaims { if _, ok := claims[c]; !ok { t.Errorf("JWT missing claim %q", c) } } if claims["aud"] != "demo-app" { t.Errorf("aud: want demo-app, got %v", claims["aud"]) } // testuser has no explicit Tenant assignment -- falls back to the // platform default (this workstation's single tenant, ADR-0006). if claims["tenant"] != "tenant:coulomb" { t.Errorf("tenant: want tenant:coulomb, got %v", claims["tenant"]) } if claims["principal_type"] != "human" { t.Errorf("principal_type: want human, got %v", claims["principal_type"]) } groups, ok := claims["groups"].([]interface{}) if !ok || len(groups) != 1 || groups[0] != "developers" { t.Errorf("groups: want [developers], got %v", claims["groups"]) } roles, ok := claims["roles"].([]interface{}) if !ok || len(roles) != 0 { t.Errorf("roles: want [] (testuser has none), got %v", claims["roles"]) } assurance, ok := claims["assurance"].(map[string]interface{}) if !ok { t.Fatalf("assurance: want an object, got %v", claims["assurance"]) } // This flow's mockMFA has required: false -- MFA was never performed, // so assurance must reflect aal1, not aal2, regardless of the user's // static MFA enrollment state. if assurance["level"] != "aal1" { t.Errorf("assurance.level: want aal1 (MFA not required in this flow), got %v", assurance["level"]) } if assurance["mfa"] != false { t.Errorf("assurance.mfa: want false, got %v", assurance["mfa"]) } if assurance["source"] != "key-cape" { t.Errorf("assurance.source: want key-cape, got %v", assurance["source"]) } }