All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s
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
679 lines
19 KiB
Go
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"])
|
|
}
|
|
}
|