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

@ -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
// ---------------------------------------------------------------------------