@startup-api/cloudflare 0.3.2 → 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 +42 -0
- 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,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
|
+
}
|
package/src/auth/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { CookieManager } from '../CookieManager';
|
|
|
4
4
|
import { refreshEntitlements } from '../entitlements/service';
|
|
5
5
|
import { computeRedirectBase, createProviders } from './providers';
|
|
6
6
|
import type { ProviderConfigs } from './providers';
|
|
7
|
+
import type { AuthContext, ExchangeResult, OAuthProvider } from './OAuthProvider';
|
|
7
8
|
|
|
8
9
|
export async function handleAuth(
|
|
9
10
|
request: Request,
|
|
@@ -25,21 +26,22 @@ export async function handleAuth(
|
|
|
25
26
|
// Instantiate active providers
|
|
26
27
|
const activeProviders = createProviders(env, redirectBase, providerConfigs);
|
|
27
28
|
|
|
29
|
+
const ctx: AuthContext = { request, env, url, redirectBase, authPath, usersPath, origin, cookieManager };
|
|
30
|
+
|
|
31
|
+
// Provider-specific auxiliary routes (e.g. the atproto client-metadata document).
|
|
32
|
+
for (const provider of activeProviders) {
|
|
33
|
+
const res = await provider.handleExtraRoute(ctx);
|
|
34
|
+
if (res) return res;
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
// Handle Auth Start
|
|
29
38
|
for (const provider of activeProviders) {
|
|
30
39
|
if (provider.isMatch(path, authPath)) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
// Use robust base64 encoding for state
|
|
37
|
-
const state = btoa(unescape(encodeURIComponent(JSON.stringify(stateObj))))
|
|
38
|
-
.replace(/\+/g, '-')
|
|
39
|
-
.replace(/\//g, '_')
|
|
40
|
-
.replace(/=+$/, '');
|
|
41
|
-
const authUrl = provider.getAuthUrl(state);
|
|
42
|
-
return Response.redirect(authUrl, 302);
|
|
40
|
+
try {
|
|
41
|
+
return await provider.authorize(ctx);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return new Response(`Auth failed: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
|
|
44
|
+
}
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -47,207 +49,205 @@ export async function handleAuth(
|
|
|
47
49
|
for (const provider of activeProviders) {
|
|
48
50
|
if (provider.isCallback(path, authPath)) {
|
|
49
51
|
console.log(`[Auth] Callback received for ${provider.name}`);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
// Robust base64 decoding
|
|
58
|
-
const base64 = stateBase64.replace(/-/g, '+').replace(/_/g, '/');
|
|
59
|
-
const stateJson = decodeURIComponent(escape(atob(base64)));
|
|
60
|
-
const stateObj = JSON.parse(stateJson);
|
|
61
|
-
returnUrl = stateObj.return_url;
|
|
62
|
-
} catch (e) {
|
|
63
|
-
console.error('Failed to parse state', e);
|
|
64
|
-
}
|
|
52
|
+
try {
|
|
53
|
+
const result = await provider.exchange(ctx);
|
|
54
|
+
return await finishLogin(provider, result, ctx);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
const status = (e as { status?: number })?.status ?? 500;
|
|
57
|
+
return new Response(`Auth failed: ${e instanceof Error ? e.message : String(e)}`, { status });
|
|
65
58
|
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
66
61
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const profile = await provider.getUserProfile(token.access_token);
|
|
70
|
-
|
|
71
|
-
const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global'));
|
|
72
|
-
|
|
73
|
-
// 1. Try to resolve existing user by credential
|
|
74
|
-
const credentialStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName(provider.name));
|
|
75
|
-
const resolveData = await credentialStub.get(profile.id);
|
|
76
|
-
|
|
77
|
-
let userIdStr: string | null = null;
|
|
78
|
-
let staleSessionId: string | null = null;
|
|
79
|
-
|
|
80
|
-
if (resolveData) {
|
|
81
|
-
userIdStr = resolveData.user_id;
|
|
82
|
-
} else {
|
|
83
|
-
// 2. Not found, check if user is already logged in (to link account)
|
|
84
|
-
const cookieHeader = request.headers.get('Cookie');
|
|
85
|
-
if (cookieHeader) {
|
|
86
|
-
const cookies = cookieHeader.split(';').reduce(
|
|
87
|
-
(acc, cookie) => {
|
|
88
|
-
const [key, value] = cookie.split('=').map((c) => c.trim());
|
|
89
|
-
if (key && value) acc[key] = value;
|
|
90
|
-
return acc;
|
|
91
|
-
},
|
|
92
|
-
{} as Record<string, string>,
|
|
93
|
-
);
|
|
94
|
-
const sessionCookieEncrypted = cookies['session_id'];
|
|
95
|
-
if (sessionCookieEncrypted) {
|
|
96
|
-
const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted);
|
|
97
|
-
if (sessionCookie && sessionCookie.includes(':')) {
|
|
98
|
-
const parts = sessionCookie.split(':');
|
|
99
|
-
staleSessionId = parts[0];
|
|
100
|
-
userIdStr = parts[1];
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
62
|
+
return new Response('Auth route not found', { status: 404 });
|
|
63
|
+
}
|
|
105
64
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Shared post-exchange login finalization, provider-agnostic: resolve or create the user, link the
|
|
67
|
+
* credential, fetch login-time entitlements, ensure a personal account exists, mint a session and set
|
|
68
|
+
* the session cookie. `result.setCookies` lets a provider emit additional cookies (e.g. clearing
|
|
69
|
+
* transient flow state).
|
|
70
|
+
*/
|
|
71
|
+
async function finishLogin(provider: OAuthProvider, result: ExchangeResult, ctx: AuthContext): Promise<Response> {
|
|
72
|
+
const { env, request, usersPath, origin, cookieManager } = ctx;
|
|
73
|
+
const { token, profile, returnUrl } = result;
|
|
74
|
+
|
|
75
|
+
const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global'));
|
|
76
|
+
|
|
77
|
+
// 1. Try to resolve existing user by credential
|
|
78
|
+
const credentialStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName(provider.name));
|
|
79
|
+
const resolveData = await credentialStub.get(profile.id);
|
|
80
|
+
|
|
81
|
+
let userIdStr: string | null = null;
|
|
82
|
+
let staleSessionId: string | null = null;
|
|
83
|
+
|
|
84
|
+
if (resolveData) {
|
|
85
|
+
userIdStr = resolveData.user_id;
|
|
86
|
+
} else {
|
|
87
|
+
// 2. Not found, check if user is already logged in (to link account)
|
|
88
|
+
const cookieHeader = request.headers.get('Cookie');
|
|
89
|
+
if (cookieHeader) {
|
|
90
|
+
const cookies = cookieHeader.split(';').reduce(
|
|
91
|
+
(acc, cookie) => {
|
|
92
|
+
const [key, value] = cookie.split('=').map((c) => c.trim());
|
|
93
|
+
if (key && value) acc[key] = value;
|
|
94
|
+
return acc;
|
|
95
|
+
},
|
|
96
|
+
{} as Record<string, string>,
|
|
97
|
+
);
|
|
98
|
+
const sessionCookieEncrypted = cookies['session_id'];
|
|
99
|
+
if (sessionCookieEncrypted) {
|
|
100
|
+
const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted);
|
|
101
|
+
if (sessionCookie && sessionCookie.includes(':')) {
|
|
102
|
+
const parts = sessionCookie.split(':');
|
|
103
|
+
staleSessionId = parts[0];
|
|
104
|
+
userIdStr = parts[1];
|
|
121
105
|
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
122
109
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
await userStub.storeImage('avatar', picBlob, picRes.headers.get('Content-Type') || 'image/jpeg');
|
|
135
|
-
// Update profile.picture to point to our worker
|
|
136
|
-
profile.picture = usersPath + 'me/avatar';
|
|
137
|
-
}
|
|
138
|
-
} catch (e) {
|
|
139
|
-
console.error('Failed to fetch avatar', e);
|
|
140
|
-
}
|
|
110
|
+
if (userIdStr) {
|
|
111
|
+
// Verify user still exists (has a profile)
|
|
112
|
+
const userStub = env.USER.get(env.USER.idFromString(userIdStr));
|
|
113
|
+
const profileData = await userStub.getProfile();
|
|
114
|
+
if (Object.keys(profileData).length === 0) {
|
|
115
|
+
// User was deleted!
|
|
116
|
+
if (staleSessionId) {
|
|
117
|
+
try {
|
|
118
|
+
await userStub.deleteSession(staleSessionId);
|
|
119
|
+
} catch (_e) {
|
|
120
|
+
// ignore
|
|
141
121
|
}
|
|
122
|
+
}
|
|
123
|
+
userIdStr = null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
142
126
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
127
|
+
const isNewUser = !userIdStr;
|
|
128
|
+
const id = userIdStr ? env.USER.idFromString(userIdStr) : env.USER.newUniqueId();
|
|
129
|
+
const userStub = env.USER.get(id);
|
|
130
|
+
userIdStr = id.toString();
|
|
131
|
+
|
|
132
|
+
// Fetch and Store Avatar (Only for new users)
|
|
133
|
+
if (isNewUser && profile.picture) {
|
|
134
|
+
try {
|
|
135
|
+
const picRes = await fetch(profile.picture);
|
|
136
|
+
if (picRes.ok) {
|
|
137
|
+
const picBlob = await picRes.arrayBuffer();
|
|
138
|
+
await userStub.storeImage('avatar', picBlob, picRes.headers.get('Content-Type') || 'image/jpeg');
|
|
139
|
+
// Update profile.picture to point to our worker
|
|
140
|
+
profile.picture = usersPath + 'me/avatar';
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
console.error('Failed to fetch avatar', e);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Register credential in provider-specific CredentialDO
|
|
148
|
+
await credentialStub.put({
|
|
149
|
+
user_id: userIdStr,
|
|
150
|
+
subject_id: profile.id,
|
|
151
|
+
access_token: token.access_token,
|
|
152
|
+
refresh_token: token.refresh_token,
|
|
153
|
+
expires_at: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined,
|
|
154
|
+
scope: token.scope,
|
|
155
|
+
profile_data: profile,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Register credential mapping in UserDO
|
|
159
|
+
await userStub.addCredential(provider.name, profile.id);
|
|
160
|
+
|
|
161
|
+
// Login-time entitlement fetch: providers that support entitlements (e.g. Patreon) get an
|
|
162
|
+
// initial entitlement snapshot now, so gating works even when no freshness mechanism is
|
|
163
|
+
// configured. Best-effort — never block or fail login on an entitlement error.
|
|
164
|
+
if (provider.supportsEntitlements()) {
|
|
165
|
+
try {
|
|
166
|
+
await refreshEntitlements(
|
|
167
|
+
env,
|
|
168
|
+
provider,
|
|
169
|
+
{
|
|
146
170
|
subject_id: profile.id,
|
|
171
|
+
user_id: userIdStr,
|
|
147
172
|
access_token: token.access_token,
|
|
148
173
|
refresh_token: token.refresh_token,
|
|
149
174
|
expires_at: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined,
|
|
150
|
-
scope: token.scope,
|
|
175
|
+
scope: typeof token.scope === 'string' ? token.scope : undefined,
|
|
151
176
|
profile_data: profile,
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
// configured. Best-effort — never block or fail login on an entitlement error.
|
|
160
|
-
if (provider.supportsEntitlements()) {
|
|
161
|
-
try {
|
|
162
|
-
await refreshEntitlements(
|
|
163
|
-
env,
|
|
164
|
-
provider,
|
|
165
|
-
{
|
|
166
|
-
subject_id: profile.id,
|
|
167
|
-
user_id: userIdStr,
|
|
168
|
-
access_token: token.access_token,
|
|
169
|
-
refresh_token: token.refresh_token,
|
|
170
|
-
expires_at: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined,
|
|
171
|
-
scope: typeof token.scope === 'string' ? token.scope : undefined,
|
|
172
|
-
profile_data: profile,
|
|
173
|
-
},
|
|
174
|
-
'oauth',
|
|
175
|
-
);
|
|
176
|
-
} catch (e) {
|
|
177
|
-
console.error('[auth] Login-time entitlement fetch failed', e);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
177
|
+
},
|
|
178
|
+
'oauth',
|
|
179
|
+
);
|
|
180
|
+
} catch (e) {
|
|
181
|
+
console.error('[auth] Login-time entitlement fetch failed', e);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
180
184
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
185
|
+
// Register User in SystemDO index (Only for new users)
|
|
186
|
+
if (isNewUser) {
|
|
187
|
+
await userStub.updateProfile(profile);
|
|
188
|
+
await systemStub.registerUser({
|
|
189
|
+
id: userIdStr,
|
|
190
|
+
name: profile.name || userIdStr,
|
|
191
|
+
email: profile.email,
|
|
192
|
+
provider: provider.name,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
191
195
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
196
|
+
// Ensure user has at least one account
|
|
197
|
+
const memberships = await userStub.getMemberships();
|
|
198
|
+
|
|
199
|
+
if (memberships.length === 0) {
|
|
200
|
+
// Create a personal account
|
|
201
|
+
const accountId = env.ACCOUNT.newUniqueId();
|
|
202
|
+
const accountStub = env.ACCOUNT.get(accountId);
|
|
203
|
+
const accountIdStr = accountId.toString();
|
|
204
|
+
|
|
205
|
+
// Initialize account info
|
|
206
|
+
await accountStub.updateInfo({
|
|
207
|
+
name: `${profile.name || userIdStr}'s Account`,
|
|
208
|
+
personal: true,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Register Account in SystemDO
|
|
212
|
+
await systemStub.registerAccount({
|
|
213
|
+
id: accountIdStr,
|
|
214
|
+
name: `${profile.name || profile.id}'s Account`,
|
|
215
|
+
status: 'active',
|
|
216
|
+
plan: 'free',
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Add user as ADMIN to the account
|
|
220
|
+
await accountStub.addMember(id.toString(), 1);
|
|
221
|
+
|
|
222
|
+
// Add membership to user
|
|
223
|
+
await userStub.addMembership(accountIdStr, 1, true);
|
|
224
|
+
}
|
|
221
225
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// Set cookie and redirect
|
|
226
|
-
const encryptedSession = await cookieManager.encrypt(`${session.sessionId}:${userIdStr}`);
|
|
227
|
-
const headers = new Headers();
|
|
228
|
-
headers.set('Set-Cookie', `session_id=${encryptedSession}; Path=/; HttpOnly; Secure; SameSite=Lax`);
|
|
229
|
-
|
|
230
|
-
let redirectUrl = !isNewUser ? usersPath + 'profile.html' : '/';
|
|
231
|
-
if (returnUrl) {
|
|
232
|
-
try {
|
|
233
|
-
const parsedReturn = new URL(returnUrl, origin);
|
|
234
|
-
if (parsedReturn.origin === origin) {
|
|
235
|
-
redirectUrl = parsedReturn.toString();
|
|
236
|
-
}
|
|
237
|
-
} catch (_e) {
|
|
238
|
-
if (returnUrl.startsWith('/')) {
|
|
239
|
-
redirectUrl = returnUrl;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
226
|
+
// Create Session
|
|
227
|
+
const session = await userStub.createSession({ provider: provider.name });
|
|
243
228
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
229
|
+
// Set cookie and redirect
|
|
230
|
+
const encryptedSession = await cookieManager.encrypt(`${session.sessionId}:${userIdStr}`);
|
|
231
|
+
const headers = new Headers();
|
|
232
|
+
headers.set('Set-Cookie', `session_id=${encryptedSession}; Path=/; HttpOnly; Secure; SameSite=Lax`);
|
|
233
|
+
for (const cookie of result.setCookies ?? []) {
|
|
234
|
+
headers.append('Set-Cookie', cookie);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let redirectUrl = !isNewUser ? usersPath + 'profile.html' : '/';
|
|
238
|
+
if (returnUrl) {
|
|
239
|
+
try {
|
|
240
|
+
const parsedReturn = new URL(returnUrl, origin);
|
|
241
|
+
if (parsedReturn.origin === origin) {
|
|
242
|
+
redirectUrl = parsedReturn.toString();
|
|
243
|
+
}
|
|
244
|
+
} catch (_e) {
|
|
245
|
+
if (returnUrl.startsWith('/')) {
|
|
246
|
+
redirectUrl = returnUrl;
|
|
248
247
|
}
|
|
249
248
|
}
|
|
250
249
|
}
|
|
251
250
|
|
|
252
|
-
|
|
251
|
+
headers.set('Location', redirectUrl);
|
|
252
|
+
return new Response(null, { status: 302, headers });
|
|
253
253
|
}
|
package/src/auth/providers.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { OAuthProvider } from './OAuthProvider';
|
|
|
4
4
|
import { GoogleProvider } from './GoogleProvider';
|
|
5
5
|
import { TwitchProvider } from './TwitchProvider';
|
|
6
6
|
import { PatreonProvider } from './PatreonProvider';
|
|
7
|
+
import { AtprotoProvider } from './AtprotoProvider';
|
|
7
8
|
|
|
8
9
|
export type ProviderConfigs = Record<string, ProviderOptions>;
|
|
9
10
|
|
|
@@ -25,6 +26,7 @@ export function createProviders(env: StartupAPIEnv, redirectBase: string, provid
|
|
|
25
26
|
GoogleProvider.create(env, redirectBase, providerConfigs.google),
|
|
26
27
|
TwitchProvider.create(env, redirectBase, providerConfigs.twitch),
|
|
27
28
|
PatreonProvider.create(env, redirectBase, providerConfigs.patreon),
|
|
29
|
+
AtprotoProvider.create(env, redirectBase, providerConfigs.atproto),
|
|
28
30
|
].filter((p): p is OAuthProvider => p !== null);
|
|
29
31
|
}
|
|
30
32
|
|
package/src/createStartupAPI.ts
CHANGED
|
@@ -167,7 +167,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {
|
|
|
167
167
|
const isAccounts = subPath === 'accounts.html' || subPath === 'accounts';
|
|
168
168
|
|
|
169
169
|
if (isProfile || isAccounts) {
|
|
170
|
-
return handleSSR(request, env, url, usersPath, cookieManager);
|
|
170
|
+
return handleSSR(request, env, url, usersPath, cookieManager, providerConfigs);
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
@@ -262,7 +262,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {
|
|
|
262
262
|
|
|
263
263
|
// Admin Routes
|
|
264
264
|
if (url.pathname.startsWith(usersPath + 'admin/')) {
|
|
265
|
-
return handleAdmin(request, env, usersPath, cookieManager);
|
|
265
|
+
return handleAdmin(request, env, usersPath, cookieManager, providerConfigs);
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
// Intercept requests to usersPath and serve them from the public/users directory.
|
|
@@ -336,7 +336,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {
|
|
|
336
336
|
return denyResponse(decision, {
|
|
337
337
|
usersPath,
|
|
338
338
|
returnUrl,
|
|
339
|
-
activeProviders: getActiveProviders(env),
|
|
339
|
+
activeProviders: getActiveProviders(env, providerConfigs),
|
|
340
340
|
authenticated,
|
|
341
341
|
request,
|
|
342
342
|
env,
|
|
@@ -345,7 +345,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {
|
|
|
345
345
|
}
|
|
346
346
|
|
|
347
347
|
const response = await originFetch(newRequest);
|
|
348
|
-
const providers = getActiveProviders(env);
|
|
348
|
+
const providers = getActiveProviders(env, providerConfigs);
|
|
349
349
|
return injectPowerStrip(response, usersPath, providers);
|
|
350
350
|
}
|
|
351
351
|
|