Skip to content

Split credentials into a separate endpoint#8

Merged
m0tzy merged 11 commits into
mainfrom
madison/separate-identity-and-tokens
Jun 4, 2026
Merged

Split credentials into a separate endpoint#8
m0tzy merged 11 commits into
mainfrom
madison/separate-identity-and-tokens

Conversation

@m0tzy

@m0tzy m0tzy commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator
  • I have QA'd the changes

Stack

  1. Split credentials into a separate endpoint #8 ← you are here
  2. Migrate revocation channel to SET delivery (RFC 8417/8935) #9
  3. Invert the claim flow to mirror device auth #10
  4. ID-JAG step-up + auth_time freshness #11
  5. Claim anonymous registration via ID-JAG #12

Summary

Separate the identity-assertion surface (/agent/identity, /agent/identity/claim) from the credential surface (/oauth2/token, /oauth2/revoke). Registration mints a service-signed identity_assertion; agents exchange it at the token endpoint for an access_token (RFC 7523 JWT-bearer). Credentials no longer come back from /agent/identity or the claim ceremony.

  • Rename /agent/auth/agent/identity (and claim sub-paths)
  • Add /oauth2/token (RFC 7523 JWT-bearer) and /oauth2/revoke (RFC 7009)
  • OAuth error envelope per RFC 6749 §5.2; Cache-Control: no-store + Pragma: no-cache on token-endpoint responses per RFC 6749 §5.1
  • Clean up the Registration model: derive status, drop credential coupling at registration time
  • Add service-side ES256 signing key for service-issued identity_assertions
  • Discovery JSON: surface top-level OAuth fields; rename agent_auth fields to identity_endpoint / claim_endpoint; keep revocation_uri at the legacy /agent/auth/revoke path (the secevent rename comes with the next PR)
  • Update READMEs and AUTH.md to describe the new two-endpoint shape

@m0tzy m0tzy changed the title Split identity and token endpoints Split credentials into a separate endpoint Jun 2, 2026
@m0tzy m0tzy force-pushed the madison/separate-identity-and-tokens branch 2 times, most recently from c9d5b19 to 2568479 Compare June 2, 2026 22:23
Separate the identity-assertion surface (/agent/identity, /agent/identity/claim)
from the credential surface (/oauth2/token, /oauth2/revoke). Registration mints
a service-signed identity_assertion; agents exchange it at the token endpoint
for an access_token (RFC 7523 JWT-bearer). Credentials no longer come back from
/agent/identity or the claim ceremony.

- Rename /agent/auth → /agent/identity (and claim sub-paths)
- Add /oauth2/token (RFC 7523 JWT-bearer) and /oauth2/revoke (RFC 7009)
- OAuth error envelope per RFC 6749 §5.2; Cache-Control: no-store +
  Pragma: no-cache on token-endpoint responses per RFC 6749 §5.1
- Clean up the Registration model: derive status, drop credential coupling
- Add service-side ES256 signing key for service-issued identity_assertions
- Discovery JSON: surface top-level OAuth fields; rename agent_auth fields
  to identity_endpoint / claim_endpoint; keep revocation_uri at the legacy
  /agent/auth/revoke path (the secevent rename comes with PR 3)
- Update READMEs and AUTH.md to describe the new two-endpoint shape
@m0tzy m0tzy force-pushed the madison/separate-identity-and-tokens branch from 2568479 to d313bb3 Compare June 2, 2026 23:39
@m0tzy

m0tzy commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile

@greptile-apps

greptile-apps Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR separates identity registration (/agent/identity) from credential issuance (/oauth2/token, /oauth2/revoke). All three registration flows now return a service-signed identity_assertion JWT (ES256, typ: oauth-id-jag+jwt, sub = registration.id) instead of credentials directly; agents exchange that assertion at the RFC 7523 JWT-bearer token endpoint for a short-lived access_token.

  • New token + revocation surface: token.ts adds RFC 7523 /oauth2/token (scope-gating anonymous pre-claim registrations correctly) and RFC 7009 /oauth2/revoke (idempotent, no enumeration leakage); RFC 6749 §5.1/5.2 cache headers and error envelopes applied throughout.
  • Key management: keys.ts generates and persists an ES256 signing keypair on first boot; verifyServiceIdJag uses getPublicKey() for verification (fixing the previously-flagged private-key bug); however, getPublicJwk() and getKid() are exported but never wired to any route — the CHANGELOG states "JWKS published at /.well-known/jwks.json" but no such endpoint or jwks_uri discovery field exists.
  • Registration model: Registration is refactored to a class with a derived status getter; RegistrationClaim/RegistrationClaimAttempt sub-objects replace the previous flat fields; issueApiKey is removed; revokeForDelegation (logout token path) still does not expire the underlying registration, leaving a window for re-issuance via a still-valid identity_assertion.

Confidence Score: 3/5

The core token exchange and scope-gating logic is sound, but the JWKS endpoint the CHANGELOG claims is published does not exist, and the registration-layer logout gap (previously flagged) is still open.

The new token endpoint correctly gates anonymous pre-claim registrations, the key-verification path now uses the public key, and the Registration model refactor is clean. However, getPublicJwk() is exported but never wired to a route — the CHANGELOG says JWKS is published at /.well-known/jwks.json and the discovery document should carry jwks_uri, neither of which is true. Additionally, provider-driven logout still leaves the underlying registration active, so a long-lived identity_assertion can be re-exchanged for a fresh access_token even after the provider fires a logout token.

agent-services/src/routes/well-known.ts (missing JWKS route and jwks_uri discovery field) and agent-services/src/store.ts (revokeForDelegation does not invalidate the registration).

Important Files Changed

Filename Overview
agent-services/src/routes/well-known.ts AS metadata updated with OAuth 2.0 fields (issuer, token_endpoint, revocation_endpoint, grant_types_supported). Missing jwks_uri and no /.well-known/jwks.json route despite CHANGELOG claiming it's published.
agent-services/src/keys.ts New key-management module generating and persisting an ES256 signing key. getPublicJwk() and getKid() are exported but never called — they should be wired to a JWKS endpoint.
agent-services/src/routes/token.ts New RFC 7523 token endpoint and RFC 7009 revocation endpoint. Scope logic correctly gates anonymous pre-claim registrations. A partial token value is logged on revocation (previously flagged).
agent-services/src/verify.ts Adds signServiceIdJag and verifyServiceIdJag. Uses getPublicKey() for verification (correctly fixing the previously-flagged private-key bug). JWT iss/aud/typ/exp all validated per custom rule.
agent-services/src/store.ts Registration refactored from a flat object to a class with a derived status getter. Credential issuance removed from registration time; issueApiKey dropped; in-place scope upgrade loops over all credentials for the registration. revokeForDelegation still doesn't expire the underlying registration (previously flagged).
agent-services/src/routes/agent-auth.ts Endpoint renamed from /agent/auth to /agent/identity. All three registration paths now return a service-signed identity_assertion instead of credentials. requested_credential_type removed. Logic looks correct.
agent-services/src/schemas.ts Added tokenEndpointBody and revocationEndpointBody schemas; removed requested_credential_type from registration bodies. Straightforward and consistent with the new endpoint shape.
agent-services/src/config.ts Adds endpoint path constants, serviceAssertionTtlSeconds, and keyPath. Clean; no issues.

Sequence Diagram

sequenceDiagram
    participant Agent
    participant Service as Service (/agent/identity)
    participant TokenEP as Service (/oauth2/token)
    participant API as Service (/api/resource)
    participant Provider as IdP

    Note over Agent,Provider: ID-JAG flow
    Agent->>Service: "POST /agent/identity { type: identity_assertion, assertion: ID-JAG }"
    Service->>Provider: GET /.well-known/jwks.json (verify ID-JAG)
    Provider-->>Service: JWKS
    Service-->>Agent: "200 { identity_assertion: service-signed JWT }"
    Agent->>TokenEP: "POST /oauth2/token grant_type=jwt-bearer&assertion=..."
    TokenEP-->>Agent: "200 { access_token, token_type, expires_in, scope }"
    Agent->>API: GET /api/resource Authorization: Bearer access_token
    API-->>Agent: 200

    Note over Agent,Service: Anonymous + claim flow
    Agent->>Service: "POST /agent/identity { type: anonymous }"
    Service-->>Agent: "200 { identity_assertion, claim_token, pre_claim_scopes }"
    Agent->>TokenEP: "POST /oauth2/token assertion=..."
    TokenEP-->>Agent: "200 { access_token scope=api.read }"
    Agent->>Service: "POST /agent/identity/claim { claim_token, email }"
    Service-->>Agent: "200 { claim_attempt_id }"
    Agent->>Service: "POST /agent/identity/claim/complete { claim_token, otp }"
    Service-->>Agent: "200 { status: claimed }"
    Agent->>TokenEP: POST /oauth2/token same assertion re-exchange
    TokenEP-->>Agent: "200 { access_token scope=api.read api.write }"
Loading

Reviews (6): Last reviewed commit: "Some changelog tweaks" | Re-trigger Greptile

Comment thread agent-services/src/verify.ts
Comment thread agent-services/src/routes/token.ts
@mthadley mthadley self-requested a review June 3, 2026 19:20
Comment thread agent-providers/README.md
Comment thread agent-providers/README.md Outdated
Comment thread agent-providers/README.md Outdated
m0tzy and others added 3 commits June 3, 2026 13:37
Co-authored-by: Michael Hadley <michael.hadley@workos.com>
Co-authored-by: Michael Hadley <michael.hadley@workos.com>
@m0tzy

m0tzy commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile

Comment thread agent-services/README.md Outdated
Comment thread README.md Outdated
Comment thread AUTH.md Outdated
Comment thread AUTH.md Outdated
Comment thread AUTH.md Outdated
**Refresh.** When the access_token expires (`expires_in` seconds after issuance), re-call [Step 5](#step-5--exchange-the-assertion) with the same `identity_assertion`. When the identity assertion itself expires or `/oauth2/token` returns `invalid_grant`, restart at [Step 3](#step-3--register). There is no OAuth refresh_token in this flow — the two-step pattern replaces it.

If you get a 401 on a previously-working credential: drop it, restart at [Step 1](#step-1--discover). Do not stash the credential and retry.
If you get a 401 on a previously-working access_token: try [Step 5](#step-5--exchange-the-assertion) once with the current assertion. If that also fails, drop everything and restart at [Step 1](#step-1--discover).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"drop everything" doesn't feel very descriptive or helpful:

Suggested change
If you get a 401 on a previously-working access_token: try [Step 5](#step-5--exchange-the-assertion) once with the current assertion. If that also fails, drop everything and restart at [Step 1](#step-1--discover).
If you get a 401 on a previously-working access_token: try [Step 5](#step-5--exchange-the-assertion) once with the current assertion. If that also fails, restart the flow at [Step 1](#step-1--discover).

@m0tzy m0tzy Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about If that also fails, discard the expired identity assertion, and restart at [Step 1](#step-1--discover).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love it. Let's go with that!

m0tzy and others added 4 commits June 3, 2026 14:49
Co-authored-by: Michael Hadley <michael.hadley@workos.com>
Co-authored-by: Michael Hadley <michael.hadley@workos.com>
Co-authored-by: Michael Hadley <michael.hadley@workos.com>
Co-authored-by: Michael Hadley <michael.hadley@workos.com>
The scope gate at /oauth2/token only checked status === "unclaimed",
but the moment the agent calls /agent/identity/claim the status moves
to "pending_claim" — and the gate fell through to the full scope set
before the human had confirmed ownership. Tighten to status !== "claimed"
so anonymous registrations stay capped until the OTP ceremony actually
completes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread agent-services/src/routes/token.ts
m0tzy and others added 2 commits June 3, 2026 16:42
- AUTH.md: clarify the 401-on-access_token recovery path (discard the
  expired assertion before restarting at Step 1).
- CHANGELOG.md: write the v0.2.0 entry covering this PR — new
  /oauth2/token and /oauth2/revoke endpoints, the /agent/auth →
  /agent/identity rename, discovery layout, error envelope changes, and
  the pre-claim scope fix.
- package.json: 0.1.0 → 0.2.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@m0tzy m0tzy merged commit 2297946 into main Jun 4, 2026
2 checks passed
@m0tzy m0tzy deleted the madison/separate-identity-and-tokens branch June 4, 2026 00:00
@m0tzy m0tzy restored the madison/separate-identity-and-tokens branch June 4, 2026 00:01
@m0tzy m0tzy mentioned this pull request Jun 4, 2026
1 task
@m0tzy m0tzy deleted the madison/separate-identity-and-tokens branch June 5, 2026 16:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants