Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,14 +261,30 @@ For non-`bypass` paths the worker forwards `X-StartupAPI-Authenticated`, `X-Star

### Keeping entitlements fresh

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):
Entitlements are fetched at login and then kept fresh per-provider via three mechanisms. **Durations** below are a named-unit object (`{ days: 1 }`, `{ minutes: 15 }` — units are summed) or a plain number of milliseconds.

- **TTL** — lazily re-check on the request path when older than the TTL (`freshness.ttl: { ms }`, default 15 min), using the OAuth refresh token.
- **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.
- **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).
- **TTL** (`entitlementTtl`) — lazily re-check on the request path when the cache is older than the duration, using the OAuth refresh token. **On by default** for entitlement providers: default **1 day for Patreon** (memberships change slowly; this backstops missed webhooks) and 15 min for other providers. Pass a Duration to tune it (`entitlementTtl: { hours: 6 }`), or `entitlementTtl: false` to disable (e.g. rely on the webhook only).
- **Cron** (`entitlementCron: { schedule }`) — a scheduled re-sync of all of a provider's credentials. Off by default. 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.
- **Webhook** (Patreon only, `entitlementWebhook: true`) — off by default. 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).

Set `providers.patreon.campaignId` to disambiguate when a user belongs to multiple campaigns.

### Login session lifetime

Login sessions are **separate** from entitlement freshness: how long a user stays logged in (authentication) is independent of how often their paid status is re-checked (authorization). A user can stay logged in for weeks while their entitlements are refreshed every few minutes via TTL/webhook — and a churned subscriber still loses access within the entitlement window regardless of session validity.

- **Default lifetime is 30 days** with a **rolling window**: on activity, once less than half the window remains, the session expiry is pushed forward and the `session_id` cookie's `Max-Age` is refreshed. Active users effectively never have to re-login; idle users expire after the window.
- The `session_id` cookie is a **persistent cookie** (`Max-Age`), so it survives browser restarts.
- Configure the window via the factory (`session.ttl` is a Duration):

```ts
const api = createStartupAPI({
session: { ttl: { days: 30 } }, // default; lower/raise as needed (or a number of ms)
});
```

> **Breaking in 0.5.0:** entitlement freshness moved from the `freshness: { ttl, cron, webhook }` block to flat per-provider keys `entitlementTtl` / `entitlementCron` / `entitlementWebhook`; `entitlementTtl` is now **on by default** (1 day for Patreon) and takes a Duration or `false`. The default session lifetime went from a fixed 24h (no renewal) to a **rolling 30 days**, and `session_id` is now a **persistent cookie**.

### Configuring via the factory

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:
Expand All @@ -287,9 +303,13 @@ const api = createStartupAPI({
patreon: {
scopes: 'identity.memberships',
campaignId: '<CAMPAIGN_ID>',
freshness: { ttl: true, cron: { schedule: '0 */6 * * *' }, webhook: true },
// entitlementTtl is on by default (1 day for Patreon); tune with a Duration or set false to disable.
entitlementTtl: { hours: 12 },
entitlementCron: { schedule: '0 */6 * * *' },
entitlementWebhook: true,
},
},
session: { ttl: { days: 30 } }, // optional; login session lifetime (default 30d, rolling)
accessPolicy: {
rules: [
/* ... */
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@startup-api/cloudflare",
"version": "0.4.5",
"version": "0.5.0",
"license": "Apache-2.0",
"publishConfig": {
"access": "public"
Expand Down
2 changes: 2 additions & 0 deletions src/auth/OAuthProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export interface AuthContext {
/** Effective origin (AUTH_ORIGIN override or request origin). */
origin: string;
cookieManager: CookieManager;
/** Login session lifetime in ms, resolved from factory config. */
sessionTtlMs: number;
}

/**
Expand Down
13 changes: 8 additions & 5 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { StartupAPIEnv } from '../StartupAPIEnv';

import { CookieManager } from '../CookieManager';
import { DEFAULT_SESSION_TTL_MS } from '../schemas/config';
import { sessionSetCookie } from '../handlers/utils';
import { refreshEntitlements } from '../entitlements/service';
import { renderAuthError } from './errorPage';
import { computeRedirectBase, createProviders } from './providers';
Expand All @@ -14,6 +16,7 @@ export async function handleAuth(
usersPath: string,
cookieManager: CookieManager,
providerConfigs: ProviderConfigs = {},
sessionTtlMs: number = DEFAULT_SESSION_TTL_MS,
): Promise<Response> {
const path = url.pathname;
const origin = env.AUTH_ORIGIN && env.AUTH_ORIGIN !== '' ? env.AUTH_ORIGIN : url.origin;
Expand All @@ -27,7 +30,7 @@ export async function handleAuth(
// Instantiate active providers
const activeProviders = createProviders(env, redirectBase, providerConfigs);

const ctx: AuthContext = { request, env, url, redirectBase, authPath, usersPath, origin, cookieManager };
const ctx: AuthContext = { request, env, url, redirectBase, authPath, usersPath, origin, cookieManager, sessionTtlMs };

// Provider-specific auxiliary routes (e.g. the atproto client-metadata document).
for (const provider of activeProviders) {
Expand Down Expand Up @@ -70,7 +73,7 @@ export async function handleAuth(
* transient flow state).
*/
async function finishLogin(provider: OAuthProvider, result: ExchangeResult, ctx: AuthContext): Promise<Response> {
const { env, request, usersPath, origin, cookieManager } = ctx;
const { env, request, usersPath, origin, cookieManager, sessionTtlMs } = ctx;
const { token, profile, returnUrl } = result;

const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global'));
Expand Down Expand Up @@ -225,12 +228,12 @@ async function finishLogin(provider: OAuthProvider, result: ExchangeResult, ctx:
}

// Create Session
const session = await userStub.createSession({ provider: provider.name });
const session = await userStub.createSession({ provider: provider.name }, sessionTtlMs);

// Set cookie and redirect
// Set cookie and redirect (persistent cookie so the session survives browser restarts)
const encryptedSession = await cookieManager.encrypt(`${session.sessionId}:${userIdStr}`);
const headers = new Headers();
headers.set('Set-Cookie', `session_id=${encryptedSession}; Path=/; HttpOnly; Secure; SameSite=Lax`);
headers.set('Set-Cookie', sessionSetCookie(encryptedSession, Math.floor(sessionTtlMs / 1000)));
for (const cookie of result.setCookies ?? []) {
headers.append('Set-Cookie', cookie);
}
Expand Down
60 changes: 43 additions & 17 deletions src/createStartupAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { CredentialDO } from './storage/CredentialDO';
import { CookieManager } from './CookieManager';
import { initPlans } from './billing/plansConfig';
import { Plan } from './billing/Plan';
import { getActiveProviders, parseCookies, getUserFromSession, isAdmin } from './handlers/utils';
import { getActiveProviders, parseCookies, getUserFromSession, isAdmin, applySessionRenewal, sessionSetCookie } from './handlers/utils';
import { handleAdmin } from './handlers/admin';
import {
handleMe,
Expand All @@ -22,7 +22,7 @@ import { handleLogout } from './handlers/auth';
import { handleSSR } from './handlers/ssr';

import type { StartupAPIEnv } from './StartupAPIEnv';
import { StartupAPIConfigSchema } from './schemas/config';
import { StartupAPIConfigSchema, DEFAULT_SESSION_TTL_MS, durationToMs } from './schemas/config';
import type { StartupAPIConfig, ProviderOptions, ResolvedFreshness } from './schemas/config';
import type { AccessPolicyConfig, PageSource } from './schemas/policy';
import { AccessPolicy, evaluateAccess } from './policy/accessPolicy';
Expand All @@ -36,31 +36,54 @@ import { handlePatreonWebhook } from './webhooks/patreon';
const DEFAULT_USERS_PATH = '/users/';
const DEFAULT_CRON_SCHEDULE = '0 */6 * * *';
const DEFAULT_ENTITLEMENT_TTL_MS = 15 * 60 * 1000;
// Patreon memberships change slowly (pledges are monthly) and real-time changes are already covered by
// the webhook, so a per-request re-check every 15 min is wasteful. Default Patreon's TTL to 1 day; it
// acts as a backstop for missed webhooks rather than the primary freshness mechanism.
const DEFAULT_PATREON_ENTITLEMENT_TTL_MS = 24 * 60 * 60 * 1000;

/** Provider-specific default entitlement TTL, used when `entitlementTtl` is on but no interval is given. */
function defaultEntitlementTtlMs(providerName?: string): number {
return providerName === 'patreon' ? DEFAULT_PATREON_ENTITLEMENT_TTL_MS : DEFAULT_ENTITLEMENT_TTL_MS;
}

// The factory's request handler is a local `const fetch`, which would shadow the global fetch inside
// its body. Use this alias to proxy to the origin via the *current* global fetch at call time (so test
// spies on globalThis.fetch are honored).
const originFetch = (...args: Parameters<typeof fetch>): Promise<Response> => globalThis.fetch(...args);

function isCronEnabled(options: ProviderOptions): boolean {
const cron = options.freshness?.cron;
const cron = options.entitlementCron;
return cron === true || (typeof cron === 'object' && cron !== null);
}

/** Resolve a provider's freshness config into concrete flags/values. */
function resolveFreshness(options: ProviderOptions | undefined): ResolvedFreshness {
const f = options?.freshness ?? {};
const ttlEnabled = f.ttl === true || (typeof f.ttl === 'object' && f.ttl !== null);
const ttlMs = typeof f.ttl === 'object' && f.ttl?.ms ? f.ttl.ms : DEFAULT_ENTITLEMENT_TTL_MS;
/** Resolve a provider's entitlement freshness config into concrete flags/values. */
function resolveFreshness(options: ProviderOptions | undefined, providerName?: string): ResolvedFreshness {
// entitlementTtl is ON by default; only an explicit `false` disables it. A provided Duration sets the
// interval, otherwise fall back to the provider default (1 day for Patreon, 15 min otherwise).
const ttlRaw = options?.entitlementTtl;
const ttlEnabled = ttlRaw !== false;
const ttlFromConfig = ttlRaw !== undefined && ttlRaw !== false ? durationToMs(ttlRaw) : 0;
const ttlMs = ttlFromConfig > 0 ? ttlFromConfig : defaultEntitlementTtlMs(providerName);
const cron = options?.entitlementCron;
const cronEnabled = isCronEnabled(options ?? {});
const cronSchedule = typeof f.cron === 'object' && f.cron?.schedule ? f.cron.schedule : DEFAULT_CRON_SCHEDULE;
const cronSchedule = typeof cron === 'object' && cron?.schedule ? cron.schedule : DEFAULT_CRON_SCHEDULE;
return {
ttl: { enabled: ttlEnabled, ms: ttlMs },
cron: { enabled: cronEnabled, schedule: cronSchedule },
webhook: { enabled: f.webhook === true },
webhook: { enabled: options?.entitlementWebhook === true },
};
}

/** Resolve the login session lifetime (rolling window, ms) from factory config, else the 30-day default. */
function resolveSessionTtlMs(session: StartupAPIConfig['session']): number {
const ttl = session?.ttl;
if (ttl !== undefined) {
const ms = durationToMs(ttl);
if (ms > 0) return ms;
}
return DEFAULT_SESSION_TTL_MS;
}

/** Resolve the access policy from factory config, else a backward-compatible all-public default. */
function resolveAccessPolicy(configPolicy: AccessPolicyConfig | undefined): AccessPolicyConfig {
// No policy configured → preserve legacy behavior: allow everything, still forward identity headers.
Expand Down Expand Up @@ -130,11 +153,12 @@ function denyResponse(
export function createStartupAPI(config: StartupAPIConfig = {}) {
const parsed = StartupAPIConfigSchema.parse(config);
const providerConfigs = parsed.providers ?? {};
const sessionTtlMs = resolveSessionTtlMs(parsed.session);
const cronProviders = Object.entries(providerConfigs)
.filter(([, options]) => isCronEnabled(options))
.map(([name]) => name);
const anyCron = cronProviders.length > 0;
const patreonWebhookEnabled = providerConfigs.patreon?.freshness?.webhook === true;
const patreonWebhookEnabled = providerConfigs.patreon?.entitlementWebhook === true;

const fetch = async (request: Request, env: StartupAPIEnv, ctx: ExecutionContext): Promise<Response> => {
if (!Plan.isInitialized()) {
Expand Down Expand Up @@ -178,7 +202,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {

// Handle OAuth Routes
if (url.pathname.startsWith(usersPath + 'auth/')) {
return handleAuth(request, env, url, usersPath, cookieManager, providerConfigs);
return handleAuth(request, env, url, usersPath, cookieManager, providerConfigs, sessionTtlMs);
}

if (url.pathname === usersPath + 'me/avatar') {
Expand Down Expand Up @@ -221,7 +245,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {

const headers = new Headers();
const newSessionIdEncrypted = await cookieManager.encrypt(backupSession);
headers.set('Set-Cookie', `session_id=${newSessionIdEncrypted}; Path=/; HttpOnly; Secure; SameSite=Lax`);
headers.set('Set-Cookie', sessionSetCookie(newSessionIdEncrypted, Math.floor(sessionTtlMs / 1000)));
headers.append('Set-Cookie', `backup_session_id=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`);

return Response.json({ success: true }, { headers });
Expand Down Expand Up @@ -262,7 +286,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {

// Admin Routes
if (url.pathname.startsWith(usersPath + 'admin/')) {
return handleAdmin(request, env, usersPath, cookieManager, providerConfigs);
return handleAdmin(request, env, usersPath, cookieManager, providerConfigs, sessionTtlMs);
}

// Intercept requests to usersPath and serve them from the public/users directory.
Expand Down Expand Up @@ -292,7 +316,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {
return originFetch(newRequest);
}

const user = await getUserFromSession(request, env, cookieManager);
const user = await getUserFromSession(request, env, cookieManager, sessionTtlMs);
const authenticated = !!user;
const userIsAdmin = user ? isAdmin(user, env) : false;
let entitlements: Entitlements | null = null;
Expand All @@ -311,7 +335,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {
if (loginProvider && subjectId) {
const provider = getProvider(env, computeRedirectBase(env, requestOrigin, usersPath), loginProvider, providerConfigs);
if (provider && provider.supportsEntitlements()) {
const fr = resolveFreshness(providerConfigs[loginProvider]);
const fr = resolveFreshness(providerConfigs[loginProvider], loginProvider);
entitlements = await loadEntitlements({
env,
provider,
Expand Down Expand Up @@ -346,7 +370,9 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {

const response = await originFetch(newRequest);
const providers = getActiveProviders(env, providerConfigs);
return injectPowerStrip(response, usersPath, providers);
// Refresh the persistent session cookie's Max-Age when the DO extended the session (sliding renewal).
const decorated = await injectPowerStrip(response, usersPath, providers);
return applySessionRenewal(decorated, user?.renew);
}

// do not modify the request as it will loop through the same worker again
Expand Down
8 changes: 5 additions & 3 deletions src/handlers/admin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { StartupAPIEnv } from '../StartupAPIEnv';
import { CookieManager } from '../CookieManager';
import { getUserFromSession, checkAndClearStaleSession, isAdmin, parseCookies, getActiveProviders } from './utils';
import { getUserFromSession, checkAndClearStaleSession, isAdmin, parseCookies, getActiveProviders, sessionSetCookie } from './utils';
import type { ProviderConfigs } from '../auth/providers';
import { DEFAULT_SESSION_TTL_MS } from '../schemas/config';
import { Plan } from '../billing/Plan';
import { UserProfileSchema } from '../schemas/user';
import { SystemAccountSchema, MemberSchema } from '../schemas/account';
Expand All @@ -13,6 +14,7 @@ export async function handleAdmin(
usersPath: string,
cookieManager: CookieManager,
providerConfigs: ProviderConfigs = {},
sessionTtlMs: number = DEFAULT_SESSION_TTL_MS,
): Promise<Response> {
const user = await getUserFromSession(request, env, cookieManager);
if (!user || !isAdmin(user, env)) {
Expand Down Expand Up @@ -113,15 +115,15 @@ export async function handleAdmin(

const userDOId = env.USER.idFromString(user_id);
const userStub = env.USER.get(userDOId);
const session = await userStub.createSession({ provider: 'admin-impersonation', impersonator: user.id });
const session = await userStub.createSession({ provider: 'admin-impersonation', impersonator: user.id }, sessionTtlMs);

const cookieHeader = request.headers.get('Cookie');
const cookies = parseCookies(cookieHeader || '');
const currentSessionEncrypted = cookies['session_id'];

const headers = new Headers();
const newSessionIdEncrypted = await cookieManager.encrypt(`${session.sessionId}:${user_id}`);
headers.set('Set-Cookie', `session_id=${newSessionIdEncrypted}; Path=/; HttpOnly; Secure; SameSite=Lax`);
headers.set('Set-Cookie', sessionSetCookie(newSessionIdEncrypted, Math.floor(sessionTtlMs / 1000)));
if (currentSessionEncrypted) {
const backupSession = await cookieManager.decrypt(currentSessionEncrypted);
if (backupSession) {
Expand Down
Loading