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

@ -309,7 +309,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
}
h.pending.Delete(state)
h.completeAuthorization(w, r, ps, result.Username)
h.completeAuthorization(w, r, ps, result.Username, mfaRequired)
}
func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Request) {
@ -350,10 +350,13 @@ func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Req
}
h.pending.Delete(state)
h.completeAuthorization(w, r, ps, ps.AuthenticatedUser)
// Reached only after ValidateMFAToken succeeded above -- MFA was
// required and passed, unlike the callback path where mfaRequired may
// be false.
h.completeAuthorization(w, r, ps, ps.AuthenticatedUser, true)
}
func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.Request, ps *PendingState, username string) {
func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.Request, ps *PendingState, username string, mfaVerified bool) {
// Generate authorization code and store PKCE session.
sess := &PKCESession{
ClientID: ps.ClientID,
@ -365,6 +368,7 @@ func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.
Username: username,
Scopes: ps.Scopes,
ExpiresAt: time.Now().Add(10 * time.Minute),
MFAVerified: mfaVerified,
}
authCode := h.Sessions.Create(sess)

View file

@ -594,6 +594,66 @@ func TestAuthorizeCallback_MFASubmission_ValidToken_RedirectsWithCode(t *testing
if _, ok := h.PendingStates().Load("random-state"); ok {
t.Error("expected pending MFA state to be deleted after successful submission")
}
// KEY-WP-0005-T01: the resulting session must record that MFA was
// actually verified in this flow, feeding token.go's assurance claim
// (aal2, not aal1).
code := parsed.Query().Get("code")
sess, ok := sessions.Get(code)
if !ok {
t.Fatal("expected a PKCE session for the issued code")
}
if !sess.MFAVerified {
t.Error("MFAVerified: want true after a successful MFA submission")
}
}
func TestAuthorizeCallback_MFANotRequired_SessionRecordsMFAVerifiedFalse(t *testing.T) {
auth := &mockAuthProvider{
callbackResult: &domain.AuthResult{Username: "alice"},
}
mfa := &mockMFAProvider{required: false}
emitter := &captureEmitter{}
sessions := oidc.NewSessionStore()
h := &oidc.AuthorizeHandler{
ClientConfig: testClient(),
Auth: auth,
MFA: mfa,
Sessions: sessions,
Emitter: emitter,
}
h.PendingStates().Store("random-state", &oidc.PendingState{
ClientID: "test-client",
RedirectURI: "https://app.example.com/callback",
PKCEChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
PKCEChallengeMethod: "S256",
State: "random-state",
Scopes: []string{"openid"},
ExpiresAt: time.Now().Add(5 * time.Minute),
})
req := httptest.NewRequest(http.MethodGet,
"/authorize/callback?code=authelia-code&state=random-state", nil)
w := httptest.NewRecorder()
h.ServeHTTPCallback(w, req)
if w.Code != http.StatusFound {
t.Fatalf("expected 302 redirect, got %d (body: %s)", w.Code, w.Body.String())
}
loc, err := url.Parse(w.Header().Get("Location"))
if err != nil {
t.Fatalf("invalid Location header: %v", err)
}
code := loc.Query().Get("code")
sess, ok := sessions.Get(code)
if !ok {
t.Fatal("expected a PKCE session for the issued code")
}
if sess.MFAVerified {
t.Error("MFAVerified: want false when MFA was never required for this flow")
}
}
func TestAuthorizeCallback_MFASubmission_InvalidToken_AuthFailure(t *testing.T) {

View file

@ -12,13 +12,20 @@ type PKCESession struct {
Code string
ClientID string
RedirectURI string
PKCEChallenge string // S256 challenge
PKCEChallengeMethod string // always "S256"
PKCEChallenge string // S256 challenge
PKCEChallengeMethod string // always "S256"
State string
Nonce string
Username string // set after auth
Username string // set after auth
Scopes []string
ExpiresAt time.Time
// MFAVerified records whether this authorization actually required and
// passed MFA validation (vs. MFA not being required for this user at
// all). Feeds the assurance claim's level in token.go
// (KEY-WP-0005-T01): aal2 when true, aal1 when false. Set once, at
// completeAuthorization -- never re-derived from stale enrollment state
// at token-exchange time.
MFAVerified bool
}
// SessionStore is an in-memory PKCE session store.

View file

@ -21,7 +21,7 @@ type TokenHandler struct {
ClientConfig map[string]*domain.Client
Sessions *SessionStore
Users domain.UserRepository
SigningKey *rsa.PrivateKey
SigningKey *rsa.PrivateKey
Issuer string
TokenLifetime time.Duration
Emitter telemetry.Emitter
@ -126,9 +126,15 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if scopeSet["email"] {
claims["email"] = user.Email
}
if scopeSet["groups"] {
claims["groups"] = user.Groups
}
// Core claims required by net-kingdom/canon/standards/iam-profile_v0.3.md
// for every production token -- not scope-gated, unlike the recommended
// human claims above (KEY-WP-0005-T01).
claims["tenant"] = effectiveTenant(user)
claims["principal_type"] = "human"
claims["groups"] = nonNilStrings(user.Groups)
claims["roles"] = nonNilStrings(user.Roles)
claims["assurance"] = assuranceClaim(sess.MFAVerified, now)
// 7. Sign JWT with RSA-SHA256.
kid := "key-1" // static kid for v0.1
@ -165,6 +171,58 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(resp)
}
// ---------------------------------------------------------------------------
// IAM Profile core claims (KEY-WP-0005-T01)
// ---------------------------------------------------------------------------
// defaultTenant is the fallback tenant claim for users with no explicit
// Tenant assignment yet. This workstation currently operates a single
// tenant (tenant:coulomb, ADR-0006); later tenants (e.g. tenant:friendly:binky,
// ADR-0013) require an explicit domain.User.Tenant value -- this default
// never silently assigns a user to a tenant other than the platform's
// original one.
const defaultTenant = "tenant:coulomb"
// effectiveTenant resolves the tenant claim for a user, falling back to
// defaultTenant when the user has no explicit tenant assignment. The IAM
// Profile requires a non-empty tenant claim on every token.
func effectiveTenant(user *domain.User) string {
if user.Tenant != "" {
return user.Tenant
}
return defaultTenant
}
// nonNilStrings returns s, or an empty (non-nil) slice if s is nil, so the
// claim always serializes as `[]`, never `null` -- the profile requires
// groups/roles to be present, "possibly empty", not absent.
func nonNilStrings(s []string) []string {
if s == nil {
return []string{}
}
return s
}
// assuranceClaim builds the profile's `assurance` object from whether MFA
// was actually verified during this authorization (session.MFAVerified),
// not from static enrollment state -- a user who has MFA enrolled but
// wasn't challenged for it in this particular flow gets aal1, not aal2.
func assuranceClaim(mfaVerified bool, at time.Time) map[string]interface{} {
level := "aal1"
methods := []string{"pwd"}
if mfaVerified {
level = "aal2"
methods = append(methods, "otp")
}
return map[string]interface{}{
"level": level,
"methods": methods,
"mfa": mfaVerified,
"source": "key-cape",
"at": at.Unix(),
}
}
// ---------------------------------------------------------------------------
// PKCE verification
// ---------------------------------------------------------------------------

View file

@ -81,13 +81,13 @@ func newTokenHandler(t *testing.T, sessions *oidc.SessionStore, users domain.Use
}
emitter := &captureEmitter{}
h := &oidc.TokenHandler{
ClientConfig: testClient(),
Sessions: sessions,
Users: users,
ClientConfig: testClient(),
Sessions: sessions,
Users: users,
SigningKey: key,
Issuer: "https://auth.netkingdom.local",
Issuer: "https://auth.netkingdom.local",
TokenLifetime: 15 * time.Minute,
Emitter: emitter,
Emitter: emitter,
}
return h, key
}
@ -377,9 +377,10 @@ func TestTokenHandler_ScopeFiltering_ProfileScope(t *testing.T) {
if _, ok := claims["email"]; ok {
t.Error("email must be absent when email scope is not granted")
}
// Without groups scope, groups must not be present.
if _, ok := claims["groups"]; ok {
t.Error("groups must be absent when groups scope is not granted")
// groups is a required IAM Profile core claim (iam-profile_v0.3.md) --
// present regardless of scope, unlike preferred_username/email above.
if _, ok := claims["groups"]; !ok {
t.Error("groups must be present as a core claim even without a groups scope (KEY-WP-0005-T01)")
}
}
@ -436,7 +437,7 @@ func TestTokenHandler_TokenIssuedTelemetry(t *testing.T) {
ClientConfig: testClient(),
Sessions: sessions,
Users: users,
SigningKey: key,
SigningKey: key,
Issuer: "https://auth.netkingdom.local",
TokenLifetime: 15 * time.Minute,
Emitter: capture,