@startup-api/cloudflare 0.3.1 → 0.4.0
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 +84 -38
- package/package.json +1 -1
- package/public/users/power-strip.js +23 -0
- package/public/users/profile.html +4 -0
- package/src/auth/AtprotoProvider.ts +282 -0
- package/src/auth/OAuthProvider.ts +103 -3
- package/src/auth/atproto/crypto.ts +119 -0
- package/src/auth/atproto/identity.ts +178 -0
- package/src/auth/index.ts +195 -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 +6 -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,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,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AT Protocol identity resolution. Given a user-supplied handle or DID we walk the full discovery
|
|
3
|
+
* chain — with NO hardcoded Bluesky/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
|
+
try {
|
|
53
|
+
const res = await fetch(`https://${handle}/.well-known/atproto-did`, { redirect: 'error' });
|
|
54
|
+
if (res.ok) {
|
|
55
|
+
const did = (await res.text()).trim();
|
|
56
|
+
if (DID_PREFIX.test(did)) return did;
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// Fall through to DNS-based resolution.
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 2. DNS TXT `_atproto.<handle>` via DNS-over-HTTPS.
|
|
63
|
+
try {
|
|
64
|
+
const dohUrl = new URL(options.dohUrl || DEFAULT_DOH_URL);
|
|
65
|
+
dohUrl.searchParams.set('name', `_atproto.${handle}`);
|
|
66
|
+
dohUrl.searchParams.set('type', 'TXT');
|
|
67
|
+
const data = await fetchJson(dohUrl.toString(), { headers: { accept: 'application/dns-json' } });
|
|
68
|
+
for (const answer of data.Answer ?? []) {
|
|
69
|
+
const txt = String(answer.data ?? '').replace(/^"|"$/g, '');
|
|
70
|
+
if (txt.startsWith('did=')) {
|
|
71
|
+
const did = txt.slice(4).trim();
|
|
72
|
+
if (DID_PREFIX.test(did)) return did;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Fall through to the error below.
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
throw new Error(`Could not resolve handle "${handle}" to a DID`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Fetch and return the DID document for a did:plc or did:web identifier. */
|
|
83
|
+
async function resolveDidDocument(did: string, options: ResolverOptions): Promise<any> {
|
|
84
|
+
if (did.startsWith('did:plc:')) {
|
|
85
|
+
const base = (options.plcUrl || DEFAULT_PLC_URL).replace(/\/$/, '');
|
|
86
|
+
return fetchJson(`${base}/${did}`);
|
|
87
|
+
}
|
|
88
|
+
if (did.startsWith('did:web:')) {
|
|
89
|
+
// did:web:example.com[:path...] → https://example.com[/path...]/did.json (or /.well-known/did.json).
|
|
90
|
+
const rest = did.slice('did:web:'.length);
|
|
91
|
+
const segments = rest.split(':').map((s) => decodeURIComponent(s));
|
|
92
|
+
const host = segments.shift();
|
|
93
|
+
if (!host) throw new Error(`Malformed did:web identifier: ${did}`);
|
|
94
|
+
const path = segments.length ? `/${segments.join('/')}/did.json` : '/.well-known/did.json';
|
|
95
|
+
return fetchJson(`https://${host}${path}`);
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`Unsupported DID method: ${did}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Extract the PDS service endpoint from a DID document. */
|
|
101
|
+
function getPdsEndpoint(didDoc: any): string {
|
|
102
|
+
const services: any[] = Array.isArray(didDoc?.service) ? didDoc.service : [];
|
|
103
|
+
const pds = services.find(
|
|
104
|
+
(s) => s?.id === '#atproto_pds' || (typeof s?.id === 'string' && s.id.endsWith('#atproto_pds')) || s?.type === 'AtprotoPersonalDataServer',
|
|
105
|
+
);
|
|
106
|
+
const endpoint = typeof pds?.serviceEndpoint === 'string' ? pds.serviceEndpoint : pds?.serviceEndpoint?.uri;
|
|
107
|
+
if (!endpoint) throw new Error('DID document does not advertise an atproto PDS endpoint');
|
|
108
|
+
return endpoint.replace(/\/$/, '');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Extract the primary handle (`at://<handle>`) from a DID document's alsoKnownAs, if present. */
|
|
112
|
+
function getHandle(didDoc: any): string | undefined {
|
|
113
|
+
const aka: string[] = Array.isArray(didDoc?.alsoKnownAs) ? didDoc.alsoKnownAs : [];
|
|
114
|
+
const at = aka.find((a) => typeof a === 'string' && a.startsWith('at://'));
|
|
115
|
+
return at ? at.slice('at://'.length) : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Resolve a PDS to its authorization server metadata via the protected-resource indirection. */
|
|
119
|
+
async function resolveAuthServer(pds: string): Promise<AuthServerMetadata> {
|
|
120
|
+
const protectedResource = await fetchJson(new URL('/.well-known/oauth-protected-resource', pds).toString());
|
|
121
|
+
const issuer: string | undefined = protectedResource?.authorization_servers?.[0];
|
|
122
|
+
if (!issuer) throw new Error(`PDS ${pds} does not declare an authorization server`);
|
|
123
|
+
|
|
124
|
+
const metadata = (await fetchJson(new URL('/.well-known/oauth-authorization-server', issuer).toString())) as AuthServerMetadata;
|
|
125
|
+
if (!metadata.authorization_endpoint || !metadata.token_endpoint || !metadata.pushed_authorization_request_endpoint) {
|
|
126
|
+
throw new Error(`Authorization server ${issuer} is missing required OAuth endpoints (PAR is mandatory for atproto)`);
|
|
127
|
+
}
|
|
128
|
+
return metadata;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Full identity → authorization-server resolution. Accepts a handle (optionally `@`-prefixed) or a DID.
|
|
133
|
+
*/
|
|
134
|
+
export async function resolveIdentity(input: string, options: ResolverOptions = {}): Promise<ResolvedIdentity> {
|
|
135
|
+
const cleaned = input.trim().replace(/^@/, '');
|
|
136
|
+
if (!cleaned) throw new Error('Empty atproto identifier');
|
|
137
|
+
|
|
138
|
+
const isDid = cleaned.startsWith('did:');
|
|
139
|
+
let handle: string | undefined = isDid ? undefined : cleaned;
|
|
140
|
+
const did = isDid ? cleaned : await resolveHandleToDid(cleaned, options);
|
|
141
|
+
|
|
142
|
+
const didDoc = await resolveDidDocument(did, options);
|
|
143
|
+
const pds = getPdsEndpoint(didDoc);
|
|
144
|
+
if (!handle) handle = getHandle(didDoc);
|
|
145
|
+
|
|
146
|
+
const authServer = await resolveAuthServer(pds);
|
|
147
|
+
return { did, handle, pds, authServer };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Fetch a public actor profile record from the user's PDS to populate display name and avatar. Best
|
|
152
|
+
* effort: getRecord is an unauthenticated read, and any failure falls back to the handle/DID.
|
|
153
|
+
*/
|
|
154
|
+
export async function fetchProfile(pds: string, did: string, handle?: string): Promise<{ name?: string; picture?: string }> {
|
|
155
|
+
try {
|
|
156
|
+
const url = new URL('/xrpc/com.atproto.repo.getRecord', pds);
|
|
157
|
+
url.searchParams.set('repo', did);
|
|
158
|
+
url.searchParams.set('collection', 'app.bsky.actor.profile');
|
|
159
|
+
url.searchParams.set('rkey', 'self');
|
|
160
|
+
|
|
161
|
+
const res = await fetch(url.toString());
|
|
162
|
+
if (!res.ok) return { name: handle };
|
|
163
|
+
|
|
164
|
+
const data = (await res.json()) as { value?: { displayName?: string; avatar?: { ref?: { $link?: string } } } };
|
|
165
|
+
const value = data.value ?? {};
|
|
166
|
+
let picture: string | undefined;
|
|
167
|
+
const cid = value.avatar?.ref?.$link;
|
|
168
|
+
if (cid) {
|
|
169
|
+
const blob = new URL('/xrpc/com.atproto.sync.getBlob', pds);
|
|
170
|
+
blob.searchParams.set('did', did);
|
|
171
|
+
blob.searchParams.set('cid', cid);
|
|
172
|
+
picture = blob.toString();
|
|
173
|
+
}
|
|
174
|
+
return { name: value.displayName || handle, picture };
|
|
175
|
+
} catch {
|
|
176
|
+
return { name: handle };
|
|
177
|
+
}
|
|
178
|
+
}
|