KEY-WP-0005-T01: IAM Profile core claims for the human PKCE flow
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 1m50s

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>
This commit is contained in:
tegwick 2026-07-24 00:03:31 +02:00
parent e51a2d74e9
commit f1f7fa9dd7
8 changed files with 271 additions and 32 deletions

View file

@ -190,7 +190,7 @@ func newTestServer(t *testing.T) *TestServer {
ClientConfig: clients,
Sessions: sessions,
Users: usersMock,
SigningKey: privateKey,
SigningKey: privateKey,
Issuer: issuer,
TokenLifetime: 15 * time.Minute,
Emitter: emitter,
@ -199,10 +199,10 @@ func newTestServer(t *testing.T) *TestServer {
// Userinfo handler.
mux.Handle("/userinfo", &oidc.UserinfoHandler{
Users: usersMock,
Users: usersMock,
SigningKey: &privateKey.PublicKey,
Issuer: issuer,
Emitter: emitter,
Issuer: issuer,
Emitter: emitter,
})
// Healthz handler.
@ -622,7 +622,12 @@ func TestCompleteTokenFlow(t *testing.T) {
t.Fatalf("parse JWT claims: %v", err)
}
requiredClaims := []string{"iss", "sub", "aud", "exp", "iat"}
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)
@ -632,4 +637,37 @@ func TestCompleteTokenFlow(t *testing.T) {
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"])
}
}