Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions .agents/skills/epds-login/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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:app.certified.authWrite",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "private_key_jwt",
Expand All @@ -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 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=<service-did>` 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
Expand All @@ -85,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 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',
Expand Down
8 changes: 4 additions & 4 deletions .agents/skills/epds-login/references/client-metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:app.certified.authWrite",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "private_key_jwt",
Expand All @@ -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:app.certified.authWrite",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "private_key_jwt",
Expand Down Expand Up @@ -103,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 | 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: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 |
Expand Down Expand Up @@ -266,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 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": "none",
Expand Down
3 changes: 2 additions & 1 deletion .agents/skills/epds-login/references/flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@ 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:app.certified.authWrite',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
Expand Down
8 changes: 5 additions & 3 deletions docs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
34 changes: 19 additions & 15 deletions e2e/step-definitions/consent.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
// <Admonition> 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" <Admonition> 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()
})

Expand Down
3 changes: 2 additions & 1 deletion e2e/step-definitions/sec-fetch-site.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion packages/demo/src/app/api/oauth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ 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:app.certified.authWrite',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
Expand Down
3 changes: 2 additions & 1 deletion packages/demo/src/app/client-metadata.json/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 transition:generic',
scope:
'atproto include:org.hypercerts.authWrite include:app.certified.authWrite',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
...(isConfidential
Expand Down
Loading