332 lines
15 KiB
Diff
332 lines
15 KiB
Diff
--- a/docs/current-token-client.md
|
|
+++ b/docs/current-token-client.md
|
|
@@ -27,6 +27,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,
|
|
--- a/docs/online-token-status.md
|
|
+++ b/docs/online-token-status.md
|
|
@@ -31,7 +31,14 @@
|
|
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
|
|
--- /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")
|
|
+ }
|
|
+}
|
|
--- a/src/internal/server/oidc/token.go
|
|
+++ b/src/internal/server/oidc/token.go
|
|
@@ -224,10 +224,17 @@
|
|
|
|
// 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 @@
|
|
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
|
|
--- /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")
|
|
+ }
|
|
+}
|
|
--- a/workplans/KEY-WP-0042-online-access-token-status.md
|
|
+++ b/workplans/KEY-WP-0042-online-access-token-status.md
|
|
@@ -33,7 +33,8 @@
|
|
|
|
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.
|
|
@@ -53,6 +54,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
|
|
|