From a00cb2529f64fc97429e99b4e6f7bb35d0a47682 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 18 May 2026 12:58:27 +0000 Subject: [PATCH 1/5] feat(shared): preview validation flags missing epds_handle_login_url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `handle-login-url` row to `/preview/validate` so client devs get a warning when their metadata is missing the hand-off URL that gates the "Or sign in with ATProto/Bluesky" button — and an error when the URL is set but isn't a parseable http(s) URL (which would silently disable the button on real flows). Mirrors the runtime isSafeHttpUrl gate in auth-service's login-page, so http:// localhost dev clients still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../preview-validate-handle-login-url.md | 15 +++++ .../src/__tests__/preview-validation.test.ts | 48 ++++++++++++++++ packages/shared/src/preview-validation.ts | 57 +++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 .changeset/preview-validate-handle-login-url.md diff --git a/.changeset/preview-validate-handle-login-url.md b/.changeset/preview-validate-handle-login-url.md new file mode 100644 index 00000000..73bd3a80 --- /dev/null +++ b/.changeset/preview-validate-handle-login-url.md @@ -0,0 +1,15 @@ +--- +'ePDS': patch +--- + +The `/preview/validate` page now checks the ATProto/Bluesky hand-off URL on your client metadata. + +**Affects:** Client app developers + +**Client app developers:** if your client metadata declares `epds_handle_login_url` (used to render the "Or sign in with ATProto/Bluesky" button on the login page), the preview validation page now surfaces a `handle-login-url` row alongside the existing field checks. + +- Missing or empty → warn ("Optional. Without it, the login page doesn't render the ATProto/Bluesky button…") so you notice if you forgot to declare it. +- Present and `http(s)://` → ok. Both schemes are accepted because the real `isSafeHttpUrl` gate in the login page also accepts `http://` for localhost dev clients. +- Present but `javascript:`, `file:`, or otherwise unparseable → error. This mirrors how the login page silently refuses to render the button on real flows, so the validator now points it out instead of letting you discover it the hard way during an OAuth round-trip. + +No new metadata fields, env vars, or response shapes — only an additional row in the existing `/preview/validate` JSON output. diff --git a/packages/shared/src/__tests__/preview-validation.test.ts b/packages/shared/src/__tests__/preview-validation.test.ts index 729b6852..d2857f54 100644 --- a/packages/shared/src/__tests__/preview-validation.test.ts +++ b/packages/shared/src/__tests__/preview-validation.test.ts @@ -68,6 +68,7 @@ describe('validateClientMetadataForPreview', () => { branding: { css: 'body { color: red; }' }, tos_uri: 'https://good.example/terms', policy_uri: 'https://good.example/privacy', + epds_handle_login_url: 'https://good.example/api/oauth/login', }) const result = await validateClientMetadataForPreview(url, [url]) expect(result.fetched).toBe(true) @@ -80,6 +81,7 @@ describe('validateClientMetadataForPreview', () => { expect(byId['branding-css'].severity).toBe('ok') expect(byId['tos-uri'].severity).toBe('ok') expect(byId['policy-uri'].severity).toBe('ok') + expect(byId['handle-login-url'].severity).toBe('ok') expect(byId['trusted-client'].severity).toBe('ok') }) @@ -123,6 +125,7 @@ describe('validateClientMetadataForPreview', () => { expect(byId['branding-css'].severity).toBe('warn') expect(byId['tos-uri'].severity).toBe('warn') expect(byId['policy-uri'].severity).toBe('warn') + expect(byId['handle-login-url'].severity).toBe('warn') // trust check also warn, not error expect(byId['trusted-client'].severity).toBe('warn') // No error-level checks on an otherwise-valid metadata: @@ -143,6 +146,51 @@ describe('validateClientMetadataForPreview', () => { expect(byId['policy-uri'].severity).toBe('error') }) + it('flags epds_handle_login_url ok for http:// (dev) and https:// values', async () => { + // Mirrors the real isSafeHttpUrl gate in auth-service's login-page: + // both schemes pass so that localhost dev clients keep working. + for (const handleUrl of [ + 'http://localhost:3000/api/oauth/login', + 'https://client.example/api/oauth/login', + ]) { + const url = `https://${handleUrl.includes('localhost') ? 'dev' : 'prod'}.example/client-metadata.json` + mockFetchOnce({ + client_id: url, + redirect_uris: [ + `https://${handleUrl.includes('localhost') ? 'dev' : 'prod'}.example/cb`, + ], + epds_handle_login_url: handleUrl, + }) + const result = await validateClientMetadataForPreview(url, null) + const check = result.checks.find((c) => c.id === 'handle-login-url') + expect(check?.severity).toBe('ok') + } + }) + + it('errors when epds_handle_login_url is not http(s)', async () => { + const url = 'https://bad-handle.example/client-metadata.json' + mockFetchOnce({ + client_id: url, + redirect_uris: ['https://bad-handle.example/cb'], + epds_handle_login_url: 'javascript:alert(1)', + }) + const result = await validateClientMetadataForPreview(url, null) + const check = result.checks.find((c) => c.id === 'handle-login-url') + expect(check?.severity).toBe('error') + }) + + it('errors when epds_handle_login_url is unparseable', async () => { + const url = 'https://bad-handle2.example/client-metadata.json' + mockFetchOnce({ + client_id: url, + redirect_uris: ['https://bad-handle2.example/cb'], + epds_handle_login_url: 'not a url', + }) + const result = await validateClientMetadataForPreview(url, null) + const check = result.checks.find((c) => c.id === 'handle-login-url') + expect(check?.severity).toBe('error') + }) + it('errors when client_id field does not match the URL', async () => { const url = 'https://a.example/client-metadata.json' mockFetchOnce({ diff --git a/packages/shared/src/preview-validation.ts b/packages/shared/src/preview-validation.ts index 636e2181..7368bd34 100644 --- a/packages/shared/src/preview-validation.ts +++ b/packages/shared/src/preview-validation.ts @@ -393,6 +393,62 @@ function checkTosUri(metadata: ClientMetadata): PreviewCheck { }) } +/** + * `epds_handle_login_url` is the hand-off URL for the + * "Or sign in with ATProto/Bluesky" button on the login page. The + * real gate in auth-service is `isSafeHttpUrl` — accepts http and + * https so that localhost dev clients keep working — so this check + * mirrors that, rather than the stricter https-only check used for + * tos_uri / policy_uri (which render as consent-page links). + * + * Missing → warn (optional; users just see no ATProto/Bluesky + * button). Present + http(s) → ok. Present + any other scheme or + * unparseable → error (button silently won't render on real flows). + */ +function checkHandleLoginUrl(metadata: ClientMetadata): PreviewCheck { + const value = metadata.epds_handle_login_url + const label = 'epds_handle_login_url set' + const labelHtml = `${code('epds_handle_login_url')} set` + + if (value === undefined || value === '') { + return { + id: 'handle-login-url', + label, + severity: 'warn', + detail: + 'Optional. Without it, the login page doesn\'t render the "Or sign in with ATProto/Bluesky" button, so users coming from a different PDS can\'t hand off to your client.', + labelHtml, + detailHtml: `Optional. Without it, the login page doesn't render the "Or sign in with ATProto/Bluesky" button, so users coming from a different PDS can't hand off to your client.`, + } + } + + let parsed: URL | null = null + try { + parsed = new URL(value) + } catch { + // fall through + } + if (parsed?.protocol !== 'https:' && parsed?.protocol !== 'http:') { + return { + id: 'handle-login-url', + label, + severity: 'error', + detail: `epds_handle_login_url="${value}" is not a valid http(s) URL. The login page rejects it via isSafeHttpUrl and the ATProto/Bluesky button silently won't render on real flows.`, + labelHtml, + detailHtml: `${code('epds_handle_login_url')}=${code('"' + value + '"')} is not a valid http(s) URL. The login page rejects it via ${code('isSafeHttpUrl')} and the ATProto/Bluesky button silently won't render on real flows.`, + } + } + + return { + id: 'handle-login-url', + label, + severity: 'ok', + detail: `epds_handle_login_url="${value}". Login page will render the "Or sign in with ATProto/Bluesky" button and hand off to this URL with ?handle=.`, + labelHtml, + detailHtml: `${code('epds_handle_login_url')}=${code('"' + value + '"')}. Login page will render the "Or sign in with ATProto/Bluesky" button and hand off to this URL with ${code('?handle=')}.`, + } +} + function checkPolicyUri(metadata: ClientMetadata): PreviewCheck { return checkUriField({ id: 'policy-uri', @@ -476,6 +532,7 @@ export async function validateClientMetadataForPreview( checkBrandingCss(metadata), checkTosUri(metadata), checkPolicyUri(metadata), + checkHandleLoginUrl(metadata), ) // 4. Trusted-clients membership (optional; caller may skip) From 7c807af0f40291f7e640fdd08162f87616c0dd52 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 18 May 2026 13:44:07 +0000 Subject: [PATCH 2/5] docs(shared): align epds_handle_login_url docstring with http(s) gate ClientMetadata previously stated the field must be `https://`, but both the runtime `isSafeHttpUrl` gate in auth-service's login page and the new preview validation accept `http://` for localhost / dev clients. Bring the docstring in line with the actual behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/shared/src/client-metadata.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/client-metadata.ts b/packages/shared/src/client-metadata.ts index 1c3d0ded..ade8c5d4 100644 --- a/packages/shared/src/client-metadata.ts +++ b/packages/shared/src/client-metadata.ts @@ -77,8 +77,11 @@ export interface ClientMetadata { * OAuth flow against that PDS. Off-PDS handles cannot be authenticated * by this PDS, so this is the only path that works for them. * - * Must be an absolute https:// URL on the client's own origin. - * If absent, the button is not rendered. + * Must be an absolute http(s):// URL on the client's own origin. + * `https://` is required in production; `http://` is accepted to + * support localhost / dev clients (this mirrors the runtime + * `isSafeHttpUrl` gate in auth-service's login page). If absent or + * not parseable as http(s), the button is not rendered. */ epds_handle_login_url?: string } From ad4e5544ee1429b96b71eb465f216f341fb0b2be Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 18 May 2026 13:45:36 +0000 Subject: [PATCH 3/5] docs(changeset): trim preview-validate-handle-login-url to essentials Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/preview-validate-handle-login-url.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.changeset/preview-validate-handle-login-url.md b/.changeset/preview-validate-handle-login-url.md index 73bd3a80..adf96261 100644 --- a/.changeset/preview-validate-handle-login-url.md +++ b/.changeset/preview-validate-handle-login-url.md @@ -2,14 +2,12 @@ 'ePDS': patch --- -The `/preview/validate` page now checks the ATProto/Bluesky hand-off URL on your client metadata. +The `/preview/validate` page now checks `epds_handle_login_url` on your client metadata. **Affects:** Client app developers -**Client app developers:** if your client metadata declares `epds_handle_login_url` (used to render the "Or sign in with ATProto/Bluesky" button on the login page), the preview validation page now surfaces a `handle-login-url` row alongside the existing field checks. +**Client app developers:** a new `handle-login-url` row joins the existing field checks. -- Missing or empty → warn ("Optional. Without it, the login page doesn't render the ATProto/Bluesky button…") so you notice if you forgot to declare it. -- Present and `http(s)://` → ok. Both schemes are accepted because the real `isSafeHttpUrl` gate in the login page also accepts `http://` for localhost dev clients. -- Present but `javascript:`, `file:`, or otherwise unparseable → error. This mirrors how the login page silently refuses to render the button on real flows, so the validator now points it out instead of letting you discover it the hard way during an OAuth round-trip. - -No new metadata fields, env vars, or response shapes — only an additional row in the existing `/preview/validate` JSON output. +- Missing or empty value warns you that the "Or sign in with ATProto/Bluesky" button won't render. +- An `http(s)://` value is ok, matching the `isSafeHttpUrl` gate that renders the button at runtime (`http://` accepted so localhost dev clients still pass). +- Any other value (`javascript:`, `file:`, unparseable) errors, because the runtime gate would silently drop the button on real flows. From 0c8a3e0368f9eb1a724f13dd4d24886c608054d1 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 19 May 2026 14:06:18 +0000 Subject: [PATCH 4/5] docs(shared): align handle-login-url wording with runtime behavior Clarify two minor docs/behavior mismatches surfaced in review: - ClientMetadata.epds_handle_login_url JSDoc claimed the URL "Must be ... on the client's own origin", but neither the login page's isSafeHttpUrl gate nor the /preview/validate check enforces same-origin. Relax to "should be on the client's own origin" with an explicit note that origin is not validated at runtime. - Both the JSDoc and the preview-validation success message described the handoff as "?handle= appended". The login page uses URLSearchParams.set, so the separator is "?" only when the URL has no existing query string, else "&". Rephrase to "with a handle= query param" to match actual behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/shared/src/client-metadata.ts | 22 +++++++++++++--------- packages/shared/src/preview-validation.ts | 4 ++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/client-metadata.ts b/packages/shared/src/client-metadata.ts index ade8c5d4..1d4aaeae 100644 --- a/packages/shared/src/client-metadata.ts +++ b/packages/shared/src/client-metadata.ts @@ -72,16 +72,20 @@ export interface ClientMetadata { /** * ePDS extension — when set, the auth-service login page renders an * "Or sign in with ATProto/Bluesky" button. Submitting a handle - * navigates the browser to this URL with `?handle=` appended, - * letting the client resolve the handle to a PDS and start a fresh - * OAuth flow against that PDS. Off-PDS handles cannot be authenticated - * by this PDS, so this is the only path that works for them. + * navigates the browser to this URL with a `handle=` query + * param added (via `URLSearchParams.set`, so any existing query + * string is preserved), letting the client resolve the handle to a + * PDS and start a fresh OAuth flow against that PDS. Off-PDS handles + * cannot be authenticated by this PDS, so this is the only path that + * works for them. * - * Must be an absolute http(s):// URL on the client's own origin. - * `https://` is required in production; `http://` is accepted to - * support localhost / dev clients (this mirrors the runtime - * `isSafeHttpUrl` gate in auth-service's login page). If absent or - * not parseable as http(s), the button is not rendered. + * Must be an absolute http(s):// URL; should be on the client's own + * origin (not enforced at runtime — neither the login page's + * `isSafeHttpUrl` gate nor the `/preview/validate` check verifies + * origin). `https://` is required in production; `http://` is + * accepted to support localhost / dev clients (this mirrors the + * runtime `isSafeHttpUrl` gate in auth-service's login page). If + * absent or not parseable as http(s), the button is not rendered. */ epds_handle_login_url?: string } diff --git a/packages/shared/src/preview-validation.ts b/packages/shared/src/preview-validation.ts index 7368bd34..e3b56f5f 100644 --- a/packages/shared/src/preview-validation.ts +++ b/packages/shared/src/preview-validation.ts @@ -443,9 +443,9 @@ function checkHandleLoginUrl(metadata: ClientMetadata): PreviewCheck { id: 'handle-login-url', label, severity: 'ok', - detail: `epds_handle_login_url="${value}". Login page will render the "Or sign in with ATProto/Bluesky" button and hand off to this URL with ?handle=.`, + detail: `epds_handle_login_url="${value}". Login page will render the "Or sign in with ATProto/Bluesky" button and hand off to this URL with a handle= query param.`, labelHtml, - detailHtml: `${code('epds_handle_login_url')}=${code('"' + value + '"')}. Login page will render the "Or sign in with ATProto/Bluesky" button and hand off to this URL with ${code('?handle=')}.`, + detailHtml: `${code('epds_handle_login_url')}=${code('"' + value + '"')}. Login page will render the "Or sign in with ATProto/Bluesky" button and hand off to this URL with a ${code('handle=')} query param.`, } } From dad840a3dda51012cb9f6824ca5a5d5d8a3c2065 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 19 May 2026 15:11:57 +0000 Subject: [PATCH 5/5] docs(shared): soften https-in-production wording in epds_handle_login_url `isSafeHttpUrl` in auth-service and `/preview/validate` both accept any absolute http(s):// URL regardless of environment, so saying https is "required in production" overstates what the runtime enforces. Reword to "expected in production" and call out that the scheme gate is not environment-aware. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/shared/src/client-metadata.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/client-metadata.ts b/packages/shared/src/client-metadata.ts index 1d4aaeae..dae89c97 100644 --- a/packages/shared/src/client-metadata.ts +++ b/packages/shared/src/client-metadata.ts @@ -82,10 +82,11 @@ export interface ClientMetadata { * Must be an absolute http(s):// URL; should be on the client's own * origin (not enforced at runtime — neither the login page's * `isSafeHttpUrl` gate nor the `/preview/validate` check verifies - * origin). `https://` is required in production; `http://` is - * accepted to support localhost / dev clients (this mirrors the - * runtime `isSafeHttpUrl` gate in auth-service's login page). If - * absent or not parseable as http(s), the button is not rendered. + * origin). `https://` is expected in production; `http://` is also + * accepted at runtime to support localhost / dev clients (this + * mirrors the `isSafeHttpUrl` gate in auth-service's login page, + * which does not enforce a scheme by environment). If absent or not + * parseable as http(s), the button is not rendered. */ epds_handle_login_url?: string }