Bind online human ID tokens to the exact current access grant
Some checks failed
Authentication acceptance / acceptance (push) Failing after 1m4s
Authentication acceptance / provider-contract (push) Successful in 12s
Build and Publish Container Image / build-and-push (push) Successful in 31s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7
This commit is contained in:
tegwick 2026-10-18 12:13:56 +02:00
parent 08c083bae9
commit e2c5a94782
6 changed files with 270 additions and 6 deletions

View file

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

View file

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

View file

@ -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")
}
}

View file

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

View file

@ -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")
}
}

View file

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