From e2c5a947829568baf837543a6708fd0c3f6a863b Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 18 Oct 2026 12:13:56 +0200 Subject: [PATCH] Bind online human ID tokens to the exact current access grant Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7 --- docs/current-token-client.md | 7 + docs/online-token-status.md | 9 +- .../forgejo_current_fixture_test.go | 156 ++++++++++++++++++ src/internal/server/oidc/token.go | 25 ++- src/internal/server/oidc/token_pair_test.go | 62 +++++++ .../KEY-WP-0042-online-access-token-status.md | 17 +- 6 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 src/internal/authclient/forgejo_current_fixture_test.go create mode 100644 src/internal/server/oidc/token_pair_test.go diff --git a/docs/current-token-client.md b/docs/current-token-client.md index 490af8a..c123062 100644 --- a/docs/current-token-client.md +++ b/docs/current-token-client.md @@ -28,6 +28,13 @@ not establish Windows ACL behavior or a broader platform admission. ## Current status is an authenticated fact +Online human PKCE ID tokens additionally carry the exact access-token `at_hash` +binding described in `online-token-status.md`. This client verifies access-token +facts; a consumer that also uses an ID token separately authenticates that token, +checks its purpose, and joins its signed hash, identity, issuance, assurance and +tenant provenance to the exact access grant. Neither token substitutes for the +other. The online profile's hash is additive; disabled legacy issuance is unchanged. + `OnlineClient.Check(ctx, token, ExpectedIdentity{...})` validates the signature, issuer, exact single resource audience, subject, tenant, human/service type, optional authorized-party binding, access-token purpose, unique jti and integer diff --git a/docs/online-token-status.md b/docs/online-token-status.md index db12b15..0ea4c6b 100644 --- a/docs/online-token-status.md +++ b/docs/online-token-status.md @@ -31,7 +31,14 @@ status registers `/introspect` and `/revoke`, advertises their implemented clien authentication methods, and connects the same store to issuance and UserInfo. Human PKCE and service grants add a cryptographically random `jti` to each access -token. ID-token claims and existing assurance/tenant provenance remain unchanged. +token. Online human PKCE ID tokens add `at_hash`, binding the exact access token +using the RS256 rule in [OIDC Core 3.1.3.6/8](https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken). +The ID token keeps its relying-party audience and omits access-only `jti`/`scope`; +the access token keeps its registered resource audience and omits `at_hash`. +Existing assurance/tenant provenance and the disabled legacy ID-token contract +remain unchanged. Consumers of this online human profile require the exact signed +hash before treating an ID token and access token as one grant; equal identity +facts or issuance seconds alone cannot distinguish separate grants. Distinct grants in the same second have distinct bearer tokens: a new grant can never remint a revoked token. A successful response is emitted only after the token hash, expiry and current registration commitment have been recorded. No diff --git a/src/internal/authclient/forgejo_current_fixture_test.go b/src/internal/authclient/forgejo_current_fixture_test.go new file mode 100644 index 0000000..908fbc0 --- /dev/null +++ b/src/internal/authclient/forgejo_current_fixture_test.go @@ -0,0 +1,156 @@ +//go:build forgejo_current_fixture + +package authclient + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "keycape/internal/domain" + "keycape/internal/server/oidc" + "keycape/internal/server/telemetry" +) + +type forgejoFixtureDirectory struct { + mu sync.RWMutex + user domain.User +} + +func (d *forgejoFixtureDirectory) LookupUser(context.Context, string) (*domain.User, error) { + d.mu.RLock() + defer d.mu.RUnlock() + user := d.user + user.Groups = append([]string(nil), d.user.Groups...) + return &user, nil +} +func (d *forgejoFixtureDirectory) LookupGroups(context.Context, string) ([]domain.Group, error) { + return nil, nil +} +func (d *forgejoFixtureDirectory) ListUsers(ctx context.Context) ([]domain.User, error) { + user, _ := d.LookupUser(ctx, "") + return []domain.User{*user}, nil +} +func (d *forgejoFixtureDirectory) ValidatePassword(context.Context, string, string) (bool, error) { + return false, nil +} + +func TestOnlineActualForgejoCallbackCurrentIssuer(t *testing.T) { + f := onlineProvider(t) + directory := &forgejoFixtureDirectory{user: domain.User{ID: "uid=operator,ou=people,dc=fixture", Username: "operator", Enabled: true, Tenant: "tenant:fixture", Groups: []string{"forge-admins"}}} + client := f.issuer.ClientConfig["current-client"] + client.GrantTypes = []string{"authorization_code"} + client.ServiceSubject = "" + client.Audience = client.ClientID + client.AllowedScopes = []string{"openid", "profile", "groups"} + client.RedirectURIs = []string{"https://forgejo.invalid/callback"} + mux := http.NewServeMux() + server := httptest.NewTLSServer(mux) + t.Cleanup(server.Close) + f.issuer.Users = directory + f.issuer.Issuer = server.URL + f.status.Users = directory + f.status.Issuer = server.URL + userinfo := &oidc.UserinfoHandler{Users: directory, SigningKey: &f.issuer.SigningKey.PublicKey, Issuer: server.URL, Tokens: f.issuer.Tokens, ClientConfig: f.issuer.ClientConfig, Emitter: telemetry.NoopEmitter{}} + var statusCalls, userinfoCalls atomic.Int64 + mux.Handle("/token", f.issuer) + mux.HandleFunc("/introspect", func(w http.ResponseWriter, r *http.Request) { statusCalls.Add(1); f.status.Introspect(w, r) }) + mux.HandleFunc("/revoke", f.status.Revoke) + mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) { userinfoCalls.Add(1); userinfo.ServeHTTP(w, r) }) + keys := oidc.NewKeySet() + keys.AddKey("key-1", &f.issuer.SigningKey.PublicKey) + mux.Handle("/jwks", oidc.NewJWKSHandler(keys)) + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"issuer": server.URL, "authorization_endpoint": server.URL + "/authorize", "token_endpoint": server.URL + "/token", "userinfo_endpoint": server.URL + "/userinfo", "jwks_uri": server.URL + "/jwks", "id_token_signing_alg_values_supported": []string{"RS256"}}) + }) + admission := f.client.admission + admission.Issuer = server.URL + admission.Audience = client.ClientID + if os.WriteFile(admission.CAFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0644) != nil { + t.Fatal("fixture CA write failed") + } + var err error + f.client, err = NewOnlineClient(admission) + if err != nil { + t.Fatal("fixture admission failed") + } + der, err := x509.MarshalPKIXPublicKey(&f.issuer.SigningKey.PublicKey) + if err != nil { + t.Fatal("fixture public key failed") + } + publicKey := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})) + issue := func() Tokens { + verifier := strings.Repeat("v", 43) + digest := sha256.Sum256([]byte(verifier)) + code := f.issuer.Sessions.Create(&oidc.PKCESession{ClientID: client.ClientID, Username: directory.user.Username, RedirectURI: client.RedirectURIs[0], PKCEChallengeMethod: "S256", PKCEChallenge: base64.RawURLEncoding.EncodeToString(digest[:]), Scopes: client.AllowedScopes, MFAVerified: true, AuthTime: time.Now().Add(-time.Minute).Truncate(time.Second), ExpiresAt: time.Now().Add(time.Minute)}) + form := url.Values{"grant_type": {"authorization_code"}, "client_id": {client.ClientID}, "code": {code}, "code_verifier": {verifier}, "redirect_uri": {client.RedirectURIs[0]}} + r, _ := http.NewRequest(http.MethodPost, server.URL+"/token", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.SetBasicAuth(url.QueryEscape(client.ClientID), url.QueryEscape(client.ClientSecret)) + response, err := server.Client().Do(r) + if err != nil { + t.Fatal("actual fixture token HTTP failed") + } + defer response.Body.Close() + var tokens Tokens + if response.StatusCode != 200 || json.NewDecoder(response.Body).Decode(&tokens) != nil || tokens.AccessToken == "" || tokens.IDToken == "" { + t.Fatal("actual fixture human PKCE failed") + } + return tokens + } + first, second := issue(), issue() + directoryPath := t.TempDir() + var phases []string + probe := func(tokens Tokens, phase string) { + input := map[string]any{"id_token": tokens.IDToken, "access_token": tokens.AccessToken, "public_key_pem": publicKey, "tenant": directory.user.Tenant, "phase": phase, "current": admission} + raw, _ := json.Marshal(input) + file := filepath.Join(directoryPath, "consumer-input.json") + if os.WriteFile(file, raw, 0600) != nil { + t.Fatal("private fixture input failed") + } + command := exec.Command(os.Getenv("FORGEJO_CURRENT_TEST_BINARY"), "-test.run=^TestAccessEngineLiveFixtureCurrentIssuer$", "-test.v") + command.Env = append(os.Environ(), "FORGEJO_CURRENT_ISSUER_FIXTURE="+file) + output, err := command.CombinedOutput() + for _, private := range []string{first.AccessToken, first.IDToken, second.AccessToken, second.IDToken, client.ClientSecret} { + if bytes.Contains(output, []byte(private)) { + t.Fatal("consumer fixture diagnostic disclosed private input") + } + } + if err != nil { + t.Fatalf("actual consumer phase %s failed: %s", phase, output) + } + phases = append(phases, phase) + } + probe(first, "active") + probe(Tokens{AccessToken: second.AccessToken, IDToken: first.IDToken}, "mixed-pair") + if _, err := f.client.Revoke(context.Background(), first.AccessToken); err != nil { + t.Fatal("actual fixture revoke/independent negative failed") + } + probe(first, "revoked-after-userinfo") + probe(second, "active") + directory.mu.Lock() + directory.user.Groups = []string{"netkingdom-suspended"} + directory.mu.Unlock() + probe(second, "suspended-after-userinfo") + report := map[string]any{"status": "passed", "consumer_processes": len(phases), "phases": phases, "actual_https_human_PKCE": true, "actual_discovery_and_userinfo": true, "actual_introspection_and_revocation": true, "status_calls": statusCalls.Load(), "userinfo_calls": userinfoCalls.Load(), "actual_forgejo_callback_and_generated_client": true, "synthetic_MFA_session_seed_and_PDP_admission": true, "native_acceptance": false, "private_inputs": "fixture memory/tmpfs only"} + raw, _ := json.MarshalIndent(report, "", " ") + if os.WriteFile(os.Getenv("FORGEJO_CURRENT_REPORT"), append(raw, '\n'), 0600) != nil { + t.Fatal("public fixture result failed") + } +} diff --git a/src/internal/server/oidc/token.go b/src/internal/server/oidc/token.go index 0ab2256..0967651 100644 --- a/src/internal/server/oidc/token.go +++ b/src/internal/server/oidc/token.go @@ -224,10 +224,17 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // 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 + identityClaims := make(map[string]interface{}, len(claims)+1) + for name, value := range claims { + identityClaims[name] = value + } + var jwtToken string + if h.Tokens == nil { + jwtToken, err = buildJWT(identityClaims, kid, h.SigningKey) + if err != nil { + http.Error(w, "failed to build JWT", http.StatusInternalServerError) + return + } } // Access tokens target the statically registered resource server. ID tokens @@ -250,6 +257,16 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } if h.Tokens != nil { + // The online profile binds the signed ID token to this exact access + // grant, including same-second distinct jtis. OIDC RS256 at_hash uses + // the base64url-encoded left half of SHA-256 over the access token. + digest := sha256.Sum256([]byte(accessToken)) + identityClaims["at_hash"] = base64.RawURLEncoding.EncodeToString(digest[:16]) + jwtToken, err = buildJWT(identityClaims, kid, h.SigningKey) + if err != nil { + http.Error(w, "failed to build JWT", http.StatusInternalServerError) + return + } if err := h.Tokens.register(accessToken, client, time.Unix(exp.Unix(), 0), now); err != nil { http.Error(w, "token status unavailable", http.StatusServiceUnavailable) return diff --git a/src/internal/server/oidc/token_pair_test.go b/src/internal/server/oidc/token_pair_test.go new file mode 100644 index 0000000..4844848 --- /dev/null +++ b/src/internal/server/oidc/token_pair_test.go @@ -0,0 +1,62 @@ +package oidc_test + +import ( + "crypto/sha256" + "encoding/base64" + "net/http" + "testing" + + "keycape/internal/jose" +) + +func TestOnlineHumanIDTokenBindsExactAccessGrant(t *testing.T) { + f := newStatusFixture(t, 10) + f.token.ClientConfig["test-client"].Audience = "resource:forgejo" + first, firstID, code := f.issue(t) + second, secondID, secondCode := f.issue(t) + if code != http.StatusOK || secondCode != http.StatusOK || first == second { + t.Fatal("distinct actual grants were not issued") + } + keys := jose.KeySet{"key-1": &f.token.SigningKey.PublicKey} + for _, pair := range [][2]string{{first, firstID}, {second, secondID}} { + claims, err := jose.VerifyTyped(pair[1], keys, "JWT") + if err != nil { + t.Fatal("actual ID signature refused") + } + digest := sha256.Sum256([]byte(pair[0])) + if claims["at_hash"] != base64.RawURLEncoding.EncodeToString(digest[:16]) || claims["aud"] != "test-client" { + t.Fatal("ID token lost exact access-grant or relying-party binding") + } + if _, ok := claims["jti"]; ok { + t.Fatal("access-grant jti escaped into ID token") + } + if _, ok := claims["scope"]; ok { + t.Fatal("access scope escaped into ID token") + } + access, err := jose.VerifyTyped(pair[0], keys, "JWT") + if err != nil || access["aud"] != "resource:forgejo" { + t.Fatal("resource audience changed") + } + if _, ok := access["at_hash"]; ok { + t.Fatal("ID-only grant binding escaped into access token") + } + } + firstClaims, _ := jose.VerifyTyped(firstID, keys, "JWT") + secondClaims, _ := jose.VerifyTyped(secondID, keys, "JWT") + if firstClaims["at_hash"] == secondClaims["at_hash"] { + t.Fatal("distinct grants shared an ID-token access binding") + } +} + +func TestDisabledOnlineProfileKeepsLegacyIDToken(t *testing.T) { + f := newStatusFixture(t, 10) + f.token.Tokens = nil + _, id, code := f.issue(t) + claims, err := jose.VerifyTyped(id, jose.KeySet{"key-1": &f.token.SigningKey.PublicKey}, "JWT") + if code != http.StatusOK || err != nil { + t.Fatal("legacy profile failed") + } + if _, ok := claims["at_hash"]; ok { + t.Fatal("disabled profile changed ID-token contract") + } +} diff --git a/workplans/KEY-WP-0042-online-access-token-status.md b/workplans/KEY-WP-0042-online-access-token-status.md index 7a5528f..81def1e 100644 --- a/workplans/KEY-WP-0042-online-access-token-status.md +++ b/workplans/KEY-WP-0042-online-access-token-status.md @@ -33,7 +33,8 @@ state_hub_task_id: "d8af8e77-f6e5-5d0d-8b44-794af3dee5cf" The reviewable contract is `docs/online-token-status.md`: opt-in RFC 7662/7009 access-token status; registered owning-client authentication; private hash-only -bounded state; unique access-token jti; no ID-token change; singleton store-loss +bounded state; unique access-token jti; online human ID-token exact `at_hash` +binding with the disabled legacy ID-token contract unchanged; singleton store-loss refusal; disabled legacy behavior explicit. Source preparation is authorized by the full Custodian implementation goal. Independent issuer/profile/consumer assent remains required before admission; no such assent is inferred here. @@ -54,6 +55,20 @@ cross-client refusals, current registration and human disposition, revocation during directory lookup, process-store loss and absence of secret output. Keep the complete existing source suite, disabled profile and build passing. +2026-10-18 T02 source continuation: the actual consumer join exposed missing +same-grant correspondence. The online human ID token now signs the exact access +token's RS256 `at_hash`, retaining its own audience and excluding access-only +fields. Distinct same-second grants cannot share that binding; disabled issuance +is unchanged. This qualifies the earlier unscoped "no ID-token change" statement +for the proposed online profile, without changing existing deployment behavior. +The complete source race suite passes 726 test/subtest results with the same six +native/external skips. The actual HTTPS PKCE/discovery/UserInfo/status/revocation +handlers and real Forgejo provider/callback pass five separate consumer processes: +two current grants accepted, mixed grants/revoked original/suspended human refused. +The original issuer fails the same current consumer. MFA session seeds and PDP +admissions are synthetic. Source-only evidence: Custodian forgejo-current-token-r1. +Independent issuer/profile/consumer review and T03 native admission remain open. + ## Admit and accept the issuer lifecycle at native scope ```task