2026-03-13 02:18:36 +01:00
|
|
|
// 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) 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,
|
KEY-WP-0005-T01: IAM Profile core claims for the human PKCE flow
Verified first: grant_types_supported advertises client_credentials in
discovery.go, but token.go only ever accepted authorization_code -- no
service-token issuance path exists at all. Building one from scratch is
materially bigger than extending the existing flow; explicitly not
attempted here, left open in the workplan rather than declared done.
What shipped for the human Authorization Code + PKCE flow:
- domain.User.Tenant (new, omitempty) + token.go's effectiveTenant():
falls back to tenant:coulomb (this workstation's actual tenant, ADR-0006)
when unset -- never an empty tenant claim, never a silent reassignment.
- principal_type: "human", unconditional.
- groups/roles promoted from scope-gated to unconditional core claims,
always [] not null when empty. One pre-existing test asserted the old
scope-gated groups behavior -- updated to match the new intentional
behavior, not left failing or reverted.
- assurance built from PKCESession.MFAVerified (new field, threaded
through completeAuthorization's two call sites in authorize.go) --
whether MFA was actually verified in this session, not static enrollment
state. aal2 only when required-and-passed this time, aal1 otherwise.
go build/vet clean, go test ./... green repo-wide. Two new authorize_test.go
cases assert MFAVerified on both paths. tests/profile/profile_test.go's
TestCompleteTokenFlow (the repo's own full HTTP integration test) extended
with real value assertions for all five claims, not just presence checks.
Python conformance tool not run against a live instance (needs the full
Authelia+LLDAP+privacyIDEA stack); TestCompleteTokenFlow's real HTTP round
trip covers the equivalent claim checks instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:03:31 +02:00
|
|
|
SigningKey: privateKey,
|
2026-03-13 02:18:36 +01:00
|
|
|
Issuer: issuer,
|
|
|
|
|
TokenLifetime: 15 * time.Minute,
|
|
|
|
|
Emitter: emitter,
|
|
|
|
|
}
|
|
|
|
|
mux.Handle("/token", reg.Middleware(tokenHandler))
|
|
|
|
|
|
|
|
|
|
// Userinfo handler.
|
|
|
|
|
mux.Handle("/userinfo", &oidc.UserinfoHandler{
|
KEY-WP-0005-T01: IAM Profile core claims for the human PKCE flow
Verified first: grant_types_supported advertises client_credentials in
discovery.go, but token.go only ever accepted authorization_code -- no
service-token issuance path exists at all. Building one from scratch is
materially bigger than extending the existing flow; explicitly not
attempted here, left open in the workplan rather than declared done.
What shipped for the human Authorization Code + PKCE flow:
- domain.User.Tenant (new, omitempty) + token.go's effectiveTenant():
falls back to tenant:coulomb (this workstation's actual tenant, ADR-0006)
when unset -- never an empty tenant claim, never a silent reassignment.
- principal_type: "human", unconditional.
- groups/roles promoted from scope-gated to unconditional core claims,
always [] not null when empty. One pre-existing test asserted the old
scope-gated groups behavior -- updated to match the new intentional
behavior, not left failing or reverted.
- assurance built from PKCESession.MFAVerified (new field, threaded
through completeAuthorization's two call sites in authorize.go) --
whether MFA was actually verified in this session, not static enrollment
state. aal2 only when required-and-passed this time, aal1 otherwise.
go build/vet clean, go test ./... green repo-wide. Two new authorize_test.go
cases assert MFAVerified on both paths. tests/profile/profile_test.go's
TestCompleteTokenFlow (the repo's own full HTTP integration test) extended
with real value assertions for all five claims, not just presence checks.
Python conformance tool not run against a live instance (needs the full
Authelia+LLDAP+privacyIDEA stack); TestCompleteTokenFlow's real HTTP round
trip covers the equivalent claim checks instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:03:31 +02:00
|
|
|
Users: usersMock,
|
2026-03-13 02:18:36 +01:00
|
|
|
SigningKey: &privateKey.PublicKey,
|
KEY-WP-0005-T01: IAM Profile core claims for the human PKCE flow
Verified first: grant_types_supported advertises client_credentials in
discovery.go, but token.go only ever accepted authorization_code -- no
service-token issuance path exists at all. Building one from scratch is
materially bigger than extending the existing flow; explicitly not
attempted here, left open in the workplan rather than declared done.
What shipped for the human Authorization Code + PKCE flow:
- domain.User.Tenant (new, omitempty) + token.go's effectiveTenant():
falls back to tenant:coulomb (this workstation's actual tenant, ADR-0006)
when unset -- never an empty tenant claim, never a silent reassignment.
- principal_type: "human", unconditional.
- groups/roles promoted from scope-gated to unconditional core claims,
always [] not null when empty. One pre-existing test asserted the old
scope-gated groups behavior -- updated to match the new intentional
behavior, not left failing or reverted.
- assurance built from PKCESession.MFAVerified (new field, threaded
through completeAuthorization's two call sites in authorize.go) --
whether MFA was actually verified in this session, not static enrollment
state. aal2 only when required-and-passed this time, aal1 otherwise.
go build/vet clean, go test ./... green repo-wide. Two new authorize_test.go
cases assert MFAVerified on both paths. tests/profile/profile_test.go's
TestCompleteTokenFlow (the repo's own full HTTP integration test) extended
with real value assertions for all five claims, not just presence checks.
Python conformance tool not run against a live instance (needs the full
Authelia+LLDAP+privacyIDEA stack); TestCompleteTokenFlow's real HTTP round
trip covers the equivalent claim checks instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:03:31 +02:00
|
|
|
Issuer: issuer,
|
|
|
|
|
Emitter: emitter,
|
2026-03-13 02:18:36 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
KEY-WP-0005-T01: IAM Profile core claims for the human PKCE flow
Verified first: grant_types_supported advertises client_credentials in
discovery.go, but token.go only ever accepted authorization_code -- no
service-token issuance path exists at all. Building one from scratch is
materially bigger than extending the existing flow; explicitly not
attempted here, left open in the workplan rather than declared done.
What shipped for the human Authorization Code + PKCE flow:
- domain.User.Tenant (new, omitempty) + token.go's effectiveTenant():
falls back to tenant:coulomb (this workstation's actual tenant, ADR-0006)
when unset -- never an empty tenant claim, never a silent reassignment.
- principal_type: "human", unconditional.
- groups/roles promoted from scope-gated to unconditional core claims,
always [] not null when empty. One pre-existing test asserted the old
scope-gated groups behavior -- updated to match the new intentional
behavior, not left failing or reverted.
- assurance built from PKCESession.MFAVerified (new field, threaded
through completeAuthorization's two call sites in authorize.go) --
whether MFA was actually verified in this session, not static enrollment
state. aal2 only when required-and-passed this time, aal1 otherwise.
go build/vet clean, go test ./... green repo-wide. Two new authorize_test.go
cases assert MFAVerified on both paths. tests/profile/profile_test.go's
TestCompleteTokenFlow (the repo's own full HTTP integration test) extended
with real value assertions for all five claims, not just presence checks.
Python conformance tool not run against a live instance (needs the full
Authelia+LLDAP+privacyIDEA stack); TestCompleteTokenFlow's real HTTP round
trip covers the equivalent claim checks instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:03:31 +02:00
|
|
|
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",
|
|
|
|
|
}
|
2026-03-13 02:18:36 +01:00
|
|
|
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"])
|
|
|
|
|
}
|
KEY-WP-0005-T01: IAM Profile core claims for the human PKCE flow
Verified first: grant_types_supported advertises client_credentials in
discovery.go, but token.go only ever accepted authorization_code -- no
service-token issuance path exists at all. Building one from scratch is
materially bigger than extending the existing flow; explicitly not
attempted here, left open in the workplan rather than declared done.
What shipped for the human Authorization Code + PKCE flow:
- domain.User.Tenant (new, omitempty) + token.go's effectiveTenant():
falls back to tenant:coulomb (this workstation's actual tenant, ADR-0006)
when unset -- never an empty tenant claim, never a silent reassignment.
- principal_type: "human", unconditional.
- groups/roles promoted from scope-gated to unconditional core claims,
always [] not null when empty. One pre-existing test asserted the old
scope-gated groups behavior -- updated to match the new intentional
behavior, not left failing or reverted.
- assurance built from PKCESession.MFAVerified (new field, threaded
through completeAuthorization's two call sites in authorize.go) --
whether MFA was actually verified in this session, not static enrollment
state. aal2 only when required-and-passed this time, aal1 otherwise.
go build/vet clean, go test ./... green repo-wide. Two new authorize_test.go
cases assert MFAVerified on both paths. tests/profile/profile_test.go's
TestCompleteTokenFlow (the repo's own full HTTP integration test) extended
with real value assertions for all five claims, not just presence checks.
Python conformance tool not run against a live instance (needs the full
Authelia+LLDAP+privacyIDEA stack); TestCompleteTokenFlow's real HTTP round
trip covers the equivalent claim checks instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:03:31 +02:00
|
|
|
|
|
|
|
|
// 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"])
|
|
|
|
|
}
|
2026-03-13 02:18:36 +01:00
|
|
|
}
|