@startup-api/cloudflare 0.0.1 → 0.2.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 +120 -0
- package/package.json +1 -1
- package/public/users/power-strip.js +32 -2
- package/public/users/profile.html +4 -0
- package/src/PowerStrip.ts +33 -9
- package/src/StartupAPIEnv.ts +3 -0
- package/src/auth/GoogleProvider.ts +10 -3
- package/src/auth/OAuthProvider.ts +48 -1
- package/src/auth/PatreonProvider.ts +124 -0
- package/src/auth/TwitchProvider.ts +10 -3
- package/src/auth/index.ts +31 -9
- package/src/auth/providers.ts +39 -0
- package/src/createStartupAPI.ts +322 -0
- package/src/entitlements/cron.ts +37 -0
- package/src/entitlements/patreon.ts +85 -0
- package/src/entitlements/service.ts +98 -0
- package/src/entitlements/tokenManager.ts +67 -0
- package/src/entitlements/types.ts +32 -0
- package/src/handlers/ssr.ts +2 -0
- package/src/handlers/utils.ts +3 -0
- package/src/index.ts +22 -190
- package/src/policy/accessPolicy.ts +106 -0
- package/src/policy/entitlementCheckers.ts +36 -0
- package/src/schemas/config.ts +50 -0
- package/src/schemas/entitlement.ts +24 -0
- package/src/schemas/policy.ts +62 -0
- package/src/storage/CredentialDO.ts +50 -4
- package/src/storage/UserDO.ts +36 -0
- package/src/webhooks/md5hmac.ts +140 -0
- package/src/webhooks/patreon.ts +61 -0
- package/worker-configuration.d.ts +10 -1
package/README.md
CHANGED
|
@@ -67,6 +67,11 @@ Use this option if you want to deploy from your local machine.
|
|
|
67
67
|
| `GOOGLE_CLIENT_SECRET` | No | N/A | Google OAuth2 Client Secret |
|
|
68
68
|
| `TWITCH_CLIENT_ID` | No | N/A | Twitch OAuth2 Client ID |
|
|
69
69
|
| `TWITCH_CLIENT_SECRET` | No | N/A | Twitch OAuth2 Client Secret |
|
|
70
|
+
| `PATREON_CLIENT_ID` | No | N/A | Patreon OAuth2 Client ID |
|
|
71
|
+
| `PATREON_CLIENT_SECRET`| No | N/A | Patreon OAuth2 Client Secret |
|
|
72
|
+
| `PATREON_WEBHOOK_SECRET`| No | N/A | Secret for verifying Patreon webhook signatures |
|
|
73
|
+
|
|
74
|
+
> 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)).
|
|
70
75
|
|
|
71
76
|
### Setting up OAuth
|
|
72
77
|
|
|
@@ -88,6 +93,28 @@ Use this option if you want to deploy from your local machine.
|
|
|
88
93
|
4. Select **Website** as the category
|
|
89
94
|
5. Copy the **Client ID** and generate a **Client Secret** to add them to your Worker's environment variables
|
|
90
95
|
|
|
96
|
+
#### Patreon
|
|
97
|
+
|
|
98
|
+
1. Go to the [Patreon Developer Portal](https://www.patreon.com/portal/registration/register-clients)
|
|
99
|
+
2. Click **Create Client** and fill in your app details
|
|
100
|
+
3. Add your authorized redirect URI: `https://<your-worker-url>/users/auth/patreon/callback`
|
|
101
|
+
4. Copy the **Client ID** and **Client Secret** and add them to your Worker's environment variables
|
|
102
|
+
|
|
103
|
+
#### Requesting additional scopes
|
|
104
|
+
|
|
105
|
+
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:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { createStartupAPI } from '@startup-api/cloudflare';
|
|
109
|
+
|
|
110
|
+
const api = createStartupAPI({
|
|
111
|
+
providers: {
|
|
112
|
+
// Adds `identity.memberships` on top of the base `identity identity[email]`
|
|
113
|
+
patreon: { scopes: 'identity.memberships' },
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
91
118
|
### Example `wrangler.jsonc` snippet:
|
|
92
119
|
|
|
93
120
|
```json
|
|
@@ -105,6 +132,99 @@ Use this option if you want to deploy from your local machine.
|
|
|
105
132
|
3. **Proxying:** All other requests are proxied to the configured `ORIGIN_URL`
|
|
106
133
|
4. **Injection:** For `text/html` responses, the worker injects a `<script>` tag and a `<power-strip>` custom element before serving the content to the user
|
|
107
134
|
|
|
135
|
+
### Customizing the power strip
|
|
136
|
+
|
|
137
|
+
By default the worker injects its own `<power-strip>` pinned to the top-right corner of the page. If that overlaps your own menu or you simply want it somewhere else, **place a `<power-strip>` element in your own HTML**. When the worker sees one, it injects only `power-strip.js` (which defines the custom element) and leaves your element exactly where you put it — so you control placement and styling:
|
|
138
|
+
|
|
139
|
+
```html
|
|
140
|
+
<nav>
|
|
141
|
+
<!-- ...your links... -->
|
|
142
|
+
<power-strip></power-strip>
|
|
143
|
+
</nav>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- **`providers` is optional.** If you omit it, the worker fills in the active providers for you (e.g. `providers="google,twitch,patreon"`). Set it yourself to override which login buttons appear.
|
|
147
|
+
- **Prefer an explicit closing tag.** `<power-strip></power-strip>` and `<power-strip/>` are both detected, but per the HTML spec `<power-strip/>` is *not* truly self-closing — the browser treats it as an open tag and nests the following content inside it. Use a closing tag (or place the element last in its container) to avoid surprises.
|
|
148
|
+
- **Script-only opt-out.** Use `<power-strip hidden>` to load `power-strip.js` (and its JS API) without rendering a visible strip.
|
|
149
|
+
|
|
150
|
+
## Access policy & provider entitlements
|
|
151
|
+
|
|
152
|
+
StartupAPI can gate access to paths and forward the visitor's login/entitlement status to your origin so it can render gated UI. This is **provider-agnostic infrastructure**; only Patreon currently implements perk-level (benefit/tier) checks — Google and Twitch participate at the login levels only.
|
|
153
|
+
|
|
154
|
+
### Path-based access policy
|
|
155
|
+
|
|
156
|
+
Configure an ordered list of rules (first match wins) mapping a path pattern to a requirement, plus a `default` for unmatched paths. Requirement modes:
|
|
157
|
+
|
|
158
|
+
- **`bypass`** — raw pass-through: no credential check, no identity resolution, no headers, no power-strip injection.
|
|
159
|
+
- **`public`** — anyone; the session is resolved and identity/entitlement headers are forwarded when present.
|
|
160
|
+
- **`authenticated`** — any logged-in user.
|
|
161
|
+
- **`entitlement`** — a provider condition: Patreon `active_patron`, a specific `benefit` (perk) ID, or a `tier` ID.
|
|
162
|
+
|
|
163
|
+
Patterns are exact (`/special`), prefix (`/app/*`), or `/` (homepage only). Each rule's `on_unauthorized` is `login` (redirect to sign in), `forbidden` (403), or `upgrade` (redirect to `upgrade_url`, e.g. a Patreon join page). When no policy is configured at all, every path is treated as `public` (backward compatible).
|
|
164
|
+
|
|
165
|
+
Admin users (those listed in `ADMIN_IDS`) bypass every `authenticated`/`entitlement` requirement and can reach any gated path. Their identity is still resolved and the usual identity/entitlement headers are forwarded to the origin — only the gate itself is skipped. (`bypass` paths remain a raw pass-through for everyone, with no identity resolution.)
|
|
166
|
+
|
|
167
|
+
The policy is passed to the factory as `accessPolicy` (see below). Example:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
const accessPolicy = {
|
|
171
|
+
rules: [
|
|
172
|
+
{ pattern: '/', requirement: { mode: 'public' } },
|
|
173
|
+
{
|
|
174
|
+
pattern: '/special',
|
|
175
|
+
requirement: { mode: 'entitlement', provider: 'patreon', condition: { type: 'benefit', benefit_id: '<BENEFIT_ID>' } },
|
|
176
|
+
on_unauthorized: 'upgrade',
|
|
177
|
+
upgrade_url: 'https://www.patreon.com/yourpage',
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
default: { mode: 'entitlement', provider: 'patreon', condition: { type: 'active_patron' } },
|
|
181
|
+
};
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Headers forwarded to the origin
|
|
185
|
+
|
|
186
|
+
For non-`bypass` paths the worker forwards `X-StartupAPI-Authenticated`, `X-StartupAPI-Login-Provider`, a compact `X-StartupAPI-Entitlements` JSON, and (for Patreon) `X-StartupAPI-Patreon-Active` / `-Tiers` / `-Benefits` alongside the existing `X-StartupAPI-User-Id` / `-Account-Id`.
|
|
187
|
+
|
|
188
|
+
### Keeping entitlements fresh
|
|
189
|
+
|
|
190
|
+
Entitlements are fetched once at login. Each provider can additionally opt into freshness mechanisms in its factory config (all off by default — if none are enabled, entitlements are only checked at login):
|
|
191
|
+
|
|
192
|
+
- **TTL** — lazily re-check on the request path when older than the TTL (`freshness.ttl: { ms }`, default 15 min), using the OAuth refresh token.
|
|
193
|
+
- **Cron** — a scheduled re-sync of all of a provider's credentials (`freshness.cron: { schedule }`). The `scheduled()` handler is only present when at least one provider enables cron; you must also add a matching `triggers.crons` to your wrangler config.
|
|
194
|
+
- **Webhook** (Patreon only) — set `freshness.webhook: true`, provide `PATREON_WEBHOOK_SECRET` (a secret, in env), and point a Patreon webhook at `<your-worker-url>/users/webhooks/patreon` (signature verified with HMAC-MD5).
|
|
195
|
+
|
|
196
|
+
Set `providers.patreon.campaignId` to disambiguate when a user belongs to multiple campaigns.
|
|
197
|
+
|
|
198
|
+
### Configuring via the factory
|
|
199
|
+
|
|
200
|
+
Environment variables hold only credentials/secrets and the per-deployment values (`ORIGIN_URL`, `AUTH_ORIGIN`, `USERS_PATH`, `ADMIN_IDS`, `ENVIRONMENT`). Everything else — provider scopes, Patreon campaign id, the access policy, and entitlement freshness — is passed to `createStartupAPI(config)`. The plain re-export still works with defaults:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
// Defaults — unchanged:
|
|
204
|
+
export { default, UserDO, AccountDO, SystemDO, CredentialDO } from '@startup-api/cloudflare';
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
// Custom configuration:
|
|
209
|
+
import { createStartupAPI } from '@startup-api/cloudflare';
|
|
210
|
+
|
|
211
|
+
const api = createStartupAPI({
|
|
212
|
+
providers: {
|
|
213
|
+
patreon: {
|
|
214
|
+
scopes: 'identity.memberships',
|
|
215
|
+
campaignId: '<CAMPAIGN_ID>',
|
|
216
|
+
freshness: { ttl: true, cron: { schedule: '0 */6 * * *' }, webhook: true },
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
accessPolicy: { rules: [/* ... */], default: { mode: 'public' } },
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
export default api.default; // includes scheduled() because cron is enabled
|
|
223
|
+
export const { UserDO, AccountDO, SystemDO, CredentialDO } = api;
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
(Remember to add `triggers.crons` to your wrangler config when enabling cron.)
|
|
227
|
+
|
|
108
228
|
## Contributing
|
|
109
229
|
|
|
110
230
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
package/package.json
CHANGED
|
@@ -93,6 +93,8 @@ class PowerStrip extends HTMLElement {
|
|
|
93
93
|
</svg>`;
|
|
94
94
|
} else if (provider === 'twitch') {
|
|
95
95
|
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>`;
|
|
96
|
+
} else if (provider === 'patreon') {
|
|
97
|
+
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>`;
|
|
96
98
|
}
|
|
97
99
|
return '';
|
|
98
100
|
}
|
|
@@ -101,6 +103,7 @@ class PowerStrip extends HTMLElement {
|
|
|
101
103
|
const returnUrl = encodeURIComponent(window.location.href);
|
|
102
104
|
const googleLink = `${this.basePath}/auth/google?return_url=${returnUrl}`;
|
|
103
105
|
const twitchLink = `${this.basePath}/auth/twitch?return_url=${returnUrl}`;
|
|
106
|
+
const patreonLink = `${this.basePath}/auth/patreon?return_url=${returnUrl}`;
|
|
104
107
|
const logoutLink = `${this.basePath}/logout?return_url=${returnUrl}`;
|
|
105
108
|
|
|
106
109
|
const providersStr = this.getAttribute('providers') || '';
|
|
@@ -128,6 +131,15 @@ class PowerStrip extends HTMLElement {
|
|
|
128
131
|
Continue with Twitch
|
|
129
132
|
</a>`;
|
|
130
133
|
}
|
|
134
|
+
if (providers.includes('patreon')) {
|
|
135
|
+
authButtons += `
|
|
136
|
+
<a href="${patreonLink}" class="auth-btn patreon">
|
|
137
|
+
<svg viewBox="0 0 24 24">
|
|
138
|
+
<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"/>
|
|
139
|
+
</svg>
|
|
140
|
+
Continue with Patreon
|
|
141
|
+
</a>`;
|
|
142
|
+
}
|
|
131
143
|
|
|
132
144
|
let content = '';
|
|
133
145
|
let accountSwitcher = '';
|
|
@@ -240,7 +252,14 @@ class PowerStrip extends HTMLElement {
|
|
|
240
252
|
display: block;
|
|
241
253
|
font-family: system-ui, -apple-system, sans-serif;
|
|
242
254
|
}
|
|
243
|
-
|
|
255
|
+
|
|
256
|
+
/* Honor the native [hidden] attribute so authors can load the script
|
|
257
|
+
without rendering a visible strip (<power-strip hidden>). The :host
|
|
258
|
+
rule above would otherwise override the UA [hidden] { display: none }. */
|
|
259
|
+
:host([hidden]) {
|
|
260
|
+
display: none !important;
|
|
261
|
+
}
|
|
262
|
+
|
|
244
263
|
@keyframes fadeIn {
|
|
245
264
|
from { opacity: 0; }
|
|
246
265
|
to { opacity: 1; }
|
|
@@ -329,6 +348,7 @@ class PowerStrip extends HTMLElement {
|
|
|
329
348
|
|
|
330
349
|
.provider-badge.google { color: #3c4043; }
|
|
331
350
|
.provider-badge.twitch { color: #9146FF; }
|
|
351
|
+
.provider-badge.patreon { color: #FF424D; }
|
|
332
352
|
|
|
333
353
|
.user-info {
|
|
334
354
|
display: flex;
|
|
@@ -506,7 +526,17 @@ class PowerStrip extends HTMLElement {
|
|
|
506
526
|
background-color: #7d2ee6;
|
|
507
527
|
border-color: #7d2ee6;
|
|
508
528
|
}
|
|
509
|
-
|
|
529
|
+
|
|
530
|
+
.auth-btn.patreon {
|
|
531
|
+
background-color: #FF424D;
|
|
532
|
+
color: white;
|
|
533
|
+
border-color: #FF424D;
|
|
534
|
+
}
|
|
535
|
+
.auth-btn.patreon:hover {
|
|
536
|
+
background-color: #e63a44;
|
|
537
|
+
border-color: #e63a44;
|
|
538
|
+
}
|
|
539
|
+
|
|
510
540
|
/* Account Switcher Styling */
|
|
511
541
|
.account-list {
|
|
512
542
|
display: flex;
|
|
@@ -432,6 +432,10 @@
|
|
|
432
432
|
return `<svg viewBox="0 0 24 24" width="24" height="24" style="color: #9146FF;">
|
|
433
433
|
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714z" fill="currentColor"/>
|
|
434
434
|
</svg>`;
|
|
435
|
+
} else if (provider === 'patreon') {
|
|
436
|
+
return `<svg viewBox="0 0 24 24" width="24" height="24" style="color: #FF424D;">
|
|
437
|
+
<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"/>
|
|
438
|
+
</svg>`;
|
|
435
439
|
}
|
|
436
440
|
return '';
|
|
437
441
|
}
|
package/src/PowerStrip.ts
CHANGED
|
@@ -2,18 +2,42 @@ export async function injectPowerStrip(response: Response, usersPath: string, pr
|
|
|
2
2
|
const contentType = response.headers.get('Content-Type');
|
|
3
3
|
|
|
4
4
|
if (contentType && contentType.includes('text/html')) {
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
const providersAttr = providers.join(',');
|
|
6
|
+
|
|
7
|
+
// Track whether the page author placed their own <power-strip> element.
|
|
8
|
+
// If they did, we respect their placement/styling and only load the script.
|
|
9
|
+
let hasUserPowerStrip = false;
|
|
10
|
+
|
|
7
11
|
return new HTMLRewriter()
|
|
12
|
+
.on('power-strip', {
|
|
13
|
+
element(element) {
|
|
14
|
+
hasUserPowerStrip = true;
|
|
15
|
+
|
|
16
|
+
// Fill in the active providers for the author so their element works
|
|
17
|
+
// out of the box, unless they explicitly chose their own list.
|
|
18
|
+
if (!element.hasAttribute('providers')) {
|
|
19
|
+
element.setAttribute('providers', providersAttr);
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
})
|
|
8
23
|
.on('body', {
|
|
9
24
|
element(element) {
|
|
10
|
-
element.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
)
|
|
25
|
+
// The script is always needed to define the <power-strip> custom element.
|
|
26
|
+
// It is loaded from the USERS_PATH, which is intercepted by this worker.
|
|
27
|
+
element.prepend(`<script src="${usersPath}power-strip.js" async></script>`, { html: true });
|
|
28
|
+
|
|
29
|
+
// Defer the component decision until the end of <body>, by which point
|
|
30
|
+
// the streaming parser has seen any author-supplied <power-strip>.
|
|
31
|
+
element.onEndTag((end) => {
|
|
32
|
+
if (!hasUserPowerStrip) {
|
|
33
|
+
end.before(
|
|
34
|
+
`<power-strip providers="${providersAttr}" style="position: absolute; top: 0; right: 0; z-index: 9999; padding: 0.1rem; border-radius: 0 0 0 0.3rem;">` +
|
|
35
|
+
'<svg viewBox="0 0 24 24" style="width: 1rem; height: 1rem;"><path d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>' +
|
|
36
|
+
'</power-strip>',
|
|
37
|
+
{ html: true },
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
17
41
|
},
|
|
18
42
|
})
|
|
19
43
|
.transform(response);
|
package/src/StartupAPIEnv.ts
CHANGED
|
@@ -6,6 +6,9 @@ export type StartupAPIEnv = {
|
|
|
6
6
|
GOOGLE_CLIENT_SECRET: string;
|
|
7
7
|
TWITCH_CLIENT_ID: string;
|
|
8
8
|
TWITCH_CLIENT_SECRET: string;
|
|
9
|
+
PATREON_CLIENT_ID: string;
|
|
10
|
+
PATREON_CLIENT_SECRET: string;
|
|
11
|
+
PATREON_WEBHOOK_SECRET?: string;
|
|
9
12
|
ADMIN_IDS: string;
|
|
10
13
|
SESSION_SECRET: string;
|
|
11
14
|
ENVIRONMENT?: string;
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import type { StartupAPIEnv } from '../StartupAPIEnv';
|
|
2
|
+
import type { ProviderOptions } from '../schemas/config';
|
|
2
3
|
|
|
3
4
|
import { OAuthProvider, OAuthTokenResponse, UserProfile } from './OAuthProvider';
|
|
4
5
|
|
|
5
6
|
export class GoogleProvider extends OAuthProvider {
|
|
6
|
-
static create(env: StartupAPIEnv, redirectBase: string): GoogleProvider | null {
|
|
7
|
+
static create(env: StartupAPIEnv, redirectBase: string, options?: ProviderOptions): GoogleProvider | null {
|
|
7
8
|
if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET) return null;
|
|
8
|
-
return new GoogleProvider(
|
|
9
|
+
return new GoogleProvider(
|
|
10
|
+
env.GOOGLE_CLIENT_ID,
|
|
11
|
+
env.GOOGLE_CLIENT_SECRET,
|
|
12
|
+
redirectBase + '/google/callback',
|
|
13
|
+
'google',
|
|
14
|
+
options?.scopes,
|
|
15
|
+
);
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
getAuthUrl(state: string): string {
|
|
@@ -13,7 +20,7 @@ export class GoogleProvider extends OAuthProvider {
|
|
|
13
20
|
client_id: this.clientId,
|
|
14
21
|
redirect_uri: this.redirectUri,
|
|
15
22
|
response_type: 'code',
|
|
16
|
-
scope: 'openid email profile',
|
|
23
|
+
scope: this.buildScope(['openid', 'email', 'profile']),
|
|
17
24
|
state: state,
|
|
18
25
|
access_type: 'offline',
|
|
19
26
|
prompt: 'consent',
|
|
@@ -15,17 +15,39 @@ export interface UserProfile {
|
|
|
15
15
|
verified_email?: boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
import type { Entitlements } from '../entitlements/types';
|
|
19
|
+
|
|
18
20
|
export abstract class OAuthProvider {
|
|
19
21
|
protected clientId: string;
|
|
20
22
|
protected clientSecret: string;
|
|
21
23
|
protected redirectUri: string;
|
|
22
24
|
public name: string;
|
|
25
|
+
protected additionalScopes: string[];
|
|
23
26
|
|
|
24
|
-
constructor(clientId: string, clientSecret: string, redirectUri: string, name: string) {
|
|
27
|
+
constructor(clientId: string, clientSecret: string, redirectUri: string, name: string, additionalScopes: string | string[] = []) {
|
|
25
28
|
this.clientId = clientId.trim();
|
|
26
29
|
this.clientSecret = clientSecret.trim();
|
|
27
30
|
this.redirectUri = redirectUri.trim();
|
|
28
31
|
this.name = name.trim();
|
|
32
|
+
// Accept a single scope string or a list; buildScope() trims and dedupes.
|
|
33
|
+
this.additionalScopes = Array.isArray(additionalScopes) ? additionalScopes : [additionalScopes];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Merge a provider's required base scopes with the configured additional scopes,
|
|
38
|
+
* preserving order and removing duplicates.
|
|
39
|
+
*/
|
|
40
|
+
protected buildScope(defaultScopes: string[], separator = ' '): string {
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
const merged: string[] = [];
|
|
43
|
+
for (const scope of [...defaultScopes, ...this.additionalScopes]) {
|
|
44
|
+
const trimmed = scope.trim();
|
|
45
|
+
if (trimmed && !seen.has(trimmed)) {
|
|
46
|
+
seen.add(trimmed);
|
|
47
|
+
merged.push(trimmed);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return merged.join(separator);
|
|
29
51
|
}
|
|
30
52
|
|
|
31
53
|
isMatch(path: string, authBasePath: string): boolean {
|
|
@@ -41,6 +63,31 @@ export abstract class OAuthProvider {
|
|
|
41
63
|
abstract getToken(code: string): Promise<OAuthTokenResponse>;
|
|
42
64
|
abstract getUserProfile(token: string): Promise<UserProfile>;
|
|
43
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Whether this provider produces entitlements (memberships / perks). Providers that gate access on
|
|
68
|
+
* provider-specific conditions (e.g. Patreon) override this to return true. Default: false.
|
|
69
|
+
*/
|
|
70
|
+
supportsEntitlements(): boolean {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fetch the user's current entitlements using a valid access token. Returns the provider-specific
|
|
76
|
+
* portion of the {@link Entitlements} shape (without `checked_at`/`source`, which the caller stamps).
|
|
77
|
+
* Default: null (provider has no entitlements).
|
|
78
|
+
*/
|
|
79
|
+
async fetchEntitlements(_accessToken: string): Promise<Partial<Entitlements> | null> {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Exchange a refresh token for a fresh access token. Providers that issue refresh tokens override
|
|
85
|
+
* this. Default: null (no refresh support).
|
|
86
|
+
*/
|
|
87
|
+
async refreshToken(_refreshToken: string): Promise<OAuthTokenResponse | null> {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
44
91
|
protected async fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
|
45
92
|
const response = await fetch(url, options);
|
|
46
93
|
if (!response.ok) {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { StartupAPIEnv } from '../StartupAPIEnv';
|
|
2
|
+
import type { ProviderOptions } from '../schemas/config';
|
|
3
|
+
|
|
4
|
+
import { OAuthProvider, OAuthTokenResponse, UserProfile } from './OAuthProvider';
|
|
5
|
+
import type { Entitlements } from '../entitlements/types';
|
|
6
|
+
import { parsePatreonIdentity } from '../entitlements/patreon';
|
|
7
|
+
|
|
8
|
+
export class PatreonProvider extends OAuthProvider {
|
|
9
|
+
private campaignId?: string;
|
|
10
|
+
|
|
11
|
+
static create(env: StartupAPIEnv, redirectBase: string, options?: ProviderOptions): PatreonProvider | null {
|
|
12
|
+
if (!env.PATREON_CLIENT_ID || !env.PATREON_CLIENT_SECRET) return null;
|
|
13
|
+
const provider = new PatreonProvider(
|
|
14
|
+
env.PATREON_CLIENT_ID,
|
|
15
|
+
env.PATREON_CLIENT_SECRET,
|
|
16
|
+
redirectBase + '/patreon/callback',
|
|
17
|
+
'patreon',
|
|
18
|
+
options?.scopes,
|
|
19
|
+
);
|
|
20
|
+
provider.campaignId = options?.campaignId?.trim() || undefined;
|
|
21
|
+
return provider;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
supportsEntitlements(): boolean {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getAuthUrl(state: string): string {
|
|
29
|
+
const params = new URLSearchParams({
|
|
30
|
+
client_id: this.clientId,
|
|
31
|
+
redirect_uri: this.redirectUri,
|
|
32
|
+
response_type: 'code',
|
|
33
|
+
scope: this.buildScope(['identity', 'identity[email]']),
|
|
34
|
+
state: state,
|
|
35
|
+
});
|
|
36
|
+
return `https://www.patreon.com/oauth2/authorize?${params.toString()}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getIcon(): string {
|
|
40
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
41
|
+
<circle cx="12" cy="12" r="11" fill="#FF424D" stroke="white" stroke-width="1"/>
|
|
42
|
+
<circle cx="14" cy="11" r="3.5" fill="white"/>
|
|
43
|
+
<rect x="6.5" y="6.5" width="2" height="11" fill="white"/>
|
|
44
|
+
</svg>`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async getToken(code: string): Promise<OAuthTokenResponse> {
|
|
48
|
+
const params = new URLSearchParams({
|
|
49
|
+
code,
|
|
50
|
+
client_id: this.clientId,
|
|
51
|
+
client_secret: this.clientSecret,
|
|
52
|
+
redirect_uri: this.redirectUri,
|
|
53
|
+
grant_type: 'authorization_code',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return this.fetchJson<OAuthTokenResponse>('https://www.patreon.com/api/oauth2/token', {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: {
|
|
59
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
60
|
+
},
|
|
61
|
+
body: params.toString(),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async getUserProfile(accessToken: string): Promise<UserProfile> {
|
|
66
|
+
const params = new URLSearchParams({
|
|
67
|
+
'fields[user]': 'email,full_name,image_url,is_email_verified',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const data = await this.fetchJson<{
|
|
71
|
+
data: {
|
|
72
|
+
id: string;
|
|
73
|
+
attributes: { email?: string; full_name?: string; image_url?: string; is_email_verified?: boolean };
|
|
74
|
+
};
|
|
75
|
+
}>(`https://www.patreon.com/api/oauth2/v2/identity?${params.toString()}`, {
|
|
76
|
+
headers: {
|
|
77
|
+
Authorization: `Bearer ${accessToken}`,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const user = data.data;
|
|
82
|
+
return {
|
|
83
|
+
id: user.id,
|
|
84
|
+
email: user.attributes.email,
|
|
85
|
+
name: user.attributes.full_name,
|
|
86
|
+
picture: user.attributes.image_url,
|
|
87
|
+
verified_email: user.attributes.is_email_verified,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async refreshToken(refreshToken: string): Promise<OAuthTokenResponse> {
|
|
92
|
+
const params = new URLSearchParams({
|
|
93
|
+
grant_type: 'refresh_token',
|
|
94
|
+
refresh_token: refreshToken,
|
|
95
|
+
client_id: this.clientId,
|
|
96
|
+
client_secret: this.clientSecret,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return this.fetchJson<OAuthTokenResponse>('https://www.patreon.com/api/oauth2/token', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: {
|
|
102
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
103
|
+
},
|
|
104
|
+
body: params.toString(),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async fetchEntitlements(accessToken: string): Promise<Partial<Entitlements>> {
|
|
109
|
+
const params = new URLSearchParams({
|
|
110
|
+
include: 'memberships,memberships.currently_entitled_tiers,memberships.currently_entitled_tiers.benefits',
|
|
111
|
+
'fields[member]': 'patron_status,currently_entitled_amount_cents',
|
|
112
|
+
'fields[tier]': 'title',
|
|
113
|
+
'fields[benefit]': 'title',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const data = await this.fetchJson<any>(`https://www.patreon.com/api/oauth2/v2/identity?${params.toString()}`, {
|
|
117
|
+
headers: {
|
|
118
|
+
Authorization: `Bearer ${accessToken}`,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return { patreon: parsePatreonIdentity(data, this.campaignId) };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import type { StartupAPIEnv } from '../StartupAPIEnv';
|
|
2
|
+
import type { ProviderOptions } from '../schemas/config';
|
|
2
3
|
|
|
3
4
|
import { OAuthProvider, OAuthTokenResponse, UserProfile } from './OAuthProvider';
|
|
4
5
|
|
|
5
6
|
export class TwitchProvider extends OAuthProvider {
|
|
6
|
-
static create(env: StartupAPIEnv, redirectBase: string): TwitchProvider | null {
|
|
7
|
+
static create(env: StartupAPIEnv, redirectBase: string, options?: ProviderOptions): TwitchProvider | null {
|
|
7
8
|
if (!env.TWITCH_CLIENT_ID || !env.TWITCH_CLIENT_SECRET) return null;
|
|
8
|
-
return new TwitchProvider(
|
|
9
|
+
return new TwitchProvider(
|
|
10
|
+
env.TWITCH_CLIENT_ID,
|
|
11
|
+
env.TWITCH_CLIENT_SECRET,
|
|
12
|
+
redirectBase + '/twitch/callback',
|
|
13
|
+
'twitch',
|
|
14
|
+
options?.scopes,
|
|
15
|
+
);
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
getAuthUrl(state: string): string {
|
|
@@ -13,7 +20,7 @@ export class TwitchProvider extends OAuthProvider {
|
|
|
13
20
|
client_id: this.clientId,
|
|
14
21
|
redirect_uri: this.redirectUri,
|
|
15
22
|
response_type: 'code',
|
|
16
|
-
scope: 'user:read:email',
|
|
23
|
+
scope: this.buildScope(['user:read:email']),
|
|
17
24
|
state: state,
|
|
18
25
|
});
|
|
19
26
|
return `https://id.twitch.tv/oauth2/authorize?${params.toString()}`;
|
package/src/auth/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { StartupAPIEnv } from '../StartupAPIEnv';
|
|
2
2
|
|
|
3
|
-
import { GoogleProvider } from './GoogleProvider';
|
|
4
|
-
import { TwitchProvider } from './TwitchProvider';
|
|
5
|
-
import { OAuthProvider } from './OAuthProvider';
|
|
6
3
|
import { CookieManager } from '../CookieManager';
|
|
4
|
+
import { refreshEntitlements } from '../entitlements/service';
|
|
5
|
+
import { computeRedirectBase, createProviders } from './providers';
|
|
6
|
+
import type { ProviderConfigs } from './providers';
|
|
7
7
|
|
|
8
8
|
export async function handleAuth(
|
|
9
9
|
request: Request,
|
|
@@ -11,21 +11,19 @@ export async function handleAuth(
|
|
|
11
11
|
url: URL,
|
|
12
12
|
usersPath: string,
|
|
13
13
|
cookieManager: CookieManager,
|
|
14
|
+
providerConfigs: ProviderConfigs = {},
|
|
14
15
|
): Promise<Response> {
|
|
15
16
|
const path = url.pathname;
|
|
16
17
|
const origin = env.AUTH_ORIGIN && env.AUTH_ORIGIN !== '' ? env.AUTH_ORIGIN : url.origin;
|
|
17
18
|
|
|
18
19
|
// Standardize redirectBase
|
|
19
|
-
const
|
|
20
|
-
const redirectBase = new URL((baseUsersPath.endsWith('/') ? baseUsersPath : baseUsersPath + '/') + 'auth', origin).toString();
|
|
20
|
+
const redirectBase = computeRedirectBase(env, origin, usersPath);
|
|
21
21
|
|
|
22
22
|
// For internal matching, we still need authPath
|
|
23
23
|
const authPath = new URL(redirectBase).pathname;
|
|
24
24
|
|
|
25
|
-
// Instantiate providers
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
const activeProviders = providers.filter((p): p is OAuthProvider => p !== null);
|
|
25
|
+
// Instantiate active providers
|
|
26
|
+
const activeProviders = createProviders(env, redirectBase, providerConfigs);
|
|
29
27
|
|
|
30
28
|
// Handle Auth Start
|
|
31
29
|
for (const provider of activeProviders) {
|
|
@@ -156,6 +154,30 @@ export async function handleAuth(
|
|
|
156
154
|
// Register credential mapping in UserDO
|
|
157
155
|
await userStub.addCredential(provider.name, profile.id);
|
|
158
156
|
|
|
157
|
+
// Login-time entitlement fetch: providers that support entitlements (e.g. Patreon) get an
|
|
158
|
+
// initial entitlement snapshot now, so gating works even when no freshness mechanism is
|
|
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
|
+
}
|
|
180
|
+
|
|
159
181
|
// Register User in SystemDO index (Only for new users)
|
|
160
182
|
if (isNewUser) {
|
|
161
183
|
await userStub.updateProfile(profile);
|