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
13 changes: 13 additions & 0 deletions .changeset/preview-validate-handle-login-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'ePDS': patch
---

The `/preview/validate` page now checks `epds_handle_login_url` on your client metadata.

**Affects:** Client app developers
Comment thread
aspiers marked this conversation as resolved.

**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.
48 changes: 48 additions & 0 deletions packages/shared/src/__tests__/preview-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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')
})

Expand Down Expand Up @@ -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:
Expand All @@ -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({
Expand Down
20 changes: 14 additions & 6 deletions packages/shared/src/client-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<value>` 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=<value>` 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
}
Expand Down
57 changes: 57 additions & 0 deletions packages/shared/src/preview-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment thread
aspiers marked this conversation as resolved.
*
* 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=<value> 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=<value>')} query param.`,
}
}

function checkPolicyUri(metadata: ClientMetadata): PreviewCheck {
return checkUriField({
id: 'policy-uri',
Expand Down Expand Up @@ -476,6 +532,7 @@ export async function validateClientMetadataForPreview(
checkBrandingCss(metadata),
checkTosUri(metadata),
checkPolicyUri(metadata),
checkHandleLoginUrl(metadata),
)

// 4. Trusted-clients membership (optional; caller may skip)
Expand Down
Loading