key-cape/src/tests/profile/profile_test.go
tegwick 139b6ff351
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s
Harden the authorization-code grant and UserInfo verification
Closes the local protocol surface of gap G01 from the scope assessment
(KEY-WP-0016). The browser grant validated PKCE, client id and scopes but left
four bindings unenforced, and UserInfo verified less than the caller CLI does.

Authorization-code path: bind the exchange to the redirect URI the code was
issued for, refuse clients whose registration does not permit the grant, and
authenticate confidential clients with a digest-based constant-time comparison
over the same credential sources as the service grant. An empty grantTypes stays
an implicit authorization-code client, matching config validation.

Code consumption: SessionStore.Consume reads and deletes under one lock. The
previous Get/Delete pair spanned JWT signing, and the added test reproduces the
race against that version -- 9 of 16 concurrent exchanges succeeded, and a
failed exchange left the code replayable.

UserInfo: check the JOSE header algorithm before trusting the signature, require
the configured issuer, and require an access token rather than accepting an ID
token of the right shape. Purpose is decided on the scope claim so the issued
token contract, which consumers pin exactly, does not change.

SCOPE.md and the assessment record which bindings are now enforced and that the
Authelia upstream-trust assumption remains open, so G01 is not fully closed and
no profile-conformance claim is made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-06 22:43:47 +02:00

679 lines
19 KiB
Go

// 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"])
}
}