Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
113 commits
Select commit Hold shift + click to select a range
0af6d7f
fix(auth): clarify Resend webhook log messages
aspiers Jul 20, 2026
1e8257e
Merge pull request #209 from hypercerts-org/fix/resend-webhook-log-me…
aspiers Jul 20, 2026
f2067a4
fix(auth): format Resend webhook event messages
aspiers Jul 20, 2026
7bce175
Merge pull request #211 from hypercerts-org/fix/resend-webhook-log-me…
aspiers Jul 20, 2026
bbe63d9
fix(auth-service): make sign-in errors announce to screen readers
aspiers May 7, 2026
a675b23
fix(demo): error banner announces to screen readers via role=alert
aspiers May 7, 2026
133d135
fix(shared): renderError adds role=alert on the error <p>
aspiers May 7, 2026
424bda0
fix(auth-service): visible keyboard focus on Verify and Resend buttons
aspiers May 7, 2026
446c5ae
fix(auth-service): visible keyboard focus on remaining route buttons
aspiers May 7, 2026
23dcd0f
chore(changeset): keyboard focus-visible on buttons
aspiers May 7, 2026
610b5e6
chore(changeset): document accessible error alerts
aspiers Jul 30, 2026
9e12e30
fix(auth): use accurate autocomplete hints
aspiers Jul 30, 2026
1a90062
fix(auth): clear email when returning from OTP entry
aspiers Jul 30, 2026
68b0760
fix(auth): use truthful unavailable-handle copy
aspiers Jul 30, 2026
6f4fa2d
fix(shared): malformed client_id no longer leaks into displayed app name
aspiers May 7, 2026
124f8fb
Merge pull request #225 from hypercerts-org/split-pr165/keyboard-focu…
aspiers Jul 30, 2026
a1ff5e9
Merge pull request #222 from hypercerts-org/split-pr165/email-autocom…
aspiers Jul 30, 2026
e3cd4e9
Merge pull request #215 from hypercerts-org/split-pr165/clear-email-o…
aspiers Jul 30, 2026
797bcd1
fix(auth): rename unavailable handle status class
aspiers Jul 31, 2026
a500a59
fix(auth-service): make the sign-in flash region actually announce
aspiers Jul 31, 2026
f84486a
fix(auth-service): announce handle-choice errors and availability
aspiers Jul 31, 2026
bd1d0d2
docs(changeset): cover handle choice and repeated failures
aspiers Jul 31, 2026
a6cf09d
Merge pull request #224 from hypercerts-org/split-pr165/accessible-er…
aspiers Jul 31, 2026
fe3b15c
feat(auth): suggest corrections for mistyped email domains
aspiers Jul 31, 2026
47b05d4
Merge pull request #227 from hypercerts-org/split-pr165/handle-unavai…
aspiers Jul 31, 2026
a42d20a
Merge pull request #231 from hypercerts-org/adam/hyper-545-email-typo…
aspiers Aug 3, 2026
87ef832
fix(auth): darken muted sign-in text to meet WCAG AA
aspiers Aug 3, 2026
6ed3d05
fix(auth): route the divider text through --muted-foreground
aspiers Aug 3, 2026
f4ff99f
docs(screenshots): capture muted-text contrast before/after
aspiers Aug 3, 2026
9b6b1f6
docs(changeset): address branding.css to client app developers
aspiers Aug 4, 2026
d2736c3
Merge pull request #232 from hypercerts-org/fix/muted-foreground-cont…
aspiers Aug 4, 2026
68b2104
fix(auth): clear OTP inputs after resend
aspiers Jul 30, 2026
a608f77
test(e2e): assert old OTP digits land before resend
aspiers Jul 30, 2026
b7329a2
feat(auth): warn on resend that earlier codes are dead
aspiers Aug 3, 2026
657acbd
test(auth): pin the resend message's two warnings
aspiers Aug 3, 2026
d897e61
docs(skill): require capitalizing the word after a changeset audience…
aspiers Aug 4, 2026
50c8620
fix(auth): show sign-in errors at the point of failure
aspiers Aug 4, 2026
e9977ab
fix(auth): confirm the resend, and hide the flash by class
aspiers Aug 4, 2026
0e67e6b
fix(auth): offer resend on any rejected code, not just lockout
aspiers Aug 4, 2026
ce3caef
docs: stop committing PR screenshots to the repo
aspiers Aug 4, 2026
ce5de14
Merge pull request #220 from hypercerts-org/split-pr165/clear-code-on…
aspiers Aug 4, 2026
dcefb95
Merge pull request #229 from hypercerts-org/split-pr165/safe-client-n…
aspiers Aug 4, 2026
e238c38
fix(auth): relabel "Resend code" to "Send a new code"
aspiers Aug 4, 2026
2419b84
fix(auth): filter unsupported OTP characters
aspiers Jul 30, 2026
0637fa7
fix(auth): apply the OTP charset filter to the sign-in grid too
aspiers Aug 4, 2026
566c2f3
docs(changeset): align pending changesets with the new label
aspiers Aug 4, 2026
d07b112
docs: update the resend handler's section comment
aspiers Aug 4, 2026
b308698
test(e2e): cover the OTP charset filter on the sign-in grid
aspiers Aug 4, 2026
02c2d5e
Merge pull request #236 from hypercerts-org/fix/send-new-code-label
aspiers Aug 4, 2026
284278f
Merge pull request #223 from hypercerts-org/split-pr165/server-otp-ch…
aspiers Aug 4, 2026
43baeb5
fix(auth): unify link affordances across the sign-in screens
aspiers Aug 4, 2026
ddde1b2
fix(auth): write sign-in errors for users, not developers
aspiers Aug 4, 2026
bfd481d
test(e2e): assert the sign-in error copy users actually see
aspiers Aug 4, 2026
d535f3a
Merge pull request #237 from hypercerts-org/fix/link-affordances
aspiers Aug 4, 2026
0254619
docs(skill): name real audience labels, and satisfy prettier
aspiers Aug 4, 2026
03a13da
Merge pull request #235 from hypercerts-org/docs/changeset-capitaliza…
aspiers Aug 4, 2026
d4256ea
security: require secret env vars at startup, remove dev fallbacks
aspiers Mar 26, 2026
b3a5a3d
fix(shared): reject whitespace-only required env vars
aspiers Aug 5, 2026
406421e
style(docs): fix prettier table alignment in configuration.md
aspiers Aug 5, 2026
9781ef3
Merge pull request #41 from hypercerts-org/remove-dev-secret-fallbacks
aspiers Aug 5, 2026
c4fbbb0
fix(pds-core): redact request_uri from CSS middleware warn log
aspiers Apr 14, 2026
2b10346
Merge pull request #80 from hypercerts-org/fix/redact-request-uri-in-…
aspiers Aug 6, 2026
1e152b5
refactor(pds-core): extract OAuth client-id resolution from CSS middl…
aspiers Aug 5, 2026
707355b
fix(pds-core): log request_uri presence, not its value
aspiers Aug 5, 2026
a0a819a
fix(auth): gate email sign-in until its handlers are installed
aspiers Aug 5, 2026
d1b71a8
Merge pull request #242 from hypercerts-org/split148/login-page-readi…
aspiers Aug 6, 2026
36eab67
Merge pull request #241 from hypercerts-org/split148/oauth-request-co…
aspiers Aug 6, 2026
b3c66f6
build(pds-core): move to Node 22 + pure ESM
aspiers Aug 3, 2026
78f6f49
build(pds-core): bump @atproto deps to latest upstream
aspiers Aug 3, 2026
16098dc
refactor(pds-core): repoint oauth-provider imports to 0.21 subpaths
aspiers Aug 3, 2026
b0aa58c
fix(pds-core): adopt strongly-typed Account did/handle
aspiers Aug 3, 2026
9a064fd
fix(pds-core): match chooser enrichment to oauth-provider-ui 0.8 markup
aspiers Aug 3, 2026
ebd214e
docs(changeset): atproto upstream upgrade + Node 22 requirement
aspiers Aug 3, 2026
d785908
fix(pds-core): replace __dirname with import.meta.url under ESM
aspiers Aug 3, 2026
c7faa35
test(pds-core): extract + cover internal handle-guard decisions
aspiers Aug 3, 2026
d386399
test(pds-core): cover constructed-handle branding guard
aspiers Aug 3, 2026
12147f1
fix(pds-core): tighten Node floor to 22.19.0 and align tsconfig module
aspiers Aug 3, 2026
4338d76
refactor(pds-core): group imports before module init
aspiers Aug 3, 2026
d70111a
fix(pds-core): realistic PLC DID in guard tests; correct changeset em…
aspiers Aug 4, 2026
b76a56e
docs(agents): record the Coveralls thresholds and why they can't be i…
aspiers Aug 11, 2026
11c23da
Merge pull request #245 from hypercerts-org/docs/coveralls-thresholds
aspiers Aug 11, 2026
534647d
feat(auth): sign epds_handle_mode through the callback hop
aspiers Aug 5, 2026
7ade8ca
Merge pull request #243 from hypercerts-org/split148/handle-mode-plum…
aspiers Aug 11, 2026
bf775ee
Merge pull request #233 from hypercerts-org/feat/atproto-upstream-upg…
aspiers Aug 11, 2026
3924855
fix(pds-core): record emailConfirmedAt after OTP-verified sign-up
aspiers Aug 4, 2026
55e2bfd
test(pds-core): cover backfill arg parsing and report wording
aspiers Aug 4, 2026
d63111c
fix(pds-core): report rows the backfill UPDATE actually touched
aspiers Aug 4, 2026
f3a1b0e
refactor(pds-core): confirm email via accountManager, not table writes
aspiers Aug 4, 2026
e22559f
feat: carry proof-of-email-control across the auth handover
aspiers Aug 4, 2026
bd97808
feat(pds-core): filter the backfill by email substring; log failures …
aspiers Aug 4, 2026
ddea22e
fix(pds-core): run backfill from dist; diagnose missing email_verified
aspiers Aug 4, 2026
b97d2ff
docs: move backfill procedure out of the changeset
aspiers Aug 4, 2026
0a97345
docs(changeset): capitalise the sentence after each audience label
aspiers Aug 4, 2026
86068b5
fix(pds-core): port email confirmation to the upgraded upstream API
aspiers Aug 11, 2026
9d0d0fb
fix(pds-core): log backfill failures structurally; document the DB ex…
aspiers Aug 12, 2026
31777ad
docs: correct the rollout and self-healing claims around email_verified
aspiers Aug 12, 2026
f140e9e
docs: catalogue the backfill's direct account-table read as white-boxing
aspiers Aug 12, 2026
eac9e21
Merge pull request #234 from hypercerts-org/adam/hyper-219-set-emailc…
aspiers Aug 12, 2026
fa83e66
chore: release
github-actions[bot] Aug 14, 2026
74fb73d
Merge pull request #247 from hypercerts-org/changeset-release/main
aspiers Aug 14, 2026
b5a3f34
docs: simplify ePDS OAuth login skill
Kzoeps Sep 3, 2026
8425320
epds-login: correct OAuth integration guidance
Kzoeps Sep 4, 2026
afe6be7
pds: upgrade upstream PDS and OAuth packages
Kzoeps Sep 14, 2026
593b02b
docs: clarify email template URL requirements
Kzoeps Sep 16, 2026
751c46e
Merge pull request #251 from hypercerts-org/pds/upgrade-0-5-34
Kzoeps Sep 16, 2026
6f41f2d
pds-ui: adapt branding to provider UI 0.10
Kzoeps Sep 15, 2026
f57ac3c
pds-core: align chooser hydration with provider UI 0.10.3
Kzoeps Sep 15, 2026
9f4e78b
docs: remove completed temporary branding checks
Kzoeps Sep 16, 2026
8314b89
Merge pull request #254 from hypercerts-org/pds/provider-ui-branding-fix
Kzoeps Sep 16, 2026
478c558
Merge pull request #249 from hypercerts-org/docs/simplify-epds-login-…
Kzoeps Sep 16, 2026
c2984b1
chore: release
github-actions[bot] Sep 24, 2026
ce95bbf
Merge pull request #256 from hypercerts-org/changeset-release/main
Kzoeps Sep 24, 2026
6f70176
Merge pull request #257 from hypercerts-org/main
Kzoeps Sep 24, 2026
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
351 changes: 209 additions & 142 deletions .agents/skills/epds-login/SKILL.md

Large diffs are not rendered by default.

37 changes: 22 additions & 15 deletions .agents/skills/epds-login/references/client-metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,9 @@ key generation and serving details.
| `dpop_bound_access_tokens` | Yes | Always `true` |
| `client_uri` | No | Your app's homepage URL |
| `logo_uri` | No | URL to your app logo (shown on login page) |
| `email_template_uri` | No | URL to a custom OTP email HTML template |
| `email_subject_template` | No | Custom email subject line with `{{code}}` placeholder |
| `email_template_uri` | No | URL to a custom OTP email HTML template. Trusted clients only. Must use HTTPS; HTTP is permitted only when `EPDS_ALLOW_PRIVATE_IPS=true` enables `allowHttp` for local development. Keep that setting disabled for internet-facing deployments. |
| `email_subject_template` | No | Custom email subject line with `{{code}}` placeholder. Trusted clients only. |
| `brand_color` | No | Hex colour for buttons and input focus rings (default: `#1A130F`) |
| `background_color` | No | Hex colour for the login page background (default: `#F2EBE4`) |
| `epds_handle_mode` | No | ePDS extension. Handle picker variant for new users: `"picker"`, `"random"`, or `"picker-with-random"` (default). See [tutorial](../../../../docs/tutorial.md#optional-control-the-handle-picker). |
| `epds_skip_consent_on_signup` | No | ePDS extension. When `true`, skip the consent screen on initial sign-up. Only honoured when the PDS has `PDS_SIGNUP_ALLOW_CONSENT_SKIP=true` AND the client is in `PDS_OAUTH_TRUSTED_CLIENTS`. |
| `epds_handle_login_url` | No | ePDS extension. Absolute http(s) URL on your client's origin. When set, the auth-service login page renders an "Or sign in with ATProto/Bluesky" button; submitting a handle redirects the browser to this URL with `?handle=<value>` appended. See [tutorial](../../../../docs/tutorial.md#optional-offer-atprotobluesky-handle-sign-in). |
Expand All @@ -134,18 +133,20 @@ for the route list and example URLs.

## Custom email templates

If you provide `email_template_uri`, the auth service fetches that URL and
uses it as the OTP email body instead of the default Certified template.
Custom email HTML and subject fields are honored only when the client is in
`PDS_OAUTH_TRUSTED_CLIENTS`. When trusted metadata provides
`email_template_uri`, auth-service fetches that HTML and uses it for the HTML
part of the OTP email. The server retains control of the plain-text part.

Your template must be an HTML file. Supported placeholders:

| Placeholder | Description |
| ------------------------------------- | ----------------------------------------- |
| `{{code}}` | The 8-digit OTP code — **required** |
| `{{app_name}}` | Value of `client_name` from your metadata |
| `{{logo_uri}}` | Value of `logo_uri` from your metadata |
| `{{#is_new_user}}...{{/is_new_user}}` | Block shown only on first sign-up |
| `{{^is_new_user}}...{{/is_new_user}}` | Block shown only on subsequent sign-ins |
| Placeholder | Description |
| ------------------------------------- | ------------------------------------------------- |
| `{{code}}` | Configured 4–12-character OTP code — **required** |
| `{{app_name}}` | Value of `client_name` from your metadata |
| `{{logo_uri}}` | Value of `logo_uri` from your metadata |
| `{{#is_new_user}}...{{/is_new_user}}` | Block shown only on first sign-up |
| `{{^is_new_user}}...{{/is_new_user}}` | Block shown only on subsequent sign-ins |

Minimal template example:

Expand Down Expand Up @@ -281,6 +282,12 @@ allow-list.

## Local development

During local development you can use `http://localhost` client IDs. The
`client_id` must still be a reachable URL — ePDS fetches it at runtime.
Use a local server or `ngrok` to expose your metadata endpoint.
A remote ePDS must be able to fetch a discoverable client metadata URL and any
remote `jwks_uri`. It cannot reach a server bound only to your localhost. Every
ordinary web client, including a public client, must use an HTTPS tunnel or
deployed HTTPS development URL.

AT Protocol loopback clients are the only exception. They use a separate
metadata encoding and public-client rules. Do not assume an ordinary
`http://localhost/.../client-metadata.json` URL will be fetched successfully by
a remote ePDS.
187 changes: 8 additions & 179 deletions .agents/skills/epds-login/references/dpop-pkce.md
Original file line number Diff line number Diff line change
@@ -1,185 +1,14 @@
# PKCE and DPoP Helper Implementations
# PKCE and DPoP

> **Flow 2 does not need these helpers.** If your app uses
> `@atproto/oauth-client-node` (recommended for any flow that does not pass
> a raw email as `login_hint`), the library handles PKCE, DPoP, and nonce
> retry internally. These helpers are only needed for **Flow 1** (hand-rolled
> PAR/DPoP with email `login_hint`).

Copy these into your project. They have no dependencies beyond Node's built-in
`node:crypto` module.
`NodeOAuthClient` handles PKCE, DPoP, and the associated OAuth state, including
for email-first login:

```typescript
import * as crypto from 'node:crypto'

// ---------------------------------------------------------------------------
// PKCE helpers
// ---------------------------------------------------------------------------

/** Generate a random code verifier for PKCE. Store this in your session. */
export function generateCodeVerifier(): string {
return crypto.randomBytes(32).toString('base64url')
}

/** Derive the code challenge to send to the auth server. */
export function generateCodeChallenge(verifier: string): string {
return crypto.createHash('sha256').update(verifier).digest('base64url')
}

/** Generate a random state value. Store this in your session. */
export function generateState(): string {
return crypto.randomBytes(16).toString('base64url')
}

// ---------------------------------------------------------------------------
// DPoP helpers
// ---------------------------------------------------------------------------

/**
* Generate a fresh DPoP key pair.
*
* Call this once per login attempt. Store `privateJwk` in your session cookie
* so the callback handler can restore the key pair for token exchange.
* Never reuse a key pair across different login flows.
*/
export function generateDpopKeyPair() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
namedCurve: 'P-256',
})
return {
privateKey,
publicJwk: publicKey.export({ format: 'jwk' }),
privateJwk: privateKey.export({ format: 'jwk' }),
}
}

/**
* Restore a DPoP key pair from a serialized private JWK.
*
* Use this in the callback handler to recover the key pair from the session
* cookie that was set during the login handler.
*/
export function restoreDpopKeyPair(privateJwk: crypto.JsonWebKey) {
const privateKey = crypto.createPrivateKey({ key: privateJwk, format: 'jwk' })
const publicKey = crypto.createPublicKey(privateKey)
return {
privateKey,
publicJwk: publicKey.export({ format: 'jwk' }),
}
}

/**
* Create a DPoP proof JWT for a single HTTP request.
*
* Create a new proof for every request — they are single-use by design.
*
* @param opts.nonce - Include when the server returned a `dpop-nonce` header.
* @param opts.accessToken - Include when making API calls with an access token.
*/
export function createDpopProof(opts: {
privateKey: crypto.KeyObject
jwk: object
method: string
url: string
nonce?: string
accessToken?: string
}): string {
const header = { alg: 'ES256', typ: 'dpop+jwt', jwk: opts.jwk }

const payload: Record<string, unknown> = {
jti: crypto.randomUUID(),
htm: opts.method,
htu: opts.url,
iat: Math.floor(Date.now() / 1000),
}
if (opts.nonce) payload.nonce = opts.nonce
if (opts.accessToken) {
payload.ath = crypto
.createHash('sha256')
.update(opts.accessToken)
.digest('base64url')
}

const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url')
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url')
const signingInput = `${headerB64}.${payloadB64}`
const sig = crypto.sign('sha256', Buffer.from(signingInput), opts.privateKey)
return `${signingInput}.${derToRaw(sig).toString('base64url')}`
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/**
* Convert a DER-encoded ECDSA signature to raw r||s format.
* Required because Node's crypto.sign() outputs DER, but JWTs expect raw.
*/
function derToRaw(der: Buffer): Buffer {
// DER: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s]
let offset = 2
if (der[1]! > 0x80) offset += der[1]! - 0x80 // long-form length

offset++ // skip 0x02 tag
const rLen = der[offset++]!
let r = der.subarray(offset, offset + rLen)
offset += rLen

offset++ // skip 0x02 tag
const sLen = der[offset++]!
let s = der.subarray(offset, offset + sLen)

// DER may pad with a leading zero byte to indicate positive — strip it
if (r.length > 32) r = r.subarray(r.length - 32)
if (s.length > 32) s = s.subarray(s.length - 32)

const raw = Buffer.alloc(64)
r.copy(raw, 32 - r.length)
s.copy(raw, 64 - s.length)
return raw
}
const url = await client.authorize(epdsUrl)
url.searchParams.set('login_hint', email)
```

## Nonce retry pattern (Flow 1 only)

ePDS always rejects the first DPoP proof with a `400` and a `dpop-nonce`
header. This is standard behaviour. For Flow 1 (hand-rolled), wrap every
PAR and token request in this retry loop. Flow 2 does not need this —
`NodeOAuthClient` handles nonce retry internally.
The SDK handles PKCE, DPoP key generation and proof signing, nonce retry, PAR,
token exchange, client assertions, and persisted OAuth state.

```typescript
async function fetchWithDpopRetry(
url: string,
body: URLSearchParams,
privateKey: crypto.KeyObject,
publicJwk: object,
): Promise<Response> {
const makeProof = (nonce?: string) =>
createDpopProof({ privateKey, jwk: publicJwk, method: 'POST', url, nonce })

let res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
DPoP: makeProof(),
},
body: body.toString(),
})

if (!res.ok) {
const nonce = res.headers.get('dpop-nonce')
if (nonce && res.status === 400) {
res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
DPoP: makeProof(nonce),
},
body: body.toString(),
})
}
}

return res
}
```
See [`../SKILL.md`](../SKILL.md) for maintained integration guidance.
Loading
Loading