@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
package/README.md
CHANGED
|
@@ -69,6 +69,9 @@ Either way, set your production secrets (`SESSION_SECRET`, OAuth credentials) on
|
|
|
69
69
|
| `PATREON_CLIENT_ID` | No | N/A | Patreon OAuth2 Client ID |
|
|
70
70
|
| `PATREON_CLIENT_SECRET` | No | N/A | Patreon OAuth2 Client Secret |
|
|
71
71
|
| `PATREON_WEBHOOK_SECRET` | No | N/A | Secret for verifying Patreon webhook signatures |
|
|
72
|
+
| `ATPROTO_ENABLED` | No | N/A | Set truthy (`true`/`1`/`yes`/`on`) to enable AT Protocol (Atmosphere) login |
|
|
73
|
+
|
|
74
|
+
> AT Protocol (Atmosphere) login needs **no client secret** — it is a public OAuth client. Enable it either by setting `ATPROTO_ENABLED` truthy (a per-deployment switch) **or** through the `createStartupAPI` factory (see [AT Protocol / Atmosphere](#at-protocol--atmosphere-atproto) below). A factory `atproto: { enabled: false }` overrides the env flag, so a deployment can force it off.
|
|
72
75
|
|
|
73
76
|
> Environment variables hold only credentials/secrets (OAuth client IDs and all secrets) plus the per‑deployment values `ORIGIN_URL`, `AUTH_ORIGIN`, `USERS_PATH`, `ADMIN_IDS`, and `ENVIRONMENT`. **All other configuration — OAuth scopes, Patreon campaign id, the access policy, entitlement freshness — is passed to the `createStartupAPI` factory** (see [Access policy & provider entitlements](#access-policy--provider-entitlements)).
|
|
74
77
|
|
|
@@ -99,6 +102,51 @@ Either way, set your production secrets (`SESSION_SECRET`, OAuth credentials) on
|
|
|
99
102
|
3. Add your authorized redirect URI: `https://<your-worker-url>/users/auth/patreon/callback`
|
|
100
103
|
4. Copy the **Client ID** and **Client Secret** and add them to your Worker's environment variables
|
|
101
104
|
|
|
105
|
+
#### AT Protocol / Atmosphere (atproto)
|
|
106
|
+
|
|
107
|
+
atproto login is decentralized: there is **no central provider to register with and no client secret**. Instead the worker acts as a [public OAuth client](https://atproto.com/specs/oauth) identified by a client-metadata document it serves itself, and it discovers the right authorization server **per user** from their handle or DID — so it works with `bsky.social` and any self-hosted PDS alike, with no provider host hardcoded.
|
|
108
|
+
|
|
109
|
+
Because it has no secrets, atproto is enabled in one of two ways:
|
|
110
|
+
|
|
111
|
+
- **Env flag (no code):** set `ATPROTO_ENABLED` truthy (`true`/`1`/`yes`/`on`) — handy for toggling it per deployment (e.g. on in prod, off in preview) without touching code. This uses the default settings.
|
|
112
|
+
- **Factory config (for customization):** include its config key in `createStartupAPI` — an empty object is enough, and the optional fields below let you set the client name, resolvers, or scopes.
|
|
113
|
+
|
|
114
|
+
A factory `atproto: { enabled: false }` is an explicit opt-out that **overrides** the env flag, so a deployment can force the provider off.
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { createStartupAPI } from '@startup-api/cloudflare';
|
|
118
|
+
|
|
119
|
+
const api = createStartupAPI({
|
|
120
|
+
providers: {
|
|
121
|
+
atproto: {}, // including the key enables it — no client id/secret needed
|
|
122
|
+
// All fields below are optional:
|
|
123
|
+
// atproto: {
|
|
124
|
+
// clientName: 'My App', // shown on the consent screen (default "StartupAPI")
|
|
125
|
+
// plcUrl: 'https://plc.directory', // override the PLC directory for did:plc
|
|
126
|
+
// dohUrl: 'https://cloudflare-dns.com/dns-query', // override the DoH resolver
|
|
127
|
+
// scopes: 'transition:generic', // extra scopes on top of the base `atproto`
|
|
128
|
+
// enabled: false, // explicit opt-out (e.g. for dynamically-built config)
|
|
129
|
+
// },
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
export default api.default;
|
|
134
|
+
export const { UserDO, AccountDO, SystemDO, CredentialDO } = api;
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
1. Enable it — set `ATPROTO_ENABLED` truthy, or include `atproto: {}` in the factory `providers` config (no client id/secret needed either way). A factory `enabled: false` forces it off.
|
|
138
|
+
2. Deploy over **HTTPS** with a stable hostname. The worker automatically serves its client metadata at `https://<your-worker-url>/users/auth/atproto/client-metadata.json` (this URL is the OAuth `client_id`) and registers the redirect URI `https://<your-worker-url>/users/auth/atproto/callback`.
|
|
139
|
+
3. That's it. When a visitor clicks **Login with your Atmosphere account**, they're asked for their handle (e.g. `alice.bsky.social`) or DID; the worker then resolves it through the full atproto discovery chain and redirects them to _their own_ server to sign in:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
handle ─▶ DID (HTTPS .well-known/atproto-did, then DNS _atproto.<handle> via DoH)
|
|
143
|
+
DID ─▶ DID document (did:plc via the PLC directory, did:web via the domain)
|
|
144
|
+
DID doc─▶ PDS endpoint (the #atproto_pds service)
|
|
145
|
+
PDS ─▶ auth server (.well-known/oauth-protected-resource → oauth-authorization-server)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The flow uses PKCE, DPoP-bound (sender-constrained) tokens, and Pushed Authorization Requests (PAR) as required by the atproto OAuth profile. The PLC directory and DNS-over-HTTPS resolver are generic infrastructure and can be overridden via the `plcUrl` / `dohUrl` factory options.
|
|
149
|
+
|
|
102
150
|
#### Requesting additional scopes
|
|
103
151
|
|
|
104
152
|
Each provider requests the minimal scopes needed to sign a user in and read their basic profile. To request more (for example, to read a user's Patreon memberships), set the provider's `scopes` in the factory config (a string or array). The extra scopes are merged with the required base scopes:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startup-api/cloudflare",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@cloudflare/vitest-pool-workers": "^0.12.4",
|
|
39
39
|
"@eslint/js": "^10.0.1",
|
|
40
|
+
"@types/he": "^1.2.3",
|
|
40
41
|
"@vitest/coverage-istanbul": "^3.2.4",
|
|
41
42
|
"@vitest/coverage-v8": "^3.2.4",
|
|
42
43
|
"eslint": "^10.0.1",
|
|
@@ -47,6 +48,7 @@
|
|
|
47
48
|
"wrangler": "^4.60.0"
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
51
|
+
"he": "^1.2.0",
|
|
50
52
|
"prettier": "^3.8.1",
|
|
51
53
|
"zod": "^3.25.76"
|
|
52
54
|
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Atmosphere (atproto) logo mark — the "union" glyph, drawn on a 0 0 32 32 viewBox.
|
|
2
|
+
// Shared by the provider badge and the login button so the two never drift apart.
|
|
3
|
+
const ATMOSPHERE_MARK_PATH =
|
|
4
|
+
'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';
|
|
5
|
+
|
|
1
6
|
class PowerStrip extends HTMLElement {
|
|
2
7
|
constructor() {
|
|
3
8
|
super();
|
|
@@ -212,6 +217,8 @@ class PowerStrip extends HTMLElement {
|
|
|
212
217
|
return `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="#9146FF" stroke="white" stroke-width="1"/><path d="M7 6H6v10h2v3l3-3h3l4-4V6H7zm9 6l-2 2h-3l-2 2v-2H8V7h8v5z" fill="white"/><path d="M14 8.5h1.5v2H14V8.5zm-3 0h1.5v2H11v-2z" fill="white"/></svg>`;
|
|
213
218
|
} else if (provider === 'patreon') {
|
|
214
219
|
return `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="#FF424D" stroke="white" stroke-width="1"/><circle cx="14" cy="11" r="3.5" fill="white"/><rect x="6.5" y="6.5" width="2" height="11" fill="white"/></svg>`;
|
|
220
|
+
} else if (provider === 'atproto') {
|
|
221
|
+
return `<svg viewBox="0 0 32 32"><circle cx="16" cy="16" r="16" fill="#4a8ad4"/><g transform="translate(3.2 3.2) scale(0.8)"><path fill-rule="evenodd" clip-rule="evenodd" d="${ATMOSPHERE_MARK_PATH}" fill="white"/></g></svg>`;
|
|
215
222
|
}
|
|
216
223
|
return '';
|
|
217
224
|
}
|
|
@@ -221,6 +228,7 @@ class PowerStrip extends HTMLElement {
|
|
|
221
228
|
const googleLink = `${this.basePath}/auth/google?return_url=${returnUrl}`;
|
|
222
229
|
const twitchLink = `${this.basePath}/auth/twitch?return_url=${returnUrl}`;
|
|
223
230
|
const patreonLink = `${this.basePath}/auth/patreon?return_url=${returnUrl}`;
|
|
231
|
+
const atprotoLink = `${this.basePath}/auth/atproto?return_url=${returnUrl}`;
|
|
224
232
|
const logoutLink = `${this.basePath}/logout?return_url=${returnUrl}`;
|
|
225
233
|
|
|
226
234
|
const providersStr = this.getAttribute('providers') || '';
|
|
@@ -257,6 +265,15 @@ class PowerStrip extends HTMLElement {
|
|
|
257
265
|
Continue with Patreon
|
|
258
266
|
</a>`;
|
|
259
267
|
}
|
|
268
|
+
if (providers.includes('atproto')) {
|
|
269
|
+
authButtons += `
|
|
270
|
+
<a href="${atprotoLink}" class="auth-btn atproto">
|
|
271
|
+
<svg viewBox="0 0 32 32">
|
|
272
|
+
<path fill-rule="evenodd" clip-rule="evenodd" d="${ATMOSPHERE_MARK_PATH}" fill="currentColor"/>
|
|
273
|
+
</svg>
|
|
274
|
+
Login with your Atmosphere account
|
|
275
|
+
</a>`;
|
|
276
|
+
}
|
|
260
277
|
|
|
261
278
|
let content = '';
|
|
262
279
|
let accountSwitcher = '';
|
|
@@ -585,6 +602,7 @@ class PowerStrip extends HTMLElement {
|
|
|
585
602
|
.provider-badge.google { color: #3c4043; }
|
|
586
603
|
.provider-badge.twitch { color: #9146FF; }
|
|
587
604
|
.provider-badge.patreon { color: #FF424D; }
|
|
605
|
+
.provider-badge.atproto { color: #4a8ad4; }
|
|
588
606
|
|
|
589
607
|
.user-info {
|
|
590
608
|
display: flex;
|
|
@@ -658,7 +676,7 @@ class PowerStrip extends HTMLElement {
|
|
|
658
676
|
box-shadow: var(--ps-dialog-shadow);
|
|
659
677
|
background: var(--ps-dialog-bg);
|
|
660
678
|
color: var(--ps-dialog-text);
|
|
661
|
-
max-width:
|
|
679
|
+
max-width: 25rem;
|
|
662
680
|
width: 90%;
|
|
663
681
|
overflow: hidden;
|
|
664
682
|
}
|
|
@@ -723,7 +741,8 @@ class PowerStrip extends HTMLElement {
|
|
|
723
741
|
justify-content: center;
|
|
724
742
|
gap: 0.75rem;
|
|
725
743
|
font-weight: 500;
|
|
726
|
-
font-size:
|
|
744
|
+
font-size: 0.95rem;
|
|
745
|
+
white-space: nowrap;
|
|
727
746
|
transition: all 0.2s ease;
|
|
728
747
|
text-decoration: none;
|
|
729
748
|
color: inherit;
|
|
@@ -742,6 +761,7 @@ class PowerStrip extends HTMLElement {
|
|
|
742
761
|
.auth-btn svg {
|
|
743
762
|
width: 1.5rem;
|
|
744
763
|
height: 1.5rem;
|
|
764
|
+
flex-shrink: 0;
|
|
745
765
|
}
|
|
746
766
|
|
|
747
767
|
.auth-btn.google {
|
|
@@ -773,6 +793,16 @@ class PowerStrip extends HTMLElement {
|
|
|
773
793
|
border-color: #e63a44;
|
|
774
794
|
}
|
|
775
795
|
|
|
796
|
+
.auth-btn.atproto {
|
|
797
|
+
background-color: #4a8ad4;
|
|
798
|
+
color: white;
|
|
799
|
+
border-color: #4a8ad4;
|
|
800
|
+
}
|
|
801
|
+
.auth-btn.atproto:hover {
|
|
802
|
+
background-color: #3d77ba;
|
|
803
|
+
border-color: #3d77ba;
|
|
804
|
+
}
|
|
805
|
+
|
|
776
806
|
/* Account Switcher Styling */
|
|
777
807
|
.account-list {
|
|
778
808
|
display: flex;
|
|
@@ -433,6 +433,10 @@
|
|
|
433
433
|
return `<svg viewBox="0 0 24 24" width="24" height="24" style="color: #FF424D;">
|
|
434
434
|
<path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-3.97 0-7.21-3.22-7.21-7.18 0-3.97 3.24-7.21 7.21-7.21M2 21.6h3.5V2.41H2V21.6z" fill="currentColor"/>
|
|
435
435
|
</svg>`;
|
|
436
|
+
} else if (provider === 'atproto') {
|
|
437
|
+
return `<svg viewBox="0 0 24 24" width="24" height="24" style="color: #0085FF;">
|
|
438
|
+
<path d="M12 10.5C10.9 8.4 8.2 6.3 6.3 6c-1.5-.2-1.8.7-1.5 2 .2 1 1.5 5 2.3 6 .9 1.2 2 1.4 3 1.2-1.7.3-3.2 1-1.2 3 .9.9 1.6.3 2.1-.6.5-1 .8-2.1 1-2.6.2.5.5 1.6 1 2.6.5.9 1.2 1.5 2.1.6 2-2 .5-2.7-1.2-3 1 .2 2.1 0 3-1.2.8-1 2.1-5 2.3-6 .3-1.3 0-2.2-1.5-2-1.9.3-4.6 2.4-5.7 4.5z" fill="currentColor"/>
|
|
439
|
+
</svg>`;
|
|
436
440
|
}
|
|
437
441
|
return '';
|
|
438
442
|
}
|
package/src/StartupAPIEnv.ts
CHANGED
|
@@ -12,4 +12,7 @@ export type StartupAPIEnv = {
|
|
|
12
12
|
ADMIN_IDS: string;
|
|
13
13
|
SESSION_SECRET: string;
|
|
14
14
|
ENVIRONMENT?: string;
|
|
15
|
+
// atproto has no credentials; this per-deployment flag enables it without touching the factory
|
|
16
|
+
// config (truthy = "true"/"1"/"yes"/"on"). A factory `atproto: { enabled: false }` still overrides it.
|
|
17
|
+
ATPROTO_ENABLED?: string;
|
|
15
18
|
} & Env;
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { escape as escapeHtml } from 'he';
|
|
2
|
+
|
|
3
|
+
import { ATMOSPHERE_MARK_PATH } from './atmosphereMark';
|
|
4
|
+
import type { StartupAPIEnv } from '../StartupAPIEnv';
|
|
5
|
+
import type { ProviderOptions } from '../schemas/config';
|
|
6
|
+
|
|
7
|
+
import { OAuthProvider, type AuthContext, type ExchangeResult, type OAuthTokenResponse, type UserProfile } from './OAuthProvider';
|
|
8
|
+
import { dpopFetch, generateDpopKey, generatePkce, randomToken } from './atproto/crypto';
|
|
9
|
+
import { fetchProfile, resolveIdentity, type ResolverOptions } from './atproto/identity';
|
|
10
|
+
|
|
11
|
+
const FLOW_COOKIE = 'atproto_flow';
|
|
12
|
+
// Transient flow state lives only for the duration of the redirect round-trip.
|
|
13
|
+
const FLOW_TTL_SECONDS = 600;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Encrypted, cookie-stored state that must survive the redirect from the authorization server back to
|
|
17
|
+
* our callback: the PKCE verifier, the DPoP private key, the latest DPoP nonce, and the dynamically
|
|
18
|
+
* discovered endpoints/identity for this specific user.
|
|
19
|
+
*/
|
|
20
|
+
interface AtprotoFlowState {
|
|
21
|
+
state: string;
|
|
22
|
+
verifier: string;
|
|
23
|
+
dpopKey: JsonWebKey;
|
|
24
|
+
dpopNonce?: string;
|
|
25
|
+
issuer: string;
|
|
26
|
+
tokenEndpoint: string;
|
|
27
|
+
pds: string;
|
|
28
|
+
did: string;
|
|
29
|
+
handle?: string;
|
|
30
|
+
returnUrl: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A string env flag is truthy when it reads as "true"/"1"/"yes"/"on" (case-insensitive). */
|
|
34
|
+
function isEnvFlagTruthy(value: string | undefined): boolean {
|
|
35
|
+
return value !== undefined && ['true', '1', 'yes', 'on'].includes(value.trim().toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether the atproto provider is turned on. It has no client secret (public OAuth client), so it is
|
|
40
|
+
* enabled either by:
|
|
41
|
+
* - including its factory config key (`providers: { atproto: {} }`), or
|
|
42
|
+
* - setting the `ATPROTO_ENABLED` env var truthy (a per-deployment switch needing no code change).
|
|
43
|
+
* A factory `atproto: { enabled: false }` is an explicit opt-out that overrides the env flag, so a
|
|
44
|
+
* deployment can force the provider off regardless of the environment.
|
|
45
|
+
*/
|
|
46
|
+
export function isAtprotoEnabled(options?: ProviderOptions, env?: Pick<StartupAPIEnv, 'ATPROTO_ENABLED'>): boolean {
|
|
47
|
+
if (options?.enabled === false) return false; // explicit factory opt-out always wins
|
|
48
|
+
if (options !== undefined) return true; // present in the factory config
|
|
49
|
+
return isEnvFlagTruthy(env?.ATPROTO_ENABLED); // otherwise honor the per-deployment env toggle
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* AT Protocol (Atmosphere) authentication — works with any atproto PDS.
|
|
54
|
+
*
|
|
55
|
+
* Unlike the classic OAuth2 providers, atproto requires PKCE, DPoP-bound tokens, Pushed Authorization
|
|
56
|
+
* Requests (PAR), and per-user dynamic endpoints discovered from the identity (handle → DID → PDS →
|
|
57
|
+
* authorization server). It is a "public" OAuth client identified by a hosted client-metadata document
|
|
58
|
+
* (served at `…/auth/atproto/client-metadata.json`) rather than a client id/secret.
|
|
59
|
+
*/
|
|
60
|
+
export class AtprotoProvider extends OAuthProvider {
|
|
61
|
+
/** The public client id, i.e. the URL of this client's metadata document. */
|
|
62
|
+
private clientMetadataUrl = '';
|
|
63
|
+
private clientUri = '';
|
|
64
|
+
private clientName = 'StartupAPI';
|
|
65
|
+
private resolverOptions: ResolverOptions = {};
|
|
66
|
+
|
|
67
|
+
static create(env: StartupAPIEnv, redirectBase: string, options?: ProviderOptions): AtprotoProvider | null {
|
|
68
|
+
if (!isAtprotoEnabled(options, env)) return null;
|
|
69
|
+
const provider = new AtprotoProvider('', '', redirectBase + '/atproto/callback', 'atproto', options?.scopes);
|
|
70
|
+
provider.clientMetadataUrl = redirectBase + '/atproto/client-metadata.json';
|
|
71
|
+
provider.clientUri = new URL(redirectBase).origin;
|
|
72
|
+
provider.clientName = options?.clientName?.trim() || 'StartupAPI';
|
|
73
|
+
provider.resolverOptions = {
|
|
74
|
+
plcUrl: options?.plcUrl?.trim() || undefined,
|
|
75
|
+
dohUrl: options?.dohUrl?.trim() || undefined,
|
|
76
|
+
};
|
|
77
|
+
return provider;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getIcon(): string {
|
|
81
|
+
// Atmosphere (atproto) "union" logo mark, white on the brand-blue badge.
|
|
82
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
83
|
+
<circle cx="16" cy="16" r="16" fill="#4a8ad4"/>
|
|
84
|
+
<g transform="translate(3.2 3.2) scale(0.8)"><path fill-rule="evenodd" clip-rule="evenodd" d="${ATMOSPHERE_MARK_PATH}" fill="white"/></g>
|
|
85
|
+
</svg>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The OAuth client metadata document (public client, DPoP-bound tokens). */
|
|
89
|
+
getClientMetadata(): Record<string, unknown> {
|
|
90
|
+
return {
|
|
91
|
+
client_id: this.clientMetadataUrl,
|
|
92
|
+
client_name: this.clientName,
|
|
93
|
+
client_uri: this.clientUri,
|
|
94
|
+
redirect_uris: [this.redirectUri],
|
|
95
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
96
|
+
response_types: ['code'],
|
|
97
|
+
scope: this.buildScope(['atproto']),
|
|
98
|
+
token_endpoint_auth_method: 'none',
|
|
99
|
+
application_type: 'web',
|
|
100
|
+
dpop_bound_access_tokens: true,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async handleExtraRoute(ctx: AuthContext): Promise<Response | null> {
|
|
105
|
+
if (ctx.url.pathname === `${ctx.authPath}/atproto/client-metadata.json`) {
|
|
106
|
+
return Response.json(this.getClientMetadata());
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Authorization start. Requires an identifier (`?handle=`); without one we serve a small handle-entry
|
|
113
|
+
* form (the standard atproto UX) so we never hardcode any authorization server. With a handle we
|
|
114
|
+
* resolve the identity, run a DPoP-protected PAR, persist the flow state in an encrypted cookie, and
|
|
115
|
+
* redirect to the discovered authorization endpoint.
|
|
116
|
+
*/
|
|
117
|
+
async authorize(ctx: AuthContext): Promise<Response> {
|
|
118
|
+
const identifier = ctx.url.searchParams.get('handle');
|
|
119
|
+
const returnUrl = ctx.url.searchParams.get('return_url');
|
|
120
|
+
if (!identifier || !identifier.trim()) {
|
|
121
|
+
return this.renderHandleForm(ctx, returnUrl);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
return await this.startAuthorization(ctx, identifier, returnUrl);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
// The user supplied a handle and is still on our side of the redirect, so the most useful
|
|
128
|
+
// recovery is to re-render the entry form with the failure shown and their handle pre-filled,
|
|
129
|
+
// rather than a dead-end error page. (Callback-phase failures fall back to renderAuthError.)
|
|
130
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
131
|
+
return this.renderHandleForm(ctx, returnUrl, { error: message, handle: identifier });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Resolve the identity, run the DPoP-protected PAR, and redirect to the discovered auth endpoint. */
|
|
136
|
+
private async startAuthorization(ctx: AuthContext, identifier: string, returnUrl: string | null): Promise<Response> {
|
|
137
|
+
const identity = await resolveIdentity(identifier, this.resolverOptions);
|
|
138
|
+
const { verifier, challenge } = await generatePkce();
|
|
139
|
+
const dpopKey = await generateDpopKey();
|
|
140
|
+
const state = randomToken(16);
|
|
141
|
+
const scope = this.buildScope(['atproto']);
|
|
142
|
+
|
|
143
|
+
// Pushed Authorization Request: hand the request parameters to the authorization server up front
|
|
144
|
+
// and receive an opaque request_uri to send the user to. DPoP is required.
|
|
145
|
+
const parBody = new URLSearchParams({
|
|
146
|
+
client_id: this.clientMetadataUrl,
|
|
147
|
+
redirect_uri: this.redirectUri,
|
|
148
|
+
response_type: 'code',
|
|
149
|
+
code_challenge: challenge,
|
|
150
|
+
code_challenge_method: 'S256',
|
|
151
|
+
state,
|
|
152
|
+
scope,
|
|
153
|
+
login_hint: identity.handle ?? identity.did,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const { res, nonce } = await dpopFetch(
|
|
157
|
+
identity.authServer.pushed_authorization_request_endpoint,
|
|
158
|
+
{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: parBody.toString() },
|
|
159
|
+
dpopKey,
|
|
160
|
+
);
|
|
161
|
+
if (!res.ok) {
|
|
162
|
+
throw new Error(`Pushed authorization request failed: ${res.status} ${await res.text()}`);
|
|
163
|
+
}
|
|
164
|
+
const par = (await res.json()) as { request_uri: string };
|
|
165
|
+
|
|
166
|
+
const flow: AtprotoFlowState = {
|
|
167
|
+
state,
|
|
168
|
+
verifier,
|
|
169
|
+
dpopKey,
|
|
170
|
+
dpopNonce: nonce,
|
|
171
|
+
issuer: identity.authServer.issuer,
|
|
172
|
+
tokenEndpoint: identity.authServer.token_endpoint,
|
|
173
|
+
pds: identity.pds,
|
|
174
|
+
did: identity.did,
|
|
175
|
+
handle: identity.handle,
|
|
176
|
+
returnUrl,
|
|
177
|
+
};
|
|
178
|
+
const encrypted = await ctx.cookieManager.encrypt(JSON.stringify(flow));
|
|
179
|
+
|
|
180
|
+
const authUrl = new URL(identity.authServer.authorization_endpoint);
|
|
181
|
+
authUrl.searchParams.set('client_id', this.clientMetadataUrl);
|
|
182
|
+
authUrl.searchParams.set('request_uri', par.request_uri);
|
|
183
|
+
|
|
184
|
+
const headers = new Headers();
|
|
185
|
+
headers.set('Location', authUrl.toString());
|
|
186
|
+
headers.append(
|
|
187
|
+
'Set-Cookie',
|
|
188
|
+
`${FLOW_COOKIE}=${encrypted}; Path=${ctx.authPath}/atproto; HttpOnly; Secure; SameSite=Lax; Max-Age=${FLOW_TTL_SECONDS}`,
|
|
189
|
+
);
|
|
190
|
+
return new Response(null, { status: 302, headers });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Callback. Recover the encrypted flow state, validate `state`/`iss`, run the DPoP-protected token
|
|
195
|
+
* exchange against the discovered token endpoint, then resolve a profile from the user's PDS.
|
|
196
|
+
*/
|
|
197
|
+
async exchange(ctx: AuthContext): Promise<ExchangeResult> {
|
|
198
|
+
const errorParam = ctx.url.searchParams.get('error');
|
|
199
|
+
if (errorParam) {
|
|
200
|
+
throw new Error(`atproto authorization error: ${errorParam} ${ctx.url.searchParams.get('error_description') ?? ''}`.trim());
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const code = ctx.url.searchParams.get('code');
|
|
204
|
+
if (!code) {
|
|
205
|
+
const err = new Error('Missing code') as Error & { status?: number };
|
|
206
|
+
err.status = 400;
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const encrypted = readCookie(ctx.request.headers.get('Cookie'), FLOW_COOKIE);
|
|
211
|
+
if (!encrypted) throw new Error('Missing atproto flow state (cookie expired or blocked)');
|
|
212
|
+
const decrypted = await ctx.cookieManager.decrypt(encrypted);
|
|
213
|
+
const flow = decrypted ? (JSON.parse(decrypted) as AtprotoFlowState) : null;
|
|
214
|
+
if (!flow) throw new Error('Invalid atproto flow state');
|
|
215
|
+
|
|
216
|
+
if (flow.state !== ctx.url.searchParams.get('state')) throw new Error('State mismatch');
|
|
217
|
+
const iss = ctx.url.searchParams.get('iss');
|
|
218
|
+
if (iss && iss !== flow.issuer) throw new Error('Issuer mismatch');
|
|
219
|
+
|
|
220
|
+
const body = new URLSearchParams({
|
|
221
|
+
grant_type: 'authorization_code',
|
|
222
|
+
code,
|
|
223
|
+
redirect_uri: this.redirectUri,
|
|
224
|
+
client_id: this.clientMetadataUrl,
|
|
225
|
+
code_verifier: flow.verifier,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const { res } = await dpopFetch(
|
|
229
|
+
flow.tokenEndpoint,
|
|
230
|
+
{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() },
|
|
231
|
+
flow.dpopKey,
|
|
232
|
+
flow.dpopNonce,
|
|
233
|
+
);
|
|
234
|
+
if (!res.ok) {
|
|
235
|
+
throw new Error(`Token request failed: ${res.status} ${await res.text()}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const tokenData = (await res.json()) as OAuthTokenResponse & { sub?: string };
|
|
239
|
+
const did = tokenData.sub || flow.did;
|
|
240
|
+
const { name, picture } = await fetchProfile(flow.pds, did, flow.handle);
|
|
241
|
+
|
|
242
|
+
const profile: UserProfile = { id: did, name: name || flow.handle || did, picture };
|
|
243
|
+
const token: OAuthTokenResponse = {
|
|
244
|
+
access_token: tokenData.access_token,
|
|
245
|
+
refresh_token: tokenData.refresh_token,
|
|
246
|
+
expires_in: tokenData.expires_in,
|
|
247
|
+
scope: tokenData.scope,
|
|
248
|
+
token_type: tokenData.token_type,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const clearCookie = `${FLOW_COOKIE}=; Path=${ctx.authPath}/atproto; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;
|
|
252
|
+
return { token, profile, returnUrl: flow.returnUrl ?? null, setCookies: [clearCookie] };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Handle-entry page. Shown when the user starts the flow without an identifier, and re-shown when a
|
|
257
|
+
* supplied handle fails to authorize — in which case `options.error` renders an inline banner and
|
|
258
|
+
* `options.handle` pre-fills the input so the user can correct and retry without leaving the page.
|
|
259
|
+
*/
|
|
260
|
+
private renderHandleForm(
|
|
261
|
+
ctx: AuthContext,
|
|
262
|
+
returnUrl: string | null,
|
|
263
|
+
options: { error?: string; handle?: string } = {},
|
|
264
|
+
): Response {
|
|
265
|
+
const action = `${ctx.authPath}/atproto`;
|
|
266
|
+
const returnField = returnUrl ? `<input type="hidden" name="return_url" value="${escapeHtml(returnUrl)}" />` : '';
|
|
267
|
+
const errorBanner = options.error ? `<div class="error" role="alert">${escapeHtml(options.error)}</div>` : '';
|
|
268
|
+
const handleValue = options.handle ? ` value="${escapeHtml(options.handle)}"` : '';
|
|
269
|
+
const status = options.error ? 400 : 200;
|
|
270
|
+
const stylesheet = `${escapeHtml(ctx.usersPath)}style.css`;
|
|
271
|
+
const html = `<!doctype html>
|
|
272
|
+
<html lang="en">
|
|
273
|
+
<head>
|
|
274
|
+
<meta charset="utf-8" />
|
|
275
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
276
|
+
<title>Login with your Atmosphere account</title>
|
|
277
|
+
<link rel="stylesheet" href="${stylesheet}" />
|
|
278
|
+
<style>
|
|
279
|
+
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); }
|
|
280
|
+
.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: 23rem; max-width: 100%; box-sizing: border-box; }
|
|
281
|
+
.auth-card h1 { font-size: 1rem; margin: 0 0 1rem; color: var(--text); white-space: nowrap; }
|
|
282
|
+
.auth-card label { display: block; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.35rem; }
|
|
283
|
+
.auth-card input[type=text] { width: 100%; box-sizing: border-box; padding: 0.6rem 0.7rem; border: 1px solid var(--border); border-radius: 8px; font-size: 0.95rem; background: var(--surface); color: var(--text); }
|
|
284
|
+
.auth-card input[type=text]:focus { outline: none; border-color: #4a8ad4; }
|
|
285
|
+
.auth-card button { margin-top: 1rem; width: 100%; padding: 0.65rem; border: 0; border-radius: 8px; background: #4a8ad4; color: #fff; font-size: 0.95rem; cursor: pointer; }
|
|
286
|
+
.auth-card button:hover { background: #3d77ba; }
|
|
287
|
+
.auth-card p { font-size: 0.8rem; color: var(--text-muted); margin: 0.75rem 0 0; }
|
|
288
|
+
.auth-card .error { background: var(--danger-soft-bg); border: 1px solid var(--danger); color: var(--danger); border-radius: 8px; padding: 0.6rem 0.7rem; font-size: 0.82rem; margin-bottom: 1rem; word-break: break-word; }
|
|
289
|
+
</style>
|
|
290
|
+
</head>
|
|
291
|
+
<body>
|
|
292
|
+
<form class="auth-card" method="GET" action="${escapeHtml(action)}">
|
|
293
|
+
<h1>Login with your Atmosphere account</h1>
|
|
294
|
+
${errorBanner}
|
|
295
|
+
<label for="handle">Your handle or DID</label>
|
|
296
|
+
<input type="text" id="handle" name="handle" placeholder="alice.bsky.social"${handleValue} autocomplete="username" autofocus required />
|
|
297
|
+
${returnField}
|
|
298
|
+
<button type="submit">Continue</button>
|
|
299
|
+
<p>Enter your atproto handle (e.g. alice.bsky.social) or DID. Your account's own server handles the login.</p>
|
|
300
|
+
</form>
|
|
301
|
+
</body>
|
|
302
|
+
</html>`;
|
|
303
|
+
return new Response(html, {
|
|
304
|
+
status,
|
|
305
|
+
headers: {
|
|
306
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
307
|
+
// Same hardening as the auth error page: only same-origin styles/form, never framed, never cached
|
|
308
|
+
// (the re-rendered form reflects the user-supplied handle).
|
|
309
|
+
'Content-Security-Policy':
|
|
310
|
+
"default-src 'none'; style-src 'self' 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
311
|
+
'X-Content-Type-Options': 'nosniff',
|
|
312
|
+
'Referrer-Policy': 'no-referrer',
|
|
313
|
+
'Cache-Control': 'no-store',
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Read a single cookie value from a Cookie header, or undefined. */
|
|
320
|
+
function readCookie(header: string | null, name: string): string | undefined {
|
|
321
|
+
if (!header) return undefined;
|
|
322
|
+
for (const part of header.split(';')) {
|
|
323
|
+
const [key, ...rest] = part.split('=');
|
|
324
|
+
if (key.trim() === name) return rest.join('=').trim();
|
|
325
|
+
}
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
@@ -16,6 +16,50 @@ export interface UserProfile {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
import type { Entitlements } from '../entitlements/types';
|
|
19
|
+
import type { StartupAPIEnv } from '../StartupAPIEnv';
|
|
20
|
+
import type { CookieManager } from '../CookieManager';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Per-request context handed to a provider's flow hooks. Carries everything needed to run an
|
|
24
|
+
* authorization start or callback without the provider reaching back into the router.
|
|
25
|
+
*/
|
|
26
|
+
export interface AuthContext {
|
|
27
|
+
request: Request;
|
|
28
|
+
env: StartupAPIEnv;
|
|
29
|
+
url: URL;
|
|
30
|
+
/** Base URL provider redirect/callback URIs are built from, e.g. `https://host/users/auth`. */
|
|
31
|
+
redirectBase: string;
|
|
32
|
+
/** Pathname of `redirectBase`, e.g. `/users/auth`. */
|
|
33
|
+
authPath: string;
|
|
34
|
+
/** Configured users path, e.g. `/users/`. */
|
|
35
|
+
usersPath: string;
|
|
36
|
+
/** Effective origin (AUTH_ORIGIN override or request origin). */
|
|
37
|
+
origin: string;
|
|
38
|
+
cookieManager: CookieManager;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Result of a successful callback exchange: the token, the resolved user profile, where to send the
|
|
43
|
+
* user next, and any extra cookies to emit (e.g. clearing transient flow state).
|
|
44
|
+
*/
|
|
45
|
+
export interface ExchangeResult {
|
|
46
|
+
token: OAuthTokenResponse;
|
|
47
|
+
profile: UserProfile;
|
|
48
|
+
returnUrl: string | null;
|
|
49
|
+
setCookies?: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Decode the base64url state param used by the standard flow back into its return_url. */
|
|
53
|
+
function parseReturnUrl(stateBase64: string | null): string | null {
|
|
54
|
+
if (!stateBase64) return null;
|
|
55
|
+
try {
|
|
56
|
+
const base64 = stateBase64.replace(/-/g, '+').replace(/_/g, '/');
|
|
57
|
+
const stateJson = decodeURIComponent(escape(atob(base64)));
|
|
58
|
+
return JSON.parse(stateJson).return_url ?? null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
19
63
|
|
|
20
64
|
export abstract class OAuthProvider {
|
|
21
65
|
protected clientId: string;
|
|
@@ -58,10 +102,66 @@ export abstract class OAuthProvider {
|
|
|
58
102
|
return path === `${authBasePath}/${this.name}/callback`;
|
|
59
103
|
}
|
|
60
104
|
|
|
61
|
-
abstract getAuthUrl(state: string): string;
|
|
62
105
|
abstract getIcon(): string;
|
|
63
|
-
|
|
64
|
-
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Simple OAuth2 hooks used by the default {@link authorize}/{@link exchange}. Providers whose flow
|
|
109
|
+
* fits the classic "redirect → code → token → profile" shape implement these. Providers with a
|
|
110
|
+
* heavier flow (e.g. atproto's PKCE/DPoP/PAR) instead override {@link authorize}/{@link exchange}
|
|
111
|
+
* and may leave these as the throwing defaults.
|
|
112
|
+
*/
|
|
113
|
+
getAuthUrl(_state: string): string {
|
|
114
|
+
throw new Error(`${this.name}: getAuthUrl is not implemented`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async getToken(_code: string): Promise<OAuthTokenResponse> {
|
|
118
|
+
throw new Error(`${this.name}: getToken is not implemented`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async getUserProfile(_token: string): Promise<UserProfile> {
|
|
122
|
+
throw new Error(`${this.name}: getUserProfile is not implemented`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Begin the authorization flow. Default: build a base64url `state` (nonce + return_url) and redirect
|
|
127
|
+
* to {@link getAuthUrl}. Providers needing async setup, server-side flow state, or custom request
|
|
128
|
+
* shapes override this and return their own Response.
|
|
129
|
+
*/
|
|
130
|
+
async authorize(ctx: AuthContext): Promise<Response> {
|
|
131
|
+
const returnUrl = ctx.url.searchParams.get('return_url');
|
|
132
|
+
const stateObj = { nonce: Math.random().toString(36).substring(2), return_url: returnUrl };
|
|
133
|
+
const state = btoa(unescape(encodeURIComponent(JSON.stringify(stateObj))))
|
|
134
|
+
.replace(/\+/g, '-')
|
|
135
|
+
.replace(/\//g, '_')
|
|
136
|
+
.replace(/=+$/, '');
|
|
137
|
+
return Response.redirect(this.getAuthUrl(state), 302);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Exchange a callback for a token and resolved profile. Default: read `code`, recover the return_url
|
|
142
|
+
* from `state`, then {@link getToken} + {@link getUserProfile}. Providers override this for custom
|
|
143
|
+
* token exchanges (PKCE/DPoP, dynamic endpoints, etc.).
|
|
144
|
+
*/
|
|
145
|
+
async exchange(ctx: AuthContext): Promise<ExchangeResult> {
|
|
146
|
+
const code = ctx.url.searchParams.get('code');
|
|
147
|
+
if (!code) {
|
|
148
|
+
const err = new Error('Missing code') as Error & { status?: number };
|
|
149
|
+
err.status = 400;
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
const returnUrl = parseReturnUrl(ctx.url.searchParams.get('state'));
|
|
153
|
+
const token = await this.getToken(code);
|
|
154
|
+
const profile = await this.getUserProfile(token.access_token);
|
|
155
|
+
return { token, profile, returnUrl };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Serve any provider-specific auxiliary GET routes mounted under the auth base path (e.g. the atproto
|
|
160
|
+
* client-metadata document). Default: not a provider route → null, so the router moves on.
|
|
161
|
+
*/
|
|
162
|
+
async handleExtraRoute(_ctx: AuthContext): Promise<Response | null> {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
65
165
|
|
|
66
166
|
/**
|
|
67
167
|
* Whether this provider produces entitlements (memberships / perks). Providers that gate access on
|