2026-03-13 01:56:57 +01:00
|
|
|
package oidc
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto"
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"crypto/rsa"
|
|
|
|
|
"crypto/sha256"
|
2026-07-27 20:03:07 +02:00
|
|
|
"crypto/subtle"
|
2026-03-13 01:56:57 +01:00
|
|
|
"encoding/base64"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
KEY-WP-0005-T02-T03: cached tenant_roles claim, close workplan
New internal/adapters/tenantengine package, mirroring
internal/adapters/{lldap,privacyidea,authelia}'s shape: Client.Roles()
calls tenant-engine's cache-read endpoint. Fails open by construction --
unreachable, non-200, malformed body, or a nil *Client all return
(nil, false), never an error to specially handle. Wired into
TokenHandler.TenantEngine (nil by default, existing tests unaffected);
token.go stamps tenant_roles only when ok.
7 adapter tests plus 3 TokenHandler-level tests proving the actual
required behavior end-to-end: present when reachable, token issuance still
200 with every other core claim intact when unreachable (tenant_roles
simply absent -- the literal done-criteria), absent when not configured.
Real bug found and fixed at the source, not worked around: the first live
cross-process check (real flex-auth, real tenant-engine, this adapter)
returned tenant_not_found for a tenant that existed -- tenant-engine's read
endpoint was keyed by its internal tenant_id, but key-cape only ever has
the tenant's profile identifier. Fixed in tenant-engine
(ADHOC-2026-07-24), re-verified with the same live three-process chain --
roles=[IAM] ok=true.
Workplan closed: T01-T03 done. Explicitly still open: client_credentials /
service-token issuance -- no such flow exists in token.go at all, a
materially larger separate piece of work than either task here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:18:17 +02:00
|
|
|
"keycape/internal/adapters/tenantengine"
|
2026-03-13 01:56:57 +01:00
|
|
|
"keycape/internal/domain"
|
|
|
|
|
profileerrors "keycape/internal/errors"
|
|
|
|
|
"keycape/internal/server/telemetry"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// TokenHandler implements POST /token.
|
|
|
|
|
type TokenHandler struct {
|
|
|
|
|
ClientConfig map[string]*domain.Client
|
|
|
|
|
Sessions *SessionStore
|
|
|
|
|
Users domain.UserRepository
|
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 *rsa.PrivateKey
|
2026-03-13 01:56:57 +01:00
|
|
|
Issuer string
|
|
|
|
|
TokenLifetime time.Duration
|
|
|
|
|
Emitter telemetry.Emitter
|
KEY-WP-0005-T02-T03: cached tenant_roles claim, close workplan
New internal/adapters/tenantengine package, mirroring
internal/adapters/{lldap,privacyidea,authelia}'s shape: Client.Roles()
calls tenant-engine's cache-read endpoint. Fails open by construction --
unreachable, non-200, malformed body, or a nil *Client all return
(nil, false), never an error to specially handle. Wired into
TokenHandler.TenantEngine (nil by default, existing tests unaffected);
token.go stamps tenant_roles only when ok.
7 adapter tests plus 3 TokenHandler-level tests proving the actual
required behavior end-to-end: present when reachable, token issuance still
200 with every other core claim intact when unreachable (tenant_roles
simply absent -- the literal done-criteria), absent when not configured.
Real bug found and fixed at the source, not worked around: the first live
cross-process check (real flex-auth, real tenant-engine, this adapter)
returned tenant_not_found for a tenant that existed -- tenant-engine's read
endpoint was keyed by its internal tenant_id, but key-cape only ever has
the tenant's profile identifier. Fixed in tenant-engine
(ADHOC-2026-07-24), re-verified with the same live three-process chain --
roles=[IAM] ok=true.
Workplan closed: T01-T03 done. Explicitly still open: client_credentials /
service-token issuance -- no such flow exists in token.go at all, a
materially larger separate piece of work than either task here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:18:17 +02:00
|
|
|
// TenantEngine sources the optional tenant_roles claim (KEY-WP-0005-T02).
|
|
|
|
|
// Nil disables it entirely -- token issuance never depends on it.
|
|
|
|
|
TenantEngine *tenantengine.Client
|
2026-03-13 01:56:57 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// tokenResponse is the JSON body returned on a successful token exchange.
|
|
|
|
|
type tokenResponse struct {
|
|
|
|
|
AccessToken string `json:"access_token"`
|
|
|
|
|
TokenType string `json:"token_type"`
|
|
|
|
|
ExpiresIn int `json:"expires_in"`
|
2026-07-27 20:03:07 +02:00
|
|
|
IDToken string `json:"id_token,omitempty"`
|
2026-03-13 01:56:57 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ServeHTTP handles POST /token.
|
|
|
|
|
func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ctx := r.Context()
|
|
|
|
|
|
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
|
|
|
http.Error(w, "invalid form body", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
grantType := r.FormValue("grant_type")
|
2026-07-27 20:03:07 +02:00
|
|
|
if grantType == "client_credentials" {
|
|
|
|
|
h.serveClientCredentials(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-13 01:56:57 +01:00
|
|
|
clientID := r.FormValue("client_id")
|
|
|
|
|
code := r.FormValue("code")
|
|
|
|
|
codeVerifier := r.FormValue("code_verifier")
|
|
|
|
|
|
|
|
|
|
// 1. Validate grant_type.
|
|
|
|
|
if grantType != "authorization_code" {
|
|
|
|
|
profileerrors.FeatureNotSupported(
|
|
|
|
|
"only grant_type=authorization_code is supported",
|
|
|
|
|
"grant_type="+grantType,
|
|
|
|
|
).Write(w, http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Validate client exists (basic check; secret auth delegated to future work).
|
|
|
|
|
if _, ok := h.ClientConfig[clientID]; !ok {
|
|
|
|
|
profileerrors.InvalidProfileUsage("unknown client_id", "client_id").
|
|
|
|
|
Write(w, http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Look up PKCE session.
|
|
|
|
|
sess, ok := h.Sessions.Get(code)
|
|
|
|
|
if !ok {
|
|
|
|
|
profileerrors.InvalidProfileUsage(
|
|
|
|
|
"authorization code not found or expired",
|
|
|
|
|
"code",
|
|
|
|
|
).Write(w, http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify client_id matches the session.
|
|
|
|
|
if sess.ClientID != clientID {
|
|
|
|
|
profileerrors.InvalidProfileUsage(
|
|
|
|
|
"client_id does not match the authorization code",
|
|
|
|
|
"client_id",
|
|
|
|
|
).Write(w, http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. Verify PKCE code_verifier.
|
|
|
|
|
if !verifyPKCE(codeVerifier, sess.PKCEChallenge) {
|
|
|
|
|
profileerrors.InvalidProfileUsage(
|
|
|
|
|
"code_verifier does not match code_challenge",
|
|
|
|
|
"code_verifier",
|
|
|
|
|
).Write(w, http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 5. Look up user.
|
|
|
|
|
user, err := h.Users.LookupUser(ctx, sess.Username)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "user not found", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-28 01:23:22 +02:00
|
|
|
if isSuspended(user) {
|
|
|
|
|
h.Sessions.Delete(code)
|
|
|
|
|
profileerrors.RejectedForSafety(
|
|
|
|
|
"account is suspended",
|
|
|
|
|
"account_lifecycle",
|
|
|
|
|
).Write(w, http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-13 01:56:57 +01:00
|
|
|
|
|
|
|
|
// 6. Build JWT claims.
|
|
|
|
|
now := time.Now()
|
|
|
|
|
exp := now.Add(h.TokenLifetime)
|
|
|
|
|
|
|
|
|
|
claims := map[string]interface{}{
|
|
|
|
|
"iss": h.Issuer,
|
|
|
|
|
"sub": user.ID,
|
|
|
|
|
"aud": clientID,
|
|
|
|
|
"exp": exp.Unix(),
|
|
|
|
|
"iat": now.Unix(),
|
|
|
|
|
}
|
2026-06-01 21:20:54 +02:00
|
|
|
if sess.Nonce != "" {
|
|
|
|
|
claims["nonce"] = sess.Nonce
|
|
|
|
|
}
|
2026-03-13 01:56:57 +01:00
|
|
|
|
|
|
|
|
scopeSet := make(map[string]bool)
|
|
|
|
|
for _, s := range sess.Scopes {
|
|
|
|
|
scopeSet[s] = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if scopeSet["profile"] {
|
|
|
|
|
claims["preferred_username"] = user.Username
|
|
|
|
|
}
|
|
|
|
|
if scopeSet["email"] {
|
|
|
|
|
claims["email"] = user.Email
|
|
|
|
|
}
|
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
|
|
|
|
|
|
|
|
// 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).
|
KEY-WP-0005-T02-T03: cached tenant_roles claim, close workplan
New internal/adapters/tenantengine package, mirroring
internal/adapters/{lldap,privacyidea,authelia}'s shape: Client.Roles()
calls tenant-engine's cache-read endpoint. Fails open by construction --
unreachable, non-200, malformed body, or a nil *Client all return
(nil, false), never an error to specially handle. Wired into
TokenHandler.TenantEngine (nil by default, existing tests unaffected);
token.go stamps tenant_roles only when ok.
7 adapter tests plus 3 TokenHandler-level tests proving the actual
required behavior end-to-end: present when reachable, token issuance still
200 with every other core claim intact when unreachable (tenant_roles
simply absent -- the literal done-criteria), absent when not configured.
Real bug found and fixed at the source, not worked around: the first live
cross-process check (real flex-auth, real tenant-engine, this adapter)
returned tenant_not_found for a tenant that existed -- tenant-engine's read
endpoint was keyed by its internal tenant_id, but key-cape only ever has
the tenant's profile identifier. Fixed in tenant-engine
(ADHOC-2026-07-24), re-verified with the same live three-process chain --
roles=[IAM] ok=true.
Workplan closed: T01-T03 done. Explicitly still open: client_credentials /
service-token issuance -- no such flow exists in token.go at all, a
materially larger separate piece of work than either task here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:18:17 +02:00
|
|
|
tenant := effectiveTenant(user)
|
|
|
|
|
claims["tenant"] = tenant
|
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
|
|
|
claims["principal_type"] = "human"
|
|
|
|
|
claims["groups"] = nonNilStrings(user.Groups)
|
|
|
|
|
claims["roles"] = nonNilStrings(user.Roles)
|
|
|
|
|
claims["assurance"] = assuranceClaim(sess.MFAVerified, now)
|
2026-03-13 01:56:57 +01:00
|
|
|
|
KEY-WP-0005-T02-T03: cached tenant_roles claim, close workplan
New internal/adapters/tenantengine package, mirroring
internal/adapters/{lldap,privacyidea,authelia}'s shape: Client.Roles()
calls tenant-engine's cache-read endpoint. Fails open by construction --
unreachable, non-200, malformed body, or a nil *Client all return
(nil, false), never an error to specially handle. Wired into
TokenHandler.TenantEngine (nil by default, existing tests unaffected);
token.go stamps tenant_roles only when ok.
7 adapter tests plus 3 TokenHandler-level tests proving the actual
required behavior end-to-end: present when reachable, token issuance still
200 with every other core claim intact when unreachable (tenant_roles
simply absent -- the literal done-criteria), absent when not configured.
Real bug found and fixed at the source, not worked around: the first live
cross-process check (real flex-auth, real tenant-engine, this adapter)
returned tenant_not_found for a tenant that existed -- tenant-engine's read
endpoint was keyed by its internal tenant_id, but key-cape only ever has
the tenant's profile identifier. Fixed in tenant-engine
(ADHOC-2026-07-24), re-verified with the same live three-process chain --
roles=[IAM] ok=true.
Workplan closed: T01-T03 done. Explicitly still open: client_credentials /
service-token issuance -- no such flow exists in token.go at all, a
materially larger separate piece of work than either task here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:18:17 +02:00
|
|
|
// Optional cached tenant_roles claim (KEY-WP-0005-T02). Fails open --
|
|
|
|
|
// see internal/adapters/tenantengine's package doc for why this is the
|
|
|
|
|
// one place in the whole tenant_roles design where that's correct.
|
|
|
|
|
if roles, ok := h.TenantEngine.Roles(ctx, tenant); ok {
|
|
|
|
|
claims["tenant_roles"] = roles
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 01:56:57 +01:00
|
|
|
// 7. Sign JWT with RSA-SHA256.
|
|
|
|
|
kid := "key-1" // static kid for v0.1
|
|
|
|
|
jwtToken, err := buildJWT(claims, kid, h.SigningKey)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "failed to build JWT", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 8. Delete used PKCE session (prevent replay).
|
|
|
|
|
h.Sessions.Delete(code)
|
|
|
|
|
|
|
|
|
|
// 9. Build response.
|
|
|
|
|
resp := tokenResponse{
|
|
|
|
|
AccessToken: jwtToken,
|
|
|
|
|
TokenType: "Bearer",
|
|
|
|
|
ExpiresIn: int(h.TokenLifetime.Seconds()),
|
|
|
|
|
IDToken: jwtToken,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 10. Emit token_issued telemetry.
|
|
|
|
|
h.Emitter.Emit(ctx, telemetry.Event{
|
|
|
|
|
Timestamp: time.Now(),
|
|
|
|
|
EventType: telemetry.EventTokenIssued,
|
|
|
|
|
ClientID: clientID,
|
|
|
|
|
Endpoint: "/token",
|
|
|
|
|
Result: "success",
|
|
|
|
|
Scopes: sess.Scopes,
|
|
|
|
|
GrantType: grantType,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 20:03:07 +02:00
|
|
|
func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ctx := r.Context()
|
|
|
|
|
clientID, clientSecret, ok := r.BasicAuth()
|
|
|
|
|
if !ok {
|
|
|
|
|
profileerrors.InvalidProfileUsage("client_secret_basic authentication required", "Authorization").
|
|
|
|
|
Write(w, http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
client, ok := h.ClientConfig[clientID]
|
|
|
|
|
if !ok || client.ClientType != "confidential" || !containsString(client.GrantTypes, "client_credentials") {
|
|
|
|
|
profileerrors.InvalidProfileUsage("invalid confidential client", "client_id").
|
|
|
|
|
Write(w, http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
presentedDigest := sha256.Sum256([]byte(clientSecret))
|
|
|
|
|
expectedDigest := sha256.Sum256([]byte(client.ClientSecret))
|
|
|
|
|
if client.ClientSecret == "" ||
|
|
|
|
|
subtle.ConstantTimeCompare(presentedDigest[:], expectedDigest[:]) != 1 {
|
|
|
|
|
profileerrors.InvalidProfileUsage("invalid client authentication", "Authorization").
|
|
|
|
|
Write(w, http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
requestedScopes := strings.Fields(r.FormValue("scope"))
|
|
|
|
|
if len(requestedScopes) == 0 {
|
|
|
|
|
requestedScopes = append([]string(nil), client.AllowedScopes...)
|
|
|
|
|
}
|
|
|
|
|
for _, scope := range requestedScopes {
|
|
|
|
|
if !containsString(client.AllowedScopes, scope) {
|
|
|
|
|
profileerrors.InvalidProfileUsage("requested scope is not allowed", "scope").
|
|
|
|
|
Write(w, http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
claims := map[string]interface{}{
|
|
|
|
|
"iss": h.Issuer,
|
|
|
|
|
"sub": client.ServiceSubject,
|
|
|
|
|
"aud": clientID,
|
|
|
|
|
"exp": now.Add(h.TokenLifetime).Unix(),
|
|
|
|
|
"iat": now.Unix(),
|
|
|
|
|
"tenant": client.Tenant,
|
|
|
|
|
"principal_type": "service",
|
|
|
|
|
"groups": []string{},
|
|
|
|
|
"roles": nonNilStrings(client.Roles),
|
|
|
|
|
"scope": strings.Join(requestedScopes, " "),
|
|
|
|
|
"assurance": map[string]interface{}{
|
|
|
|
|
"level": "aal1", "methods": []string{"client_secret"},
|
|
|
|
|
"mfa": false, "source": "key-cape", "at": now.Unix(),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
if roles, ok := h.TenantEngine.Roles(ctx, client.Tenant); ok {
|
|
|
|
|
claims["tenant_roles"] = roles
|
|
|
|
|
}
|
|
|
|
|
jwtToken, err := buildJWT(claims, "key-1", h.SigningKey)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "failed to build JWT", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.Emitter.Emit(ctx, telemetry.Event{
|
|
|
|
|
Timestamp: now, EventType: telemetry.EventTokenIssued, ClientID: clientID,
|
|
|
|
|
Endpoint: "/token", Result: "success", Scopes: requestedScopes,
|
|
|
|
|
GrantType: "client_credentials",
|
|
|
|
|
})
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
_ = json.NewEncoder(w).Encode(tokenResponse{
|
|
|
|
|
AccessToken: jwtToken, TokenType: "Bearer",
|
|
|
|
|
ExpiresIn: int(h.TokenLifetime.Seconds()),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func containsString(values []string, wanted string) bool {
|
|
|
|
|
for _, value := range values {
|
|
|
|
|
if value == wanted {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 01:23:22 +02:00
|
|
|
func isSuspended(user *domain.User) bool {
|
|
|
|
|
return containsString(user.Groups, "netkingdom-suspended")
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 01:56:57 +01:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// PKCE verification
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// verifyPKCE checks BASE64URL(SHA256(verifier)) == challenge (S256 method).
|
|
|
|
|
func verifyPKCE(verifier, challenge string) bool {
|
|
|
|
|
h := sha256.New()
|
|
|
|
|
h.Write([]byte(verifier))
|
|
|
|
|
computed := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
|
|
|
|
return computed == challenge
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// JWT construction (stdlib only — no external JWT library)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
type jwtHeader struct {
|
|
|
|
|
Alg string `json:"alg"`
|
|
|
|
|
Typ string `json:"typ"`
|
|
|
|
|
Kid string `json:"kid"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// buildJWT constructs and signs a JWT using RSA-SHA256 with the standard library.
|
|
|
|
|
// Format: base64url(header) + "." + base64url(payload) + "." + base64url(signature)
|
|
|
|
|
func buildJWT(claims map[string]interface{}, kid string, key *rsa.PrivateKey) (string, error) {
|
|
|
|
|
// Header.
|
|
|
|
|
hdr := jwtHeader{Alg: "RS256", Typ: "JWT", Kid: kid}
|
|
|
|
|
hdrJSON, err := json.Marshal(hdr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
hdrB64 := base64.RawURLEncoding.EncodeToString(hdrJSON)
|
|
|
|
|
|
|
|
|
|
// Payload.
|
|
|
|
|
payloadJSON, err := json.Marshal(claims)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
|
|
|
|
|
|
|
|
|
|
// Signing input.
|
|
|
|
|
signingInput := hdrB64 + "." + payloadB64
|
|
|
|
|
|
|
|
|
|
// Digest.
|
|
|
|
|
digest := sha256.Sum256([]byte(signingInput))
|
|
|
|
|
|
|
|
|
|
// Sign with PKCS1v15 / SHA256.
|
|
|
|
|
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
|
|
|
|
|
|
|
|
|
|
return strings.Join([]string{hdrB64, payloadB64, sigB64}, "."), nil
|
|
|
|
|
}
|