Skip to content

Commit 28c3521

Browse files
authored
Merge pull request #188 from hypercerts-org/fix/oauth-scopes
Replace transition:generic with permission sets scopes
2 parents 7dc00a7 + c730fce commit 28c3521

8 files changed

Lines changed: 55 additions & 28 deletions

File tree

.agents/skills/epds-login/SKILL.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ AT Protocol universe (a DID, a handle, a data repository) automatically provisio
1515
From your app's perspective, ePDS uses standard AT Protocol OAuth (PAR + PKCE + DPoP).
1616
The reference implementation is `packages/demo` in the [ePDS repository](https://github.com/hypercerts-org/ePDS).
1717

18+
For protocol-level detail beyond ePDS specifics — DPoP proof mechanics, granular
19+
scope design (`repo:`/`rpc:`/`blob:`/`account:`), identity verification after token
20+
exchange, and refresh-token race handling — see the `atproto-oauth` skill. This skill
21+
covers only what is ePDS-specific.
22+
1823
## Two Flows
1924

2025
| Flow | App provides | How user starts | Implementation |
@@ -57,7 +62,7 @@ are mutually exclusive:
5762
"client_id": "https://yourapp.example.com/client-metadata.json",
5863
"client_name": "Your App",
5964
"redirect_uris": ["https://yourapp.example.com/api/oauth/callback"],
60-
"scope": "atproto transition:generic",
65+
"scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite",
6166
"grant_types": ["authorization_code", "refresh_token"],
6267
"response_types": ["code"],
6368
"token_endpoint_auth_method": "private_key_jwt",
@@ -67,6 +72,17 @@ are mutually exclusive:
6772
}
6873
```
6974

75+
> **On the `scope` value:** `atproto` is mandatory and must be listed first; the
76+
> remaining entries request only the permissions your app needs. The examples here
77+
> reference two hypercerts specific permission sets via the `include:` prefix —
78+
> `include:org.hypercerts.authWrite` and `include:app.certified.authWrite`;
79+
> substitute the permission sets your own app defines. A
80+
> permission set that bundles `rpc:` service calls also needs an
81+
> `?aud=<service-did>` parameter (with `#` percent-encoded as `%23`); these are
82+
> write-only sets, so no `aud` is required. Avoid the legacy `transition:generic`
83+
> catch-all. See the `atproto-oauth` skill's "Scopes and Permission Sets" for the
84+
> grammar.
85+
7086
Alternatively, replace `jwks_uri` with an inline `jwks` object containing
7187
the public key directly — see
7288
[client-metadata.md](references/client-metadata.md) for both forms, the
@@ -85,7 +101,8 @@ const client = new NodeOAuthClient({
85101
client_id: 'https://yourapp.example.com/client-metadata.json',
86102
client_name: 'Your App',
87103
redirect_uris: ['https://yourapp.example.com/api/oauth/callback'],
88-
scope: 'atproto transition:generic',
104+
scope:
105+
'atproto include:org.hypercerts.authWrite include:app.certified.authWrite',
89106
grant_types: ['authorization_code', 'refresh_token'],
90107
response_types: ['code'],
91108
token_endpoint_auth_method: 'private_key_jwt',

.agents/skills/epds-login/references/client-metadata.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ mutually exclusive — the PDS rejects metadata that has both.
5353
"client_id": "https://yourapp.example.com/client-metadata.json",
5454
"client_name": "Your App Name",
5555
"redirect_uris": ["https://yourapp.example.com/api/oauth/callback"],
56-
"scope": "atproto transition:generic",
56+
"scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite",
5757
"grant_types": ["authorization_code", "refresh_token"],
5858
"response_types": ["code"],
5959
"token_endpoint_auth_method": "private_key_jwt",
@@ -70,7 +70,7 @@ mutually exclusive — the PDS rejects metadata that has both.
7070
"client_id": "https://yourapp.example.com/client-metadata.json",
7171
"client_name": "Your App Name",
7272
"redirect_uris": ["https://yourapp.example.com/api/oauth/callback"],
73-
"scope": "atproto transition:generic",
73+
"scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite",
7474
"grant_types": ["authorization_code", "refresh_token"],
7575
"response_types": ["code"],
7676
"token_endpoint_auth_method": "private_key_jwt",
@@ -103,7 +103,7 @@ key generation and serving details.
103103
| `client_id` | Yes | Must match the URL where this file is hosted |
104104
| `client_name` | Yes | Shown on the login page and in OTP emails |
105105
| `redirect_uris` | Yes | Array of allowed callback URLs after login |
106-
| `scope` | Yes | Always `"atproto transition:generic"` |
106+
| `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. |
107107
| `grant_types` | Yes | Always `["authorization_code", "refresh_token"]` |
108108
| `response_types` | Yes | Always `["code"]` |
109109
| `token_endpoint_auth_method` | Yes | `"private_key_jwt"` (recommended) or `"none"` — see above |
@@ -266,7 +266,7 @@ above for the trade-offs.
266266
"client_id": "https://yourapp.example.com/client-metadata.json",
267267
"client_name": "Your App Name",
268268
"redirect_uris": ["https://yourapp.example.com/api/oauth/callback"],
269-
"scope": "atproto transition:generic",
269+
"scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite",
270270
"grant_types": ["authorization_code", "refresh_token"],
271271
"response_types": ["code"],
272272
"token_endpoint_auth_method": "none",

.agents/skills/epds-login/references/flows.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,8 @@ export async function handleLogin(email: string) {
156156
client_id: CLIENT_ID,
157157
redirect_uri: REDIRECT_URI,
158158
response_type: 'code',
159-
scope: 'atproto transition:generic',
159+
scope:
160+
'atproto include:org.hypercerts.authWrite include:app.certified.authWrite',
160161
state,
161162
code_challenge: codeChallenge,
162163
code_challenge_method: 'S256',

docs/tutorial.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ The file must be served with `Content-Type: application/json`:
190190
"client_uri": "https://yourapp.example.com",
191191
"logo_uri": "https://yourapp.example.com/logo.png",
192192
"redirect_uris": ["https://yourapp.example.com/api/oauth/callback"],
193-
"scope": "atproto transition:generic",
193+
"scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite",
194194
"grant_types": ["authorization_code", "refresh_token"],
195195
"response_types": ["code"],
196196
"token_endpoint_auth_method": "private_key_jwt",
@@ -674,7 +674,8 @@ const client = new NodeOAuthClient({
674674
client_id: 'https://yourapp.example.com/client-metadata.json',
675675
client_name: 'Your App',
676676
redirect_uris: ['https://yourapp.example.com/api/oauth/callback'],
677-
scope: 'atproto transition:generic',
677+
scope:
678+
'atproto include:org.hypercerts.authWrite include:app.certified.authWrite',
678679
grant_types: ['authorization_code', 'refresh_token'],
679680
response_types: ['code'],
680681
token_endpoint_auth_method: 'private_key_jwt',
@@ -772,7 +773,8 @@ const parBody = new URLSearchParams({
772773
client_id: clientId,
773774
redirect_uri: redirectUri,
774775
response_type: 'code',
775-
scope: 'atproto transition:generic',
776+
scope:
777+
'atproto include:org.hypercerts.authWrite include:app.certified.authWrite',
776778
state,
777779
code_challenge: codeChallenge,
778780
code_challenge_method: 'S256',

e2e/step-definitions/consent.steps.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,24 +45,28 @@ Then('a consent screen is displayed', async function (this: EpdsWorld) {
4545
timeout: 30_000,
4646
})
4747

48-
// 2. The demo clients request `atproto transition:generic`. For that
49-
// scope set, @atproto/oauth-provider-ui's ScopeDescription renders
50-
// multiple permission cards — including one titled "Authenticate"
51-
// via the RpcMethodsDetails component, which fires on
52-
// hasTransitionGeneric. Assert that card is visible: this proves
53-
// the scope was actually parsed and rendered a permission summary,
54-
// not that the page loaded blank with just an Authorize button.
48+
// 2. The demo clients request two granular permission sets —
49+
// `include:org.hypercerts.authWrite` and `include:app.certified.authWrite`.
50+
// @atproto/oauth-provider-ui's ScopeDescription resolves each
51+
// permission-set lexicon and renders a permission card titled from the
52+
// set's `title` field ("Manage your Hypercerts data" / "Manage your
53+
// Certified data"). Assert those cards are visible: this proves the
54+
// scopes were actually parsed, the permission-set lexicons resolved, and
55+
// a permission summary rendered — not that the page loaded blank with
56+
// just an Authorize button.
5557
//
56-
// We deliberately do NOT assert on the raw scope strings
57-
// (`atproto`, `transition:generic`) being visible on the page —
58-
// those only appear inside a collapsed "Technical details"
59-
// <Admonition> panel that is hidden (HTML `hidden` attribute +
60-
// aria-hidden="true") until the user clicks its disclosure
61-
// button. Asserting on the user-facing scope card is both more
62-
// meaningful (what users actually see) and more resilient
58+
// We deliberately do NOT assert on the raw scope strings (`atproto`,
59+
// `include:...`) being visible on the page — those only appear inside a
60+
// collapsed "Technical details" <Admonition> panel that is hidden (HTML
61+
// `hidden` attribute + aria-hidden="true") until the user clicks its
62+
// disclosure button. Asserting on the user-facing permission cards is
63+
// both more meaningful (what users actually see) and more resilient
6364
// (doesn't depend on the details-panel implementation).
6465
await expect(
65-
page.getByRole('heading', { name: 'Authenticate' }),
66+
page.getByRole('heading', { name: 'Manage your Hypercerts data' }),
67+
).toBeVisible()
68+
await expect(
69+
page.getByRole('heading', { name: 'Manage your Certified data' }),
6670
).toBeVisible()
6771
})
6872

e2e/step-definitions/sec-fetch-site.steps.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ When(
3333
client_id: clientMetaUrl,
3434
redirect_uri: `${testEnv.demoUrl}/api/oauth/callback`,
3535
response_type: 'code',
36-
scope: 'atproto transition:generic',
36+
scope:
37+
'atproto include:org.hypercerts.authWrite include:app.certified.authWrite',
3738
state: 'test-state',
3839
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
3940
code_challenge_method: 'S256',

packages/demo/src/app/api/oauth/login/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,8 @@ export async function GET(request: Request) {
153153
client_id: clientId,
154154
redirect_uri: redirectUri,
155155
response_type: 'code',
156-
scope: 'atproto transition:generic',
156+
scope:
157+
'atproto include:org.hypercerts.authWrite include:app.certified.authWrite',
157158
state,
158159
code_challenge: codeChallenge,
159160
code_challenge_method: 'S256',

packages/demo/src/app/client-metadata.json/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ export async function GET() {
5050
client_uri: baseUrl,
5151
logo_uri: `${baseUrl}/certified-logo.png`,
5252
redirect_uris: [`${baseUrl}/api/oauth/callback`],
53-
scope: 'atproto transition:generic',
53+
scope:
54+
'atproto include:org.hypercerts.authWrite include:app.certified.authWrite',
5455
grant_types: ['authorization_code', 'refresh_token'],
5556
response_types: ['code'],
5657
...(isConfidential

0 commit comments

Comments
 (0)