@startup-api/cloudflare 0.0.1 → 0.1.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 +103 -0
- package/package.json +1 -1
- package/public/users/power-strip.js +24 -1
- package/public/users/profile.html +4 -0
- 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 +321 -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 +99 -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,82 @@ 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
|
+
## Access policy & provider entitlements
|
|
136
|
+
|
|
137
|
+
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.
|
|
138
|
+
|
|
139
|
+
### Path-based access policy
|
|
140
|
+
|
|
141
|
+
Configure an ordered list of rules (first match wins) mapping a path pattern to a requirement, plus a `default` for unmatched paths. Requirement modes:
|
|
142
|
+
|
|
143
|
+
- **`bypass`** — raw pass-through: no credential check, no identity resolution, no headers, no power-strip injection.
|
|
144
|
+
- **`public`** — anyone; the session is resolved and identity/entitlement headers are forwarded when present.
|
|
145
|
+
- **`authenticated`** — any logged-in user.
|
|
146
|
+
- **`entitlement`** — a provider condition: Patreon `active_patron`, a specific `benefit` (perk) ID, or a `tier` ID.
|
|
147
|
+
|
|
148
|
+
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).
|
|
149
|
+
|
|
150
|
+
The policy is passed to the factory as `accessPolicy` (see below). Example:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
const accessPolicy = {
|
|
154
|
+
rules: [
|
|
155
|
+
{ pattern: '/', requirement: { mode: 'public' } },
|
|
156
|
+
{
|
|
157
|
+
pattern: '/special',
|
|
158
|
+
requirement: { mode: 'entitlement', provider: 'patreon', condition: { type: 'benefit', benefit_id: '<BENEFIT_ID>' } },
|
|
159
|
+
on_unauthorized: 'upgrade',
|
|
160
|
+
upgrade_url: 'https://www.patreon.com/yourpage',
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
default: { mode: 'entitlement', provider: 'patreon', condition: { type: 'active_patron' } },
|
|
164
|
+
};
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Headers forwarded to the origin
|
|
168
|
+
|
|
169
|
+
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`.
|
|
170
|
+
|
|
171
|
+
### Keeping entitlements fresh
|
|
172
|
+
|
|
173
|
+
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):
|
|
174
|
+
|
|
175
|
+
- **TTL** — lazily re-check on the request path when older than the TTL (`freshness.ttl: { ms }`, default 15 min), using the OAuth refresh token.
|
|
176
|
+
- **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.
|
|
177
|
+
- **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).
|
|
178
|
+
|
|
179
|
+
Set `providers.patreon.campaignId` to disambiguate when a user belongs to multiple campaigns.
|
|
180
|
+
|
|
181
|
+
### Configuring via the factory
|
|
182
|
+
|
|
183
|
+
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:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
// Defaults — unchanged:
|
|
187
|
+
export { default, UserDO, AccountDO, SystemDO, CredentialDO } from '@startup-api/cloudflare';
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
// Custom configuration:
|
|
192
|
+
import { createStartupAPI } from '@startup-api/cloudflare';
|
|
193
|
+
|
|
194
|
+
const api = createStartupAPI({
|
|
195
|
+
providers: {
|
|
196
|
+
patreon: {
|
|
197
|
+
scopes: 'identity.memberships',
|
|
198
|
+
campaignId: '<CAMPAIGN_ID>',
|
|
199
|
+
freshness: { ttl: true, cron: { schedule: '0 */6 * * *' }, webhook: true },
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
accessPolicy: { rules: [/* ... */], default: { mode: 'public' } },
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
export default api.default; // includes scheduled() because cron is enabled
|
|
206
|
+
export const { UserDO, AccountDO, SystemDO, CredentialDO } = api;
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
(Remember to add `triggers.crons` to your wrangler config when enabling cron.)
|
|
210
|
+
|
|
108
211
|
## Contributing
|
|
109
212
|
|
|
110
213
|
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 = '';
|
|
@@ -329,6 +341,7 @@ class PowerStrip extends HTMLElement {
|
|
|
329
341
|
|
|
330
342
|
.provider-badge.google { color: #3c4043; }
|
|
331
343
|
.provider-badge.twitch { color: #9146FF; }
|
|
344
|
+
.provider-badge.patreon { color: #FF424D; }
|
|
332
345
|
|
|
333
346
|
.user-info {
|
|
334
347
|
display: flex;
|
|
@@ -506,7 +519,17 @@ class PowerStrip extends HTMLElement {
|
|
|
506
519
|
background-color: #7d2ee6;
|
|
507
520
|
border-color: #7d2ee6;
|
|
508
521
|
}
|
|
509
|
-
|
|
522
|
+
|
|
523
|
+
.auth-btn.patreon {
|
|
524
|
+
background-color: #FF424D;
|
|
525
|
+
color: white;
|
|
526
|
+
border-color: #FF424D;
|
|
527
|
+
}
|
|
528
|
+
.auth-btn.patreon:hover {
|
|
529
|
+
background-color: #e63a44;
|
|
530
|
+
border-color: #e63a44;
|
|
531
|
+
}
|
|
532
|
+
|
|
510
533
|
/* Account Switcher Styling */
|
|
511
534
|
.account-list {
|
|
512
535
|
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/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);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { StartupAPIEnv } from '../StartupAPIEnv';
|
|
2
|
+
import type { ProviderOptions } from '../schemas/config';
|
|
3
|
+
import { OAuthProvider } from './OAuthProvider';
|
|
4
|
+
import { GoogleProvider } from './GoogleProvider';
|
|
5
|
+
import { TwitchProvider } from './TwitchProvider';
|
|
6
|
+
import { PatreonProvider } from './PatreonProvider';
|
|
7
|
+
|
|
8
|
+
export type ProviderConfigs = Record<string, ProviderOptions>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compute the base URL that provider callback/redirect URIs are built from, e.g.
|
|
12
|
+
* `https://host/users/auth`. Mirrors the logic in handleAuth so all call sites agree.
|
|
13
|
+
*/
|
|
14
|
+
export function computeRedirectBase(env: StartupAPIEnv, origin: string, usersPath: string): string {
|
|
15
|
+
const baseUsersPath = usersPath.startsWith('/') ? usersPath : '/' + usersPath;
|
|
16
|
+
return new URL((baseUsersPath.endsWith('/') ? baseUsersPath : baseUsersPath + '/') + 'auth', origin).toString();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the list of active OAuth providers (those whose credentials are configured in env). Provider
|
|
21
|
+
* behavior (scopes, Patreon campaign) comes from the factory config passed as `providerConfigs`.
|
|
22
|
+
*/
|
|
23
|
+
export function createProviders(env: StartupAPIEnv, redirectBase: string, providerConfigs: ProviderConfigs = {}): OAuthProvider[] {
|
|
24
|
+
return [
|
|
25
|
+
GoogleProvider.create(env, redirectBase, providerConfigs.google),
|
|
26
|
+
TwitchProvider.create(env, redirectBase, providerConfigs.twitch),
|
|
27
|
+
PatreonProvider.create(env, redirectBase, providerConfigs.patreon),
|
|
28
|
+
].filter((p): p is OAuthProvider => p !== null);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Get a single active provider by name, or undefined if not configured. */
|
|
32
|
+
export function getProvider(
|
|
33
|
+
env: StartupAPIEnv,
|
|
34
|
+
redirectBase: string,
|
|
35
|
+
name: string,
|
|
36
|
+
providerConfigs: ProviderConfigs = {},
|
|
37
|
+
): OAuthProvider | undefined {
|
|
38
|
+
return createProviders(env, redirectBase, providerConfigs).find((p) => p.name === name);
|
|
39
|
+
}
|