Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
fe3ec90
feat(auth-service): preview routes for iterating on client branding CSS
aspiers Apr 14, 2026
8298d25
docs(env): add AUTH_PREVIEW_ROUTES to top-level .env.example
aspiers Apr 14, 2026
c93c891
docs: put preview-routes guide in the client tutorial, not operator c…
aspiers Apr 14, 2026
9cf50f7
feat(pds-core): preview route for iterating on consent-page CSS
aspiers Apr 14, 2026
294842d
fix(pds-core): drive preview consent SPA to the consent view
aspiers Apr 16, 2026
516db31
fix(preview): escape hydration <script> against XSS + cache-bypass
aspiers Apr 16, 2026
f1cab6e
feat(preview): persisted client_id input + live cache-status panel
aspiers Apr 17, 2026
f569411
fix(preview): reword /preview copy around the URL input
aspiers Apr 17, 2026
7b68e7b
feat(preview): validate metadata URL + richer cache-entry UI
aspiers Apr 17, 2026
0510e0c
feat(preview): cross-list all routes on both /preview pages
aspiers Apr 17, 2026
ec85696
fix(demo): drop "auth-service" from preview-link copy
aspiers Apr 17, 2026
22adea0
fix(preview): carry client_id across cross-origin preview links
aspiers Apr 17, 2026
2efff88
style(preview): widen /preview index pages 640px → 768px
aspiers Apr 17, 2026
5b7e387
style(preview): move query-string snippet onto its own line
aspiers Apr 17, 2026
87d3400
feat(preview): <code> formatting for field names and URLs in checks
aspiers Apr 17, 2026
5804997
feat(preview): add choose-handle preview for picker-only mode
aspiers Apr 17, 2026
836ab69
feat(preview): sync address bar with client_id input
aspiers Apr 17, 2026
b4d6b80
docs(preview): flag cache-status client_id leak and recommend off in …
aspiers Apr 17, 2026
2e5c538
feat(preview): link sibling-service heading to its /preview index
aspiers Apr 17, 2026
9983f47
feat(preview): validate tos_uri and policy_uri on client metadata
aspiers Apr 17, 2026
0ddbaa0
feat(preview): link 'the JSON body' to the fetched metadata URL
aspiers Apr 17, 2026
b603ed4
refactor(preview): share index-page shell across both services
aspiers Apr 17, 2026
685bc7f
refactor(preview): extract queryString / sendHtml / getBranding helpers
aspiers Apr 17, 2026
ca3d60f
test(preview): cover preview-ui; silence pino under NODE_ENV=test
aspiers Apr 17, 2026
c02b24c
fix(preview): address CodeRabbit feedback
aspiers Apr 19, 2026
7141fe4
fix(preview): carry client_id across the sibling-service heading link
aspiers Apr 19, 2026
10230b4
refactor(preview-validation): split the 250-line orchestrator into pe…
aspiers Apr 19, 2026
031f8be
refactor(preview-validation): wrap tos_uri / policy_uri in named chec…
aspiers Apr 19, 2026
bb1d557
chore: address SonarCloud minor findings + document pre-push checks
aspiers Apr 19, 2026
ede6929
refactor(pds-core): extract installPreviewRoutes from main()
aspiers Apr 19, 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
43 changes: 22 additions & 21 deletions .agents/skills/epds-login/references/client-metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,27 +98,28 @@ key generation and serving details.

## All supported fields

| Field | Required | Description |
| --------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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"` |
| `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 |
| `token_endpoint_auth_signing_alg` | Conditional | Required when `token_endpoint_auth_method` is `"private_key_jwt"`. Must be `"ES256"`. |
| `jwks_uri` | Conditional | Public JWKS URL. Required for `"private_key_jwt"` unless `jwks` is provided. Mutually exclusive with `jwks`. |
| `jwks` | Conditional | Inline JWKS object (`{"keys": [...]}`). Alternative to `jwks_uri`. Mutually exclusive with `jwks_uri`. |
| `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 |
| `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`. |
| Field | Required | Description |
| --------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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"` |
| `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 |
| `token_endpoint_auth_signing_alg` | Conditional | Required when `token_endpoint_auth_method` is `"private_key_jwt"`. Must be `"ES256"`. |
| `jwks_uri` | Conditional | Public JWKS URL. Required for `"private_key_jwt"` unless `jwks` is provided. Mutually exclusive with `jwks`. |
| `jwks` | Conditional | Inline JWKS object (`{"keys": [...]}`). Alternative to `jwks_uri`. Mutually exclusive with `jwks_uri`. |
| `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 |
| `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`. |
| `branding` | No | ePDS extension. Object containing a `css` string (max 32 KB). ePDS injects this CSS into login, OTP, choose-handle, recovery, and consent pages. Only honoured when the client is in `PDS_OAUTH_TRUSTED_CLIENTS`. Iterate via auth-service `/preview/*` routes. |

## Custom email templates

Expand Down
13 changes: 13 additions & 0 deletions .changeset/auth-preview-routes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'ePDS': minor
---

Add preview routes to auth-service for iterating on client branding CSS.

**Affects:** Client app developers, Operators

**Client app developers:** When the auth-service is started with `AUTH_PREVIEW_ROUTES=1`, a set of `/preview/*` URLs becomes available that render each auth-service page (login email step, login OTP step, choose-handle, recovery email step, recovery OTP step) with fixture data. Pass `?client_id=<URL-of-your-client-metadata.json>` to inject that client's `branding.css` into the page, exactly as it would be injected during a real OAuth flow — including the same trusted-clients gate, so your `client_id` still needs to be on the operator's `PDS_OAUTH_TRUSTED_CLIENTS` for CSS to be injected. Without a `client_id` query param the preview page renders with no branding, which lets you compare the un-themed baseline against your themed version. Iterating on your CSS becomes: edit `branding.css`, refresh the preview URL — no OTP emails, no walking through the full flow each time. Visit `/preview` on the auth-service for an index of the available pages.

**Operators:** `AUTH_PREVIEW_ROUTES=1` is safe on preview deployments (Railway PR previews, `pr-base`, dev) and on local development instances. The preview routes have no effect on real auth flows — they short-circuit real state — so they can technically run in production too, but they are a developer-only surface and are best left off outside of preview/dev envs. See `packages/auth-service/.env.example` for the full note.

The pds-core-hosted consent page (from `@atproto/oauth-provider-ui`) is out of scope for this change; a similar preview for that page needs to construct the SPA's hydration-data blob and will come in a follow-up.
16 changes: 16 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@ tag closure. The CSP `style-src` directive is updated with a SHA-256
hash of the injected CSS. Untrusted clients never get CSS injection
regardless of what their metadata contains.

#### Iterating on `branding.css` via auth-service preview routes

Set `AUTH_PREVIEW_ROUTES=1` on the auth-service to expose a set of
static `/preview/*` URLs that render each auth-service page with
fixture data (no real OAuth flow, no OTP emails). Pass
`?client_id=<URL-of-your-client-metadata.json>` to inject that
client's `branding.css`, subject to the same `PDS_OAUTH_TRUSTED_CLIENTS`
check as real flows. Visit `/preview` on the auth-service for an
index. Intended for preview envs and dev instances — the routes
short-circuit real auth state and have no effect on real flows, but
are a developer-only surface and shouldn't be left on in production.
See `packages/auth-service/.env.example` for details. The pds-core
consent page (from `@atproto/oauth-provider-ui`) is not yet covered
by a preview route — that needs a separate mechanism for the SPA's
hydration data.

Optional PDS email variables:

| Variable | Description |
Expand Down
9 changes: 9 additions & 0 deletions docs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,15 @@ You can customise the OTP email and login page colours:
}
```

For full control over the auth-service pages (login, OTP entry,
choose-handle, recovery) and the PDS consent page, trusted clients can
also supply a `branding.css` string in a `branding` object inside their
client metadata. See the
[CSS branding injection](./configuration.md#css-branding-injection)
section for the full reference, including how to iterate on your CSS
without walking through the full OAuth flow each time via the
auth-service's `/preview/*` routes.

The email template must be an HTML file containing at minimum a `{{code}}`
placeholder. Supported template variables:

Expand Down
13 changes: 13 additions & 0 deletions packages/auth-service/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,16 @@ DB_LOCATION=/data/epds.sqlite
# Values: random | picker | picker-with-random
# Defaults to 'picker-with-random' if not set.
# EPDS_DEFAULT_HANDLE_MODE=picker-with-random

# Expose /preview/* routes that render each auth-service page with fixture
# data, so client-app developers can iterate on their branding.css without
# walking through a real OAuth flow each time. Intended for preview envs
# and dev instances; safe but noisy on production (the routes short-circuit
# real auth state, so they have no effect on real flows, but they are a
# developer-only surface and will look out of place on a live PDS).
#
# The trusted-clients gate on CSS injection is preserved: a client_id passed
# via ?client_id=... query param only gets its branding.css injected when
# it's on PDS_OAUTH_TRUSTED_CLIENTS, exactly as in a real OAuth flow.
# Untrusted clients render the page with no branding.
# AUTH_PREVIEW_ROUTES=1
2 changes: 2 additions & 0 deletions packages/auth-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { createAccountLoginRouter } from './routes/account-login.js'
import { createAccountSettingsRouter } from './routes/account-settings.js'
import { createCompleteRouter } from './routes/complete.js'
import { createChooseHandleRouter } from './routes/choose-handle.js'
import { createPreviewRouter } from './routes/preview.js'
import { resolveAuthPort } from './lib/resolve-port.js'

const logger = createLogger('auth-service')
Expand Down Expand Up @@ -90,6 +91,7 @@ export function createAuthService(config: AuthServiceConfig): {
app.use(createAccountSettingsRouter(ctx, betterAuthInstance))
app.use(createCompleteRouter(ctx, betterAuthInstance))
app.use(createChooseHandleRouter(ctx, betterAuthInstance))
app.use(createPreviewRouter(ctx))

// Metrics endpoint (protect with admin auth in production)
app.get('/metrics', (req, res) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-service/src/routes/choose-handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ export function createChooseHandleRouter(
// Template
// ---------------------------------------------------------------------------

function renderChooseHandlePage(
export function renderChooseHandlePage(
handleDomain: string,
error?: string,
csrfToken?: string,
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-service/src/routes/login-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export function createLoginPageRouter(ctx: AuthServiceContext): Router {
return router
}

function renderLoginPage(opts: {
export function renderLoginPage(opts: {
flowId: string
clientId: string
clientName: string
Expand Down
Loading
Loading