From c7b7771fa564669f3ebf5afb6cbf13eb0bc99a2d Mon Sep 17 00:00:00 2001 From: Evelyn Osman Date: Tue, 7 Jul 2026 16:30:54 +0200 Subject: [PATCH 1/3] Replace transition:generic with permission sets scopes --- .agents/skills/epds-login/SKILL.md | 20 +++++++++++++++++-- .../epds-login/references/client-metadata.md | 9 +++++---- .agents/skills/epds-login/references/flows.md | 2 +- .../demo/src/app/api/oauth/login/route.ts | 2 +- .../src/app/client-metadata.json/route.ts | 2 +- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/.agents/skills/epds-login/SKILL.md b/.agents/skills/epds-login/SKILL.md index 03dfa329..7a520a4d 100644 --- a/.agents/skills/epds-login/SKILL.md +++ b/.agents/skills/epds-login/SKILL.md @@ -15,6 +15,11 @@ AT Protocol universe (a DID, a handle, a data repository) automatically provisio From your app's perspective, ePDS uses standard AT Protocol OAuth (PAR + PKCE + DPoP). The reference implementation is `packages/demo` in the [ePDS repository](https://github.com/hypercerts-org/ePDS). +For protocol-level detail beyond ePDS specifics — DPoP proof mechanics, granular +scope design (`repo:`/`rpc:`/`blob:`/`account:`), identity verification after token +exchange, and refresh-token race handling — see the `atproto-oauth` skill. This skill +covers only what is ePDS-specific. + ## Two Flows | Flow | App provides | How user starts | Implementation | @@ -57,7 +62,7 @@ are mutually exclusive: "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", + "scope": "atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -67,6 +72,17 @@ are mutually exclusive: } ``` +> **On the `scope` value:** `atproto` is mandatory and must be listed first; the +> remaining entries request only the permissions your app needs. The examples here +> reference three hypercerts specific permission sets via the `include:` prefix — +> `include:org.hypercerts.authWrite`, `include:org.hyperboards.authWrite`, and +> `include:app.certified.authWrite`; substitute the permission sets your own app defines. A +> permission set that bundles `rpc:` service calls also needs an +> `?aud=` parameter (with `#` percent-encoded as `%23`); these are +> write-only sets, so no `aud` is required. Avoid the legacy `transition:generic` +> catch-all. See the `atproto-oauth` skill's "Scopes and Permission Sets" for the +> grammar. + Alternatively, replace `jwks_uri` with an inline `jwks` object containing the public key directly — see [client-metadata.md](references/client-metadata.md) for both forms, the @@ -85,7 +101,7 @@ const client = new NodeOAuthClient({ client_id: 'https://yourapp.example.com/client-metadata.json', client_name: 'Your App', redirect_uris: ['https://yourapp.example.com/api/oauth/callback'], - scope: 'atproto transition:generic', + scope: 'atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'private_key_jwt', diff --git a/.agents/skills/epds-login/references/client-metadata.md b/.agents/skills/epds-login/references/client-metadata.md index b99d9dcb..b775e226 100644 --- a/.agents/skills/epds-login/references/client-metadata.md +++ b/.agents/skills/epds-login/references/client-metadata.md @@ -53,7 +53,7 @@ mutually exclusive — the PDS rejects metadata that has both. "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App Name", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", + "scope": "atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -70,7 +70,7 @@ mutually exclusive — the PDS rejects metadata that has both. "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App Name", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", + "scope": "atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -96,6 +96,7 @@ host — useful for simpler setups. See [Publishing the JWKS document](#publishing-the-jwks-document) below for key generation and serving details. + ## All supported fields | Field | Required | Description | @@ -103,7 +104,7 @@ key generation and serving details. | `client_id` | Yes | Must match the URL where this file is hosted | | `client_name` | Yes | Shown on the login page and in OTP emails | | `redirect_uris` | Yes | Array of allowed callback URLs after login | -| `scope` | Yes | Always `"atproto transition:generic"` | +| `scope` | Yes | Space-separated list. `atproto` is required and must come first; add the scopes/permission sets your app needs (the examples use `include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite`). Avoid the legacy `transition:generic` catch-all — see the `atproto-oauth` skill. | | `grant_types` | Yes | Always `["authorization_code", "refresh_token"]` | | `response_types` | Yes | Always `["code"]` | | `token_endpoint_auth_method` | Yes | `"private_key_jwt"` (recommended) or `"none"` — see above | @@ -266,7 +267,7 @@ above for the trade-offs. "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App Name", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", + "scope": "atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", diff --git a/.agents/skills/epds-login/references/flows.md b/.agents/skills/epds-login/references/flows.md index a12ce519..8d3f471a 100644 --- a/.agents/skills/epds-login/references/flows.md +++ b/.agents/skills/epds-login/references/flows.md @@ -156,7 +156,7 @@ export async function handleLogin(email: string) { client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', - scope: 'atproto transition:generic', + scope: 'atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite', state, code_challenge: codeChallenge, code_challenge_method: 'S256', diff --git a/packages/demo/src/app/api/oauth/login/route.ts b/packages/demo/src/app/api/oauth/login/route.ts index 30a855e0..1a953994 100644 --- a/packages/demo/src/app/api/oauth/login/route.ts +++ b/packages/demo/src/app/api/oauth/login/route.ts @@ -150,7 +150,7 @@ export async function GET(request: Request) { client_id: clientId, redirect_uri: redirectUri, response_type: 'code', - scope: 'atproto transition:generic', + scope: 'atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite', state, code_challenge: codeChallenge, code_challenge_method: 'S256', diff --git a/packages/demo/src/app/client-metadata.json/route.ts b/packages/demo/src/app/client-metadata.json/route.ts index 0651af16..f02e8bf0 100644 --- a/packages/demo/src/app/client-metadata.json/route.ts +++ b/packages/demo/src/app/client-metadata.json/route.ts @@ -50,7 +50,7 @@ export async function GET() { client_uri: baseUrl, logo_uri: `${baseUrl}/certified-logo.png`, redirect_uris: [`${baseUrl}/api/oauth/callback`], - scope: 'atproto transition:generic', + scope: 'atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], ...(isConfidential From 990ef2bdba834e29055a760bb94e60c74ef752f4 Mon Sep 17 00:00:00 2001 From: Evelyn Osman Date: Tue, 7 Jul 2026 16:50:12 +0200 Subject: [PATCH 2/3] Drop hyperboards permission set from demo --- .agents/skills/epds-login/SKILL.md | 11 ++++++----- .../skills/epds-login/references/client-metadata.md | 9 ++++----- .agents/skills/epds-login/references/flows.md | 3 ++- docs/tutorial.md | 8 +++++--- packages/demo/src/app/api/oauth/login/route.ts | 3 ++- packages/demo/src/app/client-metadata.json/route.ts | 3 ++- 6 files changed, 21 insertions(+), 16 deletions(-) diff --git a/.agents/skills/epds-login/SKILL.md b/.agents/skills/epds-login/SKILL.md index 7a520a4d..1d5fadc3 100644 --- a/.agents/skills/epds-login/SKILL.md +++ b/.agents/skills/epds-login/SKILL.md @@ -62,7 +62,7 @@ are mutually exclusive: "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite", + "scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -74,9 +74,9 @@ are mutually exclusive: > **On the `scope` value:** `atproto` is mandatory and must be listed first; the > remaining entries request only the permissions your app needs. The examples here -> reference three hypercerts specific permission sets via the `include:` prefix — -> `include:org.hypercerts.authWrite`, `include:org.hyperboards.authWrite`, and -> `include:app.certified.authWrite`; substitute the permission sets your own app defines. A +> reference two hypercerts specific permission sets via the `include:` prefix — +> `include:org.hypercerts.authWrite` and `include:app.certified.authWrite`; +> substitute the permission sets your own app defines. A > permission set that bundles `rpc:` service calls also needs an > `?aud=` parameter (with `#` percent-encoded as `%23`); these are > write-only sets, so no `aud` is required. Avoid the legacy `transition:generic` @@ -101,7 +101,8 @@ const client = new NodeOAuthClient({ client_id: 'https://yourapp.example.com/client-metadata.json', client_name: 'Your App', redirect_uris: ['https://yourapp.example.com/api/oauth/callback'], - scope: 'atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'private_key_jwt', diff --git a/.agents/skills/epds-login/references/client-metadata.md b/.agents/skills/epds-login/references/client-metadata.md index b775e226..130aceee 100644 --- a/.agents/skills/epds-login/references/client-metadata.md +++ b/.agents/skills/epds-login/references/client-metadata.md @@ -53,7 +53,7 @@ mutually exclusive — the PDS rejects metadata that has both. "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App Name", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite", + "scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -70,7 +70,7 @@ mutually exclusive — the PDS rejects metadata that has both. "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App Name", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite", + "scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -96,7 +96,6 @@ host — useful for simpler setups. See [Publishing the JWKS document](#publishing-the-jwks-document) below for key generation and serving details. - ## All supported fields | Field | Required | Description | @@ -104,7 +103,7 @@ key generation and serving details. | `client_id` | Yes | Must match the URL where this file is hosted | | `client_name` | Yes | Shown on the login page and in OTP emails | | `redirect_uris` | Yes | Array of allowed callback URLs after login | -| `scope` | Yes | Space-separated list. `atproto` is required and must come first; add the scopes/permission sets your app needs (the examples use `include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite`). Avoid the legacy `transition:generic` catch-all — see the `atproto-oauth` skill. | +| `scope` | Yes | Space-separated list. `atproto` is required and must come first; add the scopes/permission sets your app needs (the examples use `include:org.hypercerts.authWrite include:app.certified.authWrite`). Avoid the legacy `transition:generic` catch-all — see the `atproto-oauth` skill. | | `grant_types` | Yes | Always `["authorization_code", "refresh_token"]` | | `response_types` | Yes | Always `["code"]` | | `token_endpoint_auth_method` | Yes | `"private_key_jwt"` (recommended) or `"none"` — see above | @@ -267,7 +266,7 @@ above for the trade-offs. "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App Name", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite", + "scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", diff --git a/.agents/skills/epds-login/references/flows.md b/.agents/skills/epds-login/references/flows.md index 8d3f471a..4b6a9a15 100644 --- a/.agents/skills/epds-login/references/flows.md +++ b/.agents/skills/epds-login/references/flows.md @@ -156,7 +156,8 @@ export async function handleLogin(email: string) { client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', - scope: 'atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', state, code_challenge: codeChallenge, code_challenge_method: 'S256', diff --git a/docs/tutorial.md b/docs/tutorial.md index a61f23e7..9b33f34e 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -190,7 +190,7 @@ The file must be served with `Content-Type: application/json`: "client_uri": "https://yourapp.example.com", "logo_uri": "https://yourapp.example.com/logo.png", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", + "scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -674,7 +674,8 @@ const client = new NodeOAuthClient({ client_id: 'https://yourapp.example.com/client-metadata.json', client_name: 'Your App', redirect_uris: ['https://yourapp.example.com/api/oauth/callback'], - scope: 'atproto transition:generic', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'private_key_jwt', @@ -772,7 +773,8 @@ const parBody = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', - scope: 'atproto transition:generic', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', state, code_challenge: codeChallenge, code_challenge_method: 'S256', diff --git a/packages/demo/src/app/api/oauth/login/route.ts b/packages/demo/src/app/api/oauth/login/route.ts index 1a953994..79c12172 100644 --- a/packages/demo/src/app/api/oauth/login/route.ts +++ b/packages/demo/src/app/api/oauth/login/route.ts @@ -150,7 +150,8 @@ export async function GET(request: Request) { client_id: clientId, redirect_uri: redirectUri, response_type: 'code', - scope: 'atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', state, code_challenge: codeChallenge, code_challenge_method: 'S256', diff --git a/packages/demo/src/app/client-metadata.json/route.ts b/packages/demo/src/app/client-metadata.json/route.ts index f02e8bf0..a5c0b5cf 100644 --- a/packages/demo/src/app/client-metadata.json/route.ts +++ b/packages/demo/src/app/client-metadata.json/route.ts @@ -50,7 +50,8 @@ export async function GET() { client_uri: baseUrl, logo_uri: `${baseUrl}/certified-logo.png`, redirect_uris: [`${baseUrl}/api/oauth/callback`], - scope: 'atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], ...(isConfidential From c730fcef37f387ff8f0bebb15bb7bfc6a0f72d3d Mon Sep 17 00:00:00 2001 From: Evelyn Osman Date: Tue, 7 Jul 2026 17:42:38 +0200 Subject: [PATCH 3/3] Update e2e tests to match permission sets usage --- e2e/step-definitions/consent.steps.ts | 34 +++++++++++--------- e2e/step-definitions/sec-fetch-site.steps.ts | 3 +- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/e2e/step-definitions/consent.steps.ts b/e2e/step-definitions/consent.steps.ts index 5982b7a3..45eeb01a 100644 --- a/e2e/step-definitions/consent.steps.ts +++ b/e2e/step-definitions/consent.steps.ts @@ -45,24 +45,28 @@ Then('a consent screen is displayed', async function (this: EpdsWorld) { timeout: 30_000, }) - // 2. The demo clients request `atproto transition:generic`. For that - // scope set, @atproto/oauth-provider-ui's ScopeDescription renders - // multiple permission cards — including one titled "Authenticate" - // via the RpcMethodsDetails component, which fires on - // hasTransitionGeneric. Assert that card is visible: this proves - // the scope was actually parsed and rendered a permission summary, - // not that the page loaded blank with just an Authorize button. + // 2. The demo clients request two granular permission sets — + // `include:org.hypercerts.authWrite` and `include:app.certified.authWrite`. + // @atproto/oauth-provider-ui's ScopeDescription resolves each + // permission-set lexicon and renders a permission card titled from the + // set's `title` field ("Manage your Hypercerts data" / "Manage your + // Certified data"). Assert those cards are visible: this proves the + // scopes were actually parsed, the permission-set lexicons resolved, and + // a permission summary rendered — not that the page loaded blank with + // just an Authorize button. // - // We deliberately do NOT assert on the raw scope strings - // (`atproto`, `transition:generic`) being visible on the page — - // those only appear inside a collapsed "Technical details" - // panel that is hidden (HTML `hidden` attribute + - // aria-hidden="true") until the user clicks its disclosure - // button. Asserting on the user-facing scope card is both more - // meaningful (what users actually see) and more resilient + // We deliberately do NOT assert on the raw scope strings (`atproto`, + // `include:...`) being visible on the page — those only appear inside a + // collapsed "Technical details" panel that is hidden (HTML + // `hidden` attribute + aria-hidden="true") until the user clicks its + // disclosure button. Asserting on the user-facing permission cards is + // both more meaningful (what users actually see) and more resilient // (doesn't depend on the details-panel implementation). await expect( - page.getByRole('heading', { name: 'Authenticate' }), + page.getByRole('heading', { name: 'Manage your Hypercerts data' }), + ).toBeVisible() + await expect( + page.getByRole('heading', { name: 'Manage your Certified data' }), ).toBeVisible() }) diff --git a/e2e/step-definitions/sec-fetch-site.steps.ts b/e2e/step-definitions/sec-fetch-site.steps.ts index bbfddcd1..81e515ce 100644 --- a/e2e/step-definitions/sec-fetch-site.steps.ts +++ b/e2e/step-definitions/sec-fetch-site.steps.ts @@ -33,7 +33,8 @@ When( client_id: clientMetaUrl, redirect_uri: `${testEnv.demoUrl}/api/oauth/callback`, response_type: 'code', - scope: 'atproto transition:generic', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', state: 'test-state', code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', code_challenge_method: 'S256',