diff --git a/.changeset/preview-validate-handle-login-url.md b/.changeset/preview-validate-handle-login-url.md new file mode 100644 index 00000000..adf96261 --- /dev/null +++ b/.changeset/preview-validate-handle-login-url.md @@ -0,0 +1,13 @@ +--- +'ePDS': patch +--- + +The `/preview/validate` page now checks `epds_handle_login_url` on your client metadata. + +**Affects:** Client app developers + +**Client app developers:** a new `handle-login-url` row joins the existing field checks. + +- 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. 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/client-metadata.ts b/packages/shared/src/client-metadata.ts index 1c3d0ded..dae89c97 100644 --- a/packages/shared/src/client-metadata.ts +++ b/packages/shared/src/client-metadata.ts @@ -72,13 +72,21 @@ 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 https:// URL on the client's own origin. - * If absent, 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 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 } diff --git a/packages/shared/src/preview-validation.ts b/packages/shared/src/preview-validation.ts index 636e2181..e3b56f5f 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 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 a ${code('handle=')} query param.`, + } +} + 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)