@startup-api/cloudflare 0.3.2 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/package.json +3 -1
- package/public/users/power-strip.js +32 -2
- package/public/users/profile.html +4 -0
- package/src/StartupAPIEnv.ts +3 -0
- package/src/auth/AtprotoProvider.ts +327 -0
- package/src/auth/OAuthProvider.ts +103 -3
- package/src/auth/atmosphereMark.ts +7 -0
- package/src/auth/atproto/crypto.ts +119 -0
- package/src/auth/atproto/identity.ts +182 -0
- package/src/auth/errorPage.ts +73 -0
- package/src/auth/index.ts +196 -195
- package/src/auth/providers.ts +2 -0
- package/src/createStartupAPI.ts +4 -4
- package/src/handlers/admin.ts +3 -1
- package/src/handlers/ssr.ts +7 -2
- package/src/handlers/utils.ts +7 -1
- package/src/schemas/config.ts +6 -0
- package/worker-configuration.d.ts +1 -5
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atmosphere (atproto) "union" logo mark, drawn on a `0 0 32 32` viewBox with `fill-rule: evenodd`.
|
|
3
|
+
* Shared by every server-rendered atproto icon so they never drift apart. The client-side
|
|
4
|
+
* `public/users/power-strip.js` keeps its own copy since it cannot import from `src/`.
|
|
5
|
+
*/
|
|
6
|
+
export const ATMOSPHERE_MARK_PATH =
|
|
7
|
+
'M16 0C18.1127 1.0372e-05 19.9555 1.15414 20.9332 2.8662C21.1045 3.16613 21.4647 3.31517 21.7979 3.22403C22.2761 3.09321 22.7795 3.02333 23.2992 3.02333C26.4347 3.02335 28.9765 5.56529 28.9765 8.70083C28.9765 9.22048 28.9066 9.72378 28.7758 10.2019C28.6846 10.5351 28.8337 10.8953 29.1336 11.0666C30.8458 12.0442 32 13.8872 32 16C32 16.0822 31.9981 16.1639 31.9946 16.2452C31.9946 16.2456 31.9944 16.246 31.9942 16.2463C31.9939 16.2465 31.9937 16.2469 31.9937 16.2473C31.8599 19.6701 29.2873 22.4 26.1329 22.4L25.9815 22.3979C24.3638 22.353 22.909 21.59 21.8719 20.3888C21.5701 20.0392 20.9949 20.0275 20.6799 20.3652C19.5117 21.6171 17.8474 22.4 16 22.4C12.4654 22.4 9.6 19.5346 9.6 16C9.6 12.4654 12.4654 9.6 16 9.6C17.4368 9.6 18.7629 10.0736 19.831 10.8731C20.0053 11.0036 20.2667 10.8843 20.2667 10.6667C20.2667 10.0776 20.7442 9.6 21.3333 9.6C21.9224 9.6 22.4 10.0776 22.4 10.6667V16C22.4158 18.5458 24.26 20.2666 26.1329 20.2667C27.9447 20.2667 29.7293 18.6564 29.8583 16.2467C29.8583 16.2466 29.8583 16.2464 29.8581 16.2463C29.858 16.2461 29.8579 16.2459 29.8579 16.2458C29.8591 16.2281 29.8597 16.2104 29.8606 16.1927C29.8634 16.1293 29.8654 16.0654 29.8658 16.001C29.8658 16.0006 29.866 16.0003 29.8663 16C29.8665 15.9997 29.8667 15.9994 29.8667 15.999C29.8663 14.5464 28.9914 13.2933 27.7308 12.7465L26.4045 12.1712C26.0876 12.0337 25.937 11.6696 26.0641 11.3484L26.5963 10.0042C26.7549 9.60324 26.8431 9.16479 26.8431 8.70083C26.8431 6.77399 25.3056 5.20644 23.3906 5.15792L23.2992 5.15667C22.8351 5.15667 22.3965 5.24487 21.9956 5.40354L20.6515 5.93568C20.3304 6.06282 19.9663 5.91222 19.8288 5.59538L19.2533 4.26917C18.7234 3.04774 17.5306 2.18836 16.1356 2.13583L16 2.13333C14.547 2.13333 13.2934 3.00824 12.7465 4.26896L12.1712 5.59529C12.0337 5.91217 11.6696 6.06282 11.3484 5.93568L10.0042 5.40354C9.62831 5.25477 9.21941 5.16798 8.7875 5.15771L8.70083 5.15667C6.74349 5.15667 5.15667 6.74349 5.15667 8.70083C5.15667 9.16475 5.24485 9.60323 5.40354 10.0042L5.93568 11.3484C6.06282 11.6696 5.91217 12.0337 5.59529 12.1712L4.26896 12.7465C3.00823 13.2934 2.13333 14.5469 2.13333 16C2.13333 17.4529 3.00832 18.7063 4.26917 19.2533L5.59538 19.8288C5.91222 19.9663 6.06282 20.3304 5.93568 20.6515L5.40354 21.9956C5.24487 22.3965 5.15667 22.835 5.15667 23.2992C5.15667 25.2565 6.74349 26.8433 8.70083 26.8433C9.1644 26.8433 9.60287 26.7551 10.0042 26.5963L11.3484 26.0641C11.6696 25.937 12.0337 26.0876 12.1712 26.4045L12.7465 27.7308C13.2934 28.9917 14.547 29.8667 16 29.8667C17.4529 29.8667 18.7063 28.9918 19.2533 27.7308L19.6555 26.8037C19.8871 26.2698 20.5083 26.0256 21.0415 26.2589C21.561 26.4862 21.8066 27.0847 21.5966 27.6115L21.2104 28.5798C20.3374 30.5922 18.3333 32 16 32C13.8872 32 12.0442 30.8458 11.0666 29.1336C10.8953 28.8337 10.5351 28.6846 10.2019 28.7758C9.72378 28.9067 9.22049 28.9767 8.70083 28.9767C5.56528 28.9767 3.02333 26.4347 3.02333 23.2992C3.02333 22.7795 3.09321 22.2761 3.22403 21.7979C3.31516 21.4647 3.16613 21.1045 2.86619 20.9332C1.15412 19.9555 0 18.1127 0 16C5.927e-07 13.8873 1.1541 12.0443 2.8662 11.0666C3.16613 10.8953 3.31517 10.5351 3.22403 10.2019C3.09323 9.72379 3.02333 9.22047 3.02333 8.70083C3.02333 5.56528 5.56528 3.02333 8.70083 3.02333C9.22047 3.02334 9.72379 3.09323 10.2019 3.22404C10.5351 3.31517 10.8953 3.16614 11.0666 2.86621C12.0443 1.15411 13.8873 0 16 0ZM16 11.7333C13.6436 11.7333 11.7333 13.6436 11.7333 16C11.7333 18.3564 13.6436 20.2667 16 20.2667C18.3564 20.2667 20.2667 18.3564 20.2667 16C20.2667 13.6436 18.3564 11.7333 16 11.7333Z';
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cryptographic primitives for the AT Protocol OAuth flow: base64url helpers, PKCE, and DPoP
|
|
3
|
+
* (RFC 9449) proof generation. atproto OAuth requires PKCE and sender-constrained (DPoP-bound)
|
|
4
|
+
* tokens, so every request to the authorization/token endpoints carries a freshly signed ES256
|
|
5
|
+
* proof-of-possession JWT. All of this runs on the WebCrypto API available in Workers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Encode raw bytes as unpadded base64url. */
|
|
9
|
+
export function base64urlEncode(input: ArrayBuffer | Uint8Array): string {
|
|
10
|
+
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
|
|
11
|
+
let str = '';
|
|
12
|
+
for (const b of bytes) str += String.fromCharCode(b);
|
|
13
|
+
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Encode a UTF-8 string as unpadded base64url. */
|
|
17
|
+
export function base64urlEncodeString(value: string): string {
|
|
18
|
+
return base64urlEncode(new TextEncoder().encode(value));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A random unpadded-base64url token of `byteLength` random bytes (used for state, jti, PKCE verifier). */
|
|
22
|
+
export function randomToken(byteLength = 32): string {
|
|
23
|
+
return base64urlEncode(crypto.getRandomValues(new Uint8Array(byteLength)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Pkce {
|
|
27
|
+
verifier: string;
|
|
28
|
+
challenge: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Generate a PKCE verifier and its S256 challenge. */
|
|
32
|
+
export async function generatePkce(): Promise<Pkce> {
|
|
33
|
+
const verifier = randomToken(32);
|
|
34
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
|
35
|
+
return { verifier, challenge: base64urlEncode(digest) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ES256_PARAMS = { name: 'ECDSA', namedCurve: 'P-256' } as const;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generate an ephemeral ES256 (P-256) keypair for DPoP and return its private JWK. The private JWK is
|
|
42
|
+
* persisted in the (encrypted) flow state so the same key can be reused for the token request and any
|
|
43
|
+
* later refresh; the public half is embedded in each proof's header.
|
|
44
|
+
*/
|
|
45
|
+
export async function generateDpopKey(): Promise<JsonWebKey> {
|
|
46
|
+
const pair = (await crypto.subtle.generateKey(ES256_PARAMS, true, ['sign', 'verify'])) as CryptoKeyPair;
|
|
47
|
+
return (await crypto.subtle.exportKey('jwk', pair.privateKey)) as JsonWebKey;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The public portion of a DPoP JWK, as embedded in the proof header. */
|
|
51
|
+
function publicJwk(jwk: JsonWebKey): JsonWebKey {
|
|
52
|
+
return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build a DPoP proof JWT bound to the given HTTP method and URL (htu is normalized to origin+path, per
|
|
57
|
+
* RFC 9449). When the server has issued a nonce it must be echoed back in the proof.
|
|
58
|
+
*/
|
|
59
|
+
export async function createDpopProof(privateJwk: JsonWebKey, htm: string, htu: string, nonce?: string): Promise<string> {
|
|
60
|
+
const key = await crypto.subtle.importKey('jwk', privateJwk, ES256_PARAMS, false, ['sign']);
|
|
61
|
+
const target = new URL(htu);
|
|
62
|
+
const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: publicJwk(privateJwk) };
|
|
63
|
+
const payload: Record<string, unknown> = {
|
|
64
|
+
jti: randomToken(16),
|
|
65
|
+
htm: htm.toUpperCase(),
|
|
66
|
+
htu: target.origin + target.pathname,
|
|
67
|
+
iat: Math.floor(Date.now() / 1000),
|
|
68
|
+
};
|
|
69
|
+
if (nonce) payload.nonce = nonce;
|
|
70
|
+
|
|
71
|
+
const signingInput = `${base64urlEncodeString(JSON.stringify(header))}.${base64urlEncodeString(JSON.stringify(payload))}`;
|
|
72
|
+
const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, new TextEncoder().encode(signingInput));
|
|
73
|
+
// WebCrypto ECDSA already returns the raw r||s signature that JOSE/ES256 expects.
|
|
74
|
+
return `${signingInput}.${base64urlEncode(signature)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface DpopResult {
|
|
78
|
+
res: Response;
|
|
79
|
+
/** The most recent server-issued DPoP nonce, to be reused on the next request. */
|
|
80
|
+
nonce?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* POST to a DPoP-protected endpoint, transparently handling the `use_dpop_nonce` challenge: the first
|
|
85
|
+
* request is sent without (or with the cached) nonce; if the server demands a fresh nonce we retry once
|
|
86
|
+
* with it. Returns the final response and the latest nonce so the caller can chain requests (PAR →
|
|
87
|
+
* token) without re-fetching one.
|
|
88
|
+
*/
|
|
89
|
+
export async function dpopFetch(url: string, init: RequestInit, privateJwk: JsonWebKey, nonce?: string): Promise<DpopResult> {
|
|
90
|
+
let currentNonce = nonce;
|
|
91
|
+
const method = (init.method || 'POST').toUpperCase();
|
|
92
|
+
|
|
93
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
94
|
+
const proof = await createDpopProof(privateJwk, method, url, currentNonce);
|
|
95
|
+
const headers = new Headers(init.headers);
|
|
96
|
+
headers.set('DPoP', proof);
|
|
97
|
+
const res = await fetch(url, { ...init, headers });
|
|
98
|
+
const serverNonce = res.headers.get('DPoP-Nonce') ?? undefined;
|
|
99
|
+
|
|
100
|
+
if ((res.status === 400 || res.status === 401) && serverNonce && attempt === 0) {
|
|
101
|
+
let needsNonce = false;
|
|
102
|
+
try {
|
|
103
|
+
const body = (await res.clone().json()) as { error?: string };
|
|
104
|
+
needsNonce = body?.error === 'use_dpop_nonce';
|
|
105
|
+
} catch {
|
|
106
|
+
// Non-JSON body; fall through and surface the original response.
|
|
107
|
+
}
|
|
108
|
+
if (needsNonce) {
|
|
109
|
+
currentNonce = serverNonce;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { res, nonce: serverNonce ?? currentNonce };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Unreachable: the loop either returns a response or retries exactly once then returns.
|
|
118
|
+
throw new Error('dpopFetch: exhausted retries');
|
|
119
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AT Protocol identity resolution. Given a user-supplied handle or DID we walk the full discovery
|
|
3
|
+
* chain — with NO hardcoded provider/PDS hosts — to find the authorization server that owns the
|
|
4
|
+
* identity:
|
|
5
|
+
*
|
|
6
|
+
* handle ──▶ DID (HTTPS `.well-known/atproto-did`, then DNS TXT `_atproto.<handle>` via DoH)
|
|
7
|
+
* DID ──▶ DID document (did:plc via a PLC directory, did:web via the domain's `.well-known`)
|
|
8
|
+
* DID doc──▶ PDS endpoint (the `#atproto_pds` service)
|
|
9
|
+
* PDS ──▶ auth server (`.well-known/oauth-protected-resource` → `.well-known/oauth-authorization-server`)
|
|
10
|
+
*
|
|
11
|
+
* The PLC directory and DNS-over-HTTPS resolver are generic, swappable infrastructure (overridable via
|
|
12
|
+
* env), not identity providers — every account hosts its own PDS and authorization server, which we
|
|
13
|
+
* discover dynamically here.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const DID_PREFIX = /^did:(plc|web):/;
|
|
17
|
+
|
|
18
|
+
export interface AuthServerMetadata {
|
|
19
|
+
issuer: string;
|
|
20
|
+
authorization_endpoint: string;
|
|
21
|
+
token_endpoint: string;
|
|
22
|
+
pushed_authorization_request_endpoint: string;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ResolvedIdentity {
|
|
27
|
+
did: string;
|
|
28
|
+
handle?: string;
|
|
29
|
+
pds: string;
|
|
30
|
+
authServer: AuthServerMetadata;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ResolverOptions {
|
|
34
|
+
/** PLC directory base URL for resolving did:plc. Defaults to the canonical https://plc.directory. */
|
|
35
|
+
plcUrl?: string;
|
|
36
|
+
/** DNS-over-HTTPS endpoint (RFC 8484 JSON) for the `_atproto.<handle>` TXT fallback. */
|
|
37
|
+
dohUrl?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_PLC_URL = 'https://plc.directory';
|
|
41
|
+
const DEFAULT_DOH_URL = 'https://cloudflare-dns.com/dns-query';
|
|
42
|
+
|
|
43
|
+
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
|
|
44
|
+
const res = await fetch(url, init);
|
|
45
|
+
if (!res.ok) throw new Error(`Request to ${url} failed: ${res.status} ${res.statusText}`);
|
|
46
|
+
return res.json();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resolve a handle to a DID via the HTTPS well-known method, falling back to DNS TXT over DoH. */
|
|
50
|
+
async function resolveHandleToDid(handle: string, options: ResolverOptions): Promise<string> {
|
|
51
|
+
// 1. HTTPS well-known (works for any host that serves it; no third-party dependency).
|
|
52
|
+
// Use `redirect: 'manual'` rather than `'error'`: the Cloudflare Workers runtime does not implement
|
|
53
|
+
// the `'error'` redirect mode and throws a TypeError when it is used, which would silently disable
|
|
54
|
+
// this method (and break resolution for *.bsky.social handles, which have no `_atproto` DNS record).
|
|
55
|
+
// With `'manual'`, a redirected response is not followed and `res.ok` is false, so we fall through.
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(`https://${handle}/.well-known/atproto-did`, { redirect: 'manual' });
|
|
58
|
+
if (res.ok) {
|
|
59
|
+
const did = (await res.text()).trim();
|
|
60
|
+
if (DID_PREFIX.test(did)) return did;
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Fall through to DNS-based resolution.
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2. DNS TXT `_atproto.<handle>` via DNS-over-HTTPS.
|
|
67
|
+
try {
|
|
68
|
+
const dohUrl = new URL(options.dohUrl || DEFAULT_DOH_URL);
|
|
69
|
+
dohUrl.searchParams.set('name', `_atproto.${handle}`);
|
|
70
|
+
dohUrl.searchParams.set('type', 'TXT');
|
|
71
|
+
const data = await fetchJson(dohUrl.toString(), { headers: { accept: 'application/dns-json' } });
|
|
72
|
+
for (const answer of data.Answer ?? []) {
|
|
73
|
+
const txt = String(answer.data ?? '').replace(/^"|"$/g, '');
|
|
74
|
+
if (txt.startsWith('did=')) {
|
|
75
|
+
const did = txt.slice(4).trim();
|
|
76
|
+
if (DID_PREFIX.test(did)) return did;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Fall through to the error below.
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw new Error(`Could not resolve handle "${handle}" to a DID`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Fetch and return the DID document for a did:plc or did:web identifier. */
|
|
87
|
+
async function resolveDidDocument(did: string, options: ResolverOptions): Promise<any> {
|
|
88
|
+
if (did.startsWith('did:plc:')) {
|
|
89
|
+
const base = (options.plcUrl || DEFAULT_PLC_URL).replace(/\/$/, '');
|
|
90
|
+
return fetchJson(`${base}/${did}`);
|
|
91
|
+
}
|
|
92
|
+
if (did.startsWith('did:web:')) {
|
|
93
|
+
// did:web:example.com[:path...] → https://example.com[/path...]/did.json (or /.well-known/did.json).
|
|
94
|
+
const rest = did.slice('did:web:'.length);
|
|
95
|
+
const segments = rest.split(':').map((s) => decodeURIComponent(s));
|
|
96
|
+
const host = segments.shift();
|
|
97
|
+
if (!host) throw new Error(`Malformed did:web identifier: ${did}`);
|
|
98
|
+
const path = segments.length ? `/${segments.join('/')}/did.json` : '/.well-known/did.json';
|
|
99
|
+
return fetchJson(`https://${host}${path}`);
|
|
100
|
+
}
|
|
101
|
+
throw new Error(`Unsupported DID method: ${did}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Extract the PDS service endpoint from a DID document. */
|
|
105
|
+
function getPdsEndpoint(didDoc: any): string {
|
|
106
|
+
const services: any[] = Array.isArray(didDoc?.service) ? didDoc.service : [];
|
|
107
|
+
const pds = services.find(
|
|
108
|
+
(s) => s?.id === '#atproto_pds' || (typeof s?.id === 'string' && s.id.endsWith('#atproto_pds')) || s?.type === 'AtprotoPersonalDataServer',
|
|
109
|
+
);
|
|
110
|
+
const endpoint = typeof pds?.serviceEndpoint === 'string' ? pds.serviceEndpoint : pds?.serviceEndpoint?.uri;
|
|
111
|
+
if (!endpoint) throw new Error('DID document does not advertise an atproto PDS endpoint');
|
|
112
|
+
return endpoint.replace(/\/$/, '');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Extract the primary handle (`at://<handle>`) from a DID document's alsoKnownAs, if present. */
|
|
116
|
+
function getHandle(didDoc: any): string | undefined {
|
|
117
|
+
const aka: string[] = Array.isArray(didDoc?.alsoKnownAs) ? didDoc.alsoKnownAs : [];
|
|
118
|
+
const at = aka.find((a) => typeof a === 'string' && a.startsWith('at://'));
|
|
119
|
+
return at ? at.slice('at://'.length) : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Resolve a PDS to its authorization server metadata via the protected-resource indirection. */
|
|
123
|
+
async function resolveAuthServer(pds: string): Promise<AuthServerMetadata> {
|
|
124
|
+
const protectedResource = await fetchJson(new URL('/.well-known/oauth-protected-resource', pds).toString());
|
|
125
|
+
const issuer: string | undefined = protectedResource?.authorization_servers?.[0];
|
|
126
|
+
if (!issuer) throw new Error(`PDS ${pds} does not declare an authorization server`);
|
|
127
|
+
|
|
128
|
+
const metadata = (await fetchJson(new URL('/.well-known/oauth-authorization-server', issuer).toString())) as AuthServerMetadata;
|
|
129
|
+
if (!metadata.authorization_endpoint || !metadata.token_endpoint || !metadata.pushed_authorization_request_endpoint) {
|
|
130
|
+
throw new Error(`Authorization server ${issuer} is missing required OAuth endpoints (PAR is mandatory for atproto)`);
|
|
131
|
+
}
|
|
132
|
+
return metadata;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Full identity → authorization-server resolution. Accepts a handle (optionally `@`-prefixed) or a DID.
|
|
137
|
+
*/
|
|
138
|
+
export async function resolveIdentity(input: string, options: ResolverOptions = {}): Promise<ResolvedIdentity> {
|
|
139
|
+
const cleaned = input.trim().replace(/^@/, '');
|
|
140
|
+
if (!cleaned) throw new Error('Empty atproto identifier');
|
|
141
|
+
|
|
142
|
+
const isDid = cleaned.startsWith('did:');
|
|
143
|
+
let handle: string | undefined = isDid ? undefined : cleaned;
|
|
144
|
+
const did = isDid ? cleaned : await resolveHandleToDid(cleaned, options);
|
|
145
|
+
|
|
146
|
+
const didDoc = await resolveDidDocument(did, options);
|
|
147
|
+
const pds = getPdsEndpoint(didDoc);
|
|
148
|
+
if (!handle) handle = getHandle(didDoc);
|
|
149
|
+
|
|
150
|
+
const authServer = await resolveAuthServer(pds);
|
|
151
|
+
return { did, handle, pds, authServer };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Fetch a public actor profile record from the user's PDS to populate display name and avatar. Best
|
|
156
|
+
* effort: getRecord is an unauthenticated read, and any failure falls back to the handle/DID.
|
|
157
|
+
*/
|
|
158
|
+
export async function fetchProfile(pds: string, did: string, handle?: string): Promise<{ name?: string; picture?: string }> {
|
|
159
|
+
try {
|
|
160
|
+
const url = new URL('/xrpc/com.atproto.repo.getRecord', pds);
|
|
161
|
+
url.searchParams.set('repo', did);
|
|
162
|
+
url.searchParams.set('collection', 'app.bsky.actor.profile');
|
|
163
|
+
url.searchParams.set('rkey', 'self');
|
|
164
|
+
|
|
165
|
+
const res = await fetch(url.toString());
|
|
166
|
+
if (!res.ok) return { name: handle };
|
|
167
|
+
|
|
168
|
+
const data = (await res.json()) as { value?: { displayName?: string; avatar?: { ref?: { $link?: string } } } };
|
|
169
|
+
const value = data.value ?? {};
|
|
170
|
+
let picture: string | undefined;
|
|
171
|
+
const cid = value.avatar?.ref?.$link;
|
|
172
|
+
if (cid) {
|
|
173
|
+
const blob = new URL('/xrpc/com.atproto.sync.getBlob', pds);
|
|
174
|
+
blob.searchParams.set('did', did);
|
|
175
|
+
blob.searchParams.set('cid', cid);
|
|
176
|
+
picture = blob.toString();
|
|
177
|
+
}
|
|
178
|
+
return { name: value.displayName || handle, picture };
|
|
179
|
+
} catch {
|
|
180
|
+
return { name: handle };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Styled error page for the auth flow.
|
|
3
|
+
*
|
|
4
|
+
* Auth error messages frequently embed UNTRUSTED text from outside our trust boundary: the
|
|
5
|
+
* user-supplied handle, and raw response bodies from a user's PDS or authorization server
|
|
6
|
+
* (e.g. `Token request failed: 400 <body>`). Protection is layered:
|
|
7
|
+
*
|
|
8
|
+
* 1. Encode — the untrusted message is HTML-escaped with `he` (a battle-tested, dependency-free
|
|
9
|
+
* output encoder) before it ever reaches the markup. We do not hand-roll escaping.
|
|
10
|
+
* 2. Contain — the page is served under a restrictive Content-Security-Policy (no script may run)
|
|
11
|
+
* plus `nosniff`, so even an encoding bypass cannot execute code or be re-sniffed.
|
|
12
|
+
*
|
|
13
|
+
* Theming matches the rest of the app: we link `/users/style.css` and use its CSS variables, so the
|
|
14
|
+
* page follows the user's OS / chosen theme via `prefers-color-scheme` exactly like profile.html.
|
|
15
|
+
* The CSP allows `style-src 'self'` for that same-origin stylesheet; scripts remain fully blocked.
|
|
16
|
+
*/
|
|
17
|
+
import { escape as escapeHtml } from 'he';
|
|
18
|
+
|
|
19
|
+
/** Hard cap on rendered detail length — external bodies can be arbitrarily large. */
|
|
20
|
+
const MAX_DETAIL_LENGTH = 300;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Content-Security-Policy for the error page. `default-src 'none'` blocks every resource type
|
|
24
|
+
* (scripts, images, frames, connections). We additively allow `style-src 'self' 'unsafe-inline'`
|
|
25
|
+
* for the linked `style.css` (same-origin) and the page's own inline `<style>`. With no `script-src`,
|
|
26
|
+
* no inline or external script can ever run — neutralizing HTML/script injection as a class.
|
|
27
|
+
* `frame-ancestors 'none'` is listed explicitly because it does NOT fall back to `default-src`, and
|
|
28
|
+
* without it the page could be framed by another origin (clickjacking) — unwanted for an auth endpoint.
|
|
29
|
+
*/
|
|
30
|
+
const CONTENT_SECURITY_POLICY =
|
|
31
|
+
"default-src 'none'; style-src 'self' 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
|
|
32
|
+
|
|
33
|
+
/** Render a minimal styled "sign-in failed" page that matches the app theme. */
|
|
34
|
+
export function renderAuthError(message: string, status = 500, usersPath = '/users/'): Response {
|
|
35
|
+
const text = typeof message === 'string' ? message : String(message ?? '');
|
|
36
|
+
const bounded = text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH - 1)}…` : text;
|
|
37
|
+
const detail = escapeHtml(bounded).trim();
|
|
38
|
+
const stylesheet = `${escapeHtml(usersPath)}style.css`;
|
|
39
|
+
const html = `<!doctype html>
|
|
40
|
+
<html lang="en">
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="utf-8" />
|
|
43
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
44
|
+
<title>Sign-in failed</title>
|
|
45
|
+
<link rel="stylesheet" href="${stylesheet}" />
|
|
46
|
+
<style>
|
|
47
|
+
body { margin: 0; padding: 1rem; box-sizing: border-box; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg); color: var(--text); }
|
|
48
|
+
.auth-card { background: var(--surface); border: 1px solid var(--border); padding: 2rem; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.08); width: 360px; max-width: 100%; box-sizing: border-box; }
|
|
49
|
+
.auth-card h1 { font-size: 1.15rem; margin: 0 0 0.75rem; color: var(--danger); }
|
|
50
|
+
.auth-card p { font-size: 0.9rem; color: var(--text-secondary); margin: 0 0 1rem; line-height: 1.45; }
|
|
51
|
+
.auth-card .detail { background: var(--surface-muted); border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.7rem; font-size: 0.82rem; color: var(--text-faint); word-break: break-word; white-space: pre-wrap; }
|
|
52
|
+
</style>
|
|
53
|
+
</head>
|
|
54
|
+
<body>
|
|
55
|
+
<div class="auth-card">
|
|
56
|
+
<h1>Sign-in failed</h1>
|
|
57
|
+
<p>We couldn't complete your sign-in. Please return to the sign-in page and try again.</p>
|
|
58
|
+
${detail ? `<div class="detail">${detail}</div>` : ''}
|
|
59
|
+
</div>
|
|
60
|
+
</body>
|
|
61
|
+
</html>`;
|
|
62
|
+
return new Response(html, {
|
|
63
|
+
status,
|
|
64
|
+
headers: {
|
|
65
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
66
|
+
'Content-Security-Policy': CONTENT_SECURITY_POLICY,
|
|
67
|
+
'X-Content-Type-Options': 'nosniff',
|
|
68
|
+
'Referrer-Policy': 'no-referrer',
|
|
69
|
+
// Reflects a user handle / remote server message — never let a browser or intermediary cache it.
|
|
70
|
+
'Cache-Control': 'no-store',
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|