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
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,12 @@ const api = createStartupAPI({
atproto: {}, // including the key enables it — no client id/secret needed
// All fields below are optional:
// atproto: {
// clientName: 'My App', // shown on the consent screen (default "StartupAPI")
// clientName: 'My App', // advertised in the client-metadata document (default "StartupAPI")
// plcUrl: 'https://plc.directory', // override the PLC directory for did:plc
// dohUrl: 'https://cloudflare-dns.com/dns-query', // override the DoH resolver
// scopes: 'transition:generic', // extra scopes on top of the base `atproto`
// useRootOAuthClientMetadata: false, // keep the client-metadata document under USERS_PATH (default: true, served at /oauth-client-metadata.json)
// customOAuthClientMetadataURL: '/oauth-client-metadata.json', // use your own self-hosted document as client_id (requires useRootOAuthClientMetadata: false)
// enabled: false, // explicit opt-out (e.g. for dynamically-built config)
// },
},
Expand All @@ -135,7 +137,7 @@ export const { UserDO, AccountDO, SystemDO, CredentialDO } = api;
```

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.
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`.
2. Deploy over **HTTPS** with a stable hostname. The worker automatically serves its client metadata at `https://<your-worker-url>/oauth-client-metadata.json` (this URL is the OAuth `client_id`) and registers the redirect URI `https://<your-worker-url>/users/auth/atproto/callback`. See [Where the client-metadata document lives](#where-the-client-metadata-document-lives) if you'd rather not expose a root-level path.
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:

```
Expand All @@ -147,6 +149,22 @@ export const { UserDO, AccountDO, SystemDO, CredentialDO } = api;

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.

##### Where the client-metadata document lives

The [atproto OAuth spec](https://atproto.com/specs/oauth) lets the client-metadata document live at any `https://` URL, but `/oauth-client-metadata.json` at the domain root is the recognized convention: the reference PDS consent screen shows **just your hostname** (`example.com`) for a `client_id` at that path, and the **full metadata URL** for any other path. That is why StartupAPI serves it at the root by default — the only route it claims outside `USERS_PATH`. Three modes:

| Mode | Config | Document served by StartupAPI at | `client_id` |
| :------------------------------- | :------------------------------------------------------------------------------------------------------------ | :---------------------------------------------------------- | :------------------------------------------------------- |
| Root (default) | `atproto: {}` | `/oauth-client-metadata.json` | `https://<host>/oauth-client-metadata.json` |
| Under `USERS_PATH` | `atproto: { useRootOAuthClientMetadata: false }` | `/users/auth/atproto/client-metadata.json` | `https://<host>/users/auth/atproto/client-metadata.json` |
| Self-hosted (you serve the file) | `atproto: { useRootOAuthClientMetadata: false, customOAuthClientMetadataURL: '/oauth-client-metadata.json' }` | `/users/auth/atproto/client-metadata.json` (reference copy) | your URL (root-relative, or absolute `https://`) |

In self-hosted mode StartupAPI does not claim the root path; copy the reference document it serves under `USERS_PATH` to your URL verbatim (it already carries your `client_id`, `redirect_uris` and `scope`) and keep them in sync when you change scopes. Setting `customOAuthClientMetadataURL` without `useRootOAuthClientMetadata: false` is a configuration error.

> **What the consent screen shows.** Authorization servers deliberately do **not** display `client_name` or a logo for clients they don't know — those fields are unverified and could impersonate another app — so Bluesky-hosted PDSes show your hostname only. A self-hosted PDS can opt in to showing your name by allowlisting your `client_id` in its `PDS_OAUTH_TRUSTED_CLIENTS` setting.
>
> **Changing the `client_id`** (switching modes, or upgrading from a version that served the document under `USERS_PATH`) invalidates existing atproto authorizations; users simply log in again.

#### Requesting additional scopes

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:
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.5.0",
"version": "0.6.0",
"license": "Apache-2.0",
"publishConfig": {
"access": "public"
Expand Down
22 changes: 18 additions & 4 deletions src/auth/AtprotoProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,34 @@ export function isAtprotoEnabled(options?: ProviderOptions, env?: Pick<StartupAP
* Unlike the classic OAuth2 providers, atproto requires PKCE, DPoP-bound tokens, Pushed Authorization
* Requests (PAR), and per-user dynamic endpoints discovered from the identity (handle → DID → PDS →
* authorization server). It is a "public" OAuth client identified by a hosted client-metadata document
* (served at `…/auth/atproto/client-metadata.json`) rather than a client id/secret.
* rather than a client id/secret. By default the document is served at the conventional domain root
* (`/oauth-client-metadata.json`) so consent screens show just the hostname; it can instead live under
* the auth path (`…/auth/atproto/client-metadata.json`) or be replaced by one the app owner hosts.
*/
export class AtprotoProvider extends OAuthProvider {
/** Conventional root path for an atproto client-metadata document (auth servers show just the host). */
static readonly ROOT_METADATA_PATH = '/oauth-client-metadata.json';

/** The public client id, i.e. the URL of this client's metadata document. */
private clientMetadataUrl = '';
/** Path (on the auth origin) at which this worker serves the client-metadata document. */
private metadataPath = '';
private clientUri = '';
private clientName = 'StartupAPI';
private resolverOptions: ResolverOptions = {};

static create(env: StartupAPIEnv, redirectBase: string, options?: ProviderOptions): AtprotoProvider | null {
if (!isAtprotoEnabled(options, env)) return null;
const provider = new AtprotoProvider('', '', redirectBase + '/atproto/callback', 'atproto', options?.scopes);
provider.clientMetadataUrl = redirectBase + '/atproto/client-metadata.json';
provider.clientUri = new URL(redirectBase).origin;
const base = new URL(redirectBase);
// Where we serve the document: the conventional domain root (default) or under the auth path.
const useRoot = options?.useRootOAuthClientMetadata !== false;
provider.metadataPath = useRoot ? AtprotoProvider.ROOT_METADATA_PATH : `${base.pathname}/atproto/client-metadata.json`;
// The client id is the URL the authorization server fetches the document from: ours, or one the app
// owner hosts themselves (our copy under the auth path is then a reference for them to mirror).
const custom = options?.customOAuthClientMetadataURL?.trim();
provider.clientMetadataUrl = new URL(custom || provider.metadataPath, base.origin).toString();
provider.clientUri = new URL(provider.clientMetadataUrl).origin;
provider.clientName = options?.clientName?.trim() || 'StartupAPI';
provider.resolverOptions = {
plcUrl: options?.plcUrl?.trim() || undefined,
Expand Down Expand Up @@ -102,7 +116,7 @@ export class AtprotoProvider extends OAuthProvider {
}

async handleExtraRoute(ctx: AuthContext): Promise<Response | null> {
if (ctx.url.pathname === `${ctx.authPath}/atproto/client-metadata.json`) {
if (ctx.url.pathname === this.metadataPath) {
return Response.json(this.getClientMetadata());
}
return null;
Expand Down
57 changes: 41 additions & 16 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,59 @@ import { computeRedirectBase, createProviders } from './providers';
import type { ProviderConfigs } from './providers';
import type { AuthContext, ExchangeResult, OAuthProvider } from './OAuthProvider';

export async function handleAuth(
/** Build the per-request auth context shared by the auth router and provider auxiliary routes. */
function createAuthContext(
request: Request,
env: StartupAPIEnv,
url: URL,
usersPath: string,
cookieManager: CookieManager,
providerConfigs: ProviderConfigs = {},
sessionTtlMs: number = DEFAULT_SESSION_TTL_MS,
): Promise<Response> {
const path = url.pathname;
sessionTtlMs: number,
): AuthContext {
const origin = env.AUTH_ORIGIN && env.AUTH_ORIGIN !== '' ? env.AUTH_ORIGIN : url.origin;

// Standardize redirectBase
// Standardize redirectBase; authPath is its path, used for internal route matching.
const redirectBase = computeRedirectBase(env, origin, usersPath);

// For internal matching, we still need authPath
const authPath = new URL(redirectBase).pathname;
return { request, env, url, redirectBase, authPath, usersPath, origin, cookieManager, sessionTtlMs };
}

// Instantiate active providers
const activeProviders = createProviders(env, redirectBase, providerConfigs);

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) {
/**
* Provider-specific auxiliary routes that are not part of the start/callback flow and may live outside
* `usersPath` — e.g. the atproto client-metadata document at the conventional `/oauth-client-metadata.json`.
* Returns null when no active provider claims the request.
*/
export async function handleProviderExtraRoutes(
request: Request,
env: StartupAPIEnv,
url: URL,
usersPath: string,
cookieManager: CookieManager,
providerConfigs: ProviderConfigs = {},
sessionTtlMs: number = DEFAULT_SESSION_TTL_MS,
): Promise<Response | null> {
const ctx = createAuthContext(request, env, url, usersPath, cookieManager, sessionTtlMs);
for (const provider of createProviders(env, ctx.redirectBase, providerConfigs)) {
const res = await provider.handleExtraRoute(ctx);
if (res) return res;
}
return null;
}

export async function handleAuth(
request: Request,
env: StartupAPIEnv,
url: URL,
usersPath: string,
cookieManager: CookieManager,
providerConfigs: ProviderConfigs = {},
sessionTtlMs: number = DEFAULT_SESSION_TTL_MS,
): Promise<Response> {
const path = url.pathname;
const ctx = createAuthContext(request, env, url, usersPath, cookieManager, sessionTtlMs);
const { authPath } = ctx;

// Instantiate active providers
const activeProviders = createProviders(env, ctx.redirectBase, providerConfigs);

// Handle Auth Start
for (const provider of activeProviders) {
Expand Down
8 changes: 7 additions & 1 deletion src/createStartupAPI.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { handleAuth } from './auth/index';
import { handleAuth, handleProviderExtraRoutes } from './auth/index';
import { injectPowerStrip } from './PowerStrip';
import { UserDO } from './storage/UserDO';
import { AccountDO } from './storage/AccountDO';
Expand Down Expand Up @@ -183,6 +183,12 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {

const cookieManager = new CookieManager(env.SESSION_SECRET);

// Provider auxiliary documents that may live outside USERS_PATH (e.g. the atproto client-metadata
// document at the conventional /oauth-client-metadata.json root path). Checked first so a provider
// can claim its exact path; everything else falls through unchanged.
const extraRouteRes = await handleProviderExtraRoutes(request, env, url, usersPath, cookieManager, providerConfigs, sessionTtlMs);
if (extraRouteRes) return extraRouteRes;

// SSR Routes
const usersPathNormalized = usersPath.endsWith('/') ? usersPath : usersPath + '/';
if (url.pathname.startsWith(usersPathNormalized)) {
Expand Down
40 changes: 39 additions & 1 deletion src/schemas/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function durationToMs(d: Duration): number {
/** Periodic re-sync of entitlements via a scheduled() handler. `true`/`{ schedule }` on, off by default. */
const EntitlementCronSchema = z.union([z.boolean(), z.object({ schedule: z.string().optional() })]);

export const ProviderOptionsSchema = z.object({
export const ProviderOptionsObjectSchema = z.object({
/** Force-enable/disable the provider. Default: enabled iff its credentials are present in env. */
enabled: z.boolean().optional(),
/** Extra OAuth scopes to request, on top of the provider's required base scopes. */
Expand All @@ -52,6 +52,21 @@ export const ProviderOptionsSchema = z.object({
plcUrl: z.string().optional(),
/** atproto only: override the DNS-over-HTTPS resolver used for handle resolution. */
dohUrl: z.string().optional(),
/**
* atproto only: serve the OAuth client-metadata document at the domain root
* (`/oauth-client-metadata.json`) and use that URL as the `client_id`. ON by default — atproto
* authorization servers recognize this conventional path and show just your hostname on the consent
* screen instead of the full metadata URL. Set `false` to keep the document (and the `client_id`)
* under `USERS_PATH`, at `…/auth/atproto/client-metadata.json`.
*/
useRootOAuthClientMetadata: z.boolean().optional(),
/**
* atproto only: use a client-metadata document you host yourself as the `client_id`, instead of the
* one StartupAPI serves. A root-relative path (`/oauth-client-metadata.json`, resolved against the
* auth origin) or an absolute `https://` URL. Requires `useRootOAuthClientMetadata: false`; StartupAPI
* then keeps serving a reference copy under `USERS_PATH` for you to mirror at this URL.
*/
customOAuthClientMetadataURL: z.string().min(1).optional(),
/**
* Lazily re-check entitlements on the request hot path when the cache is older than this duration.
* ON by default for entitlement providers — default 1 day for Patreon, 15 min otherwise. Pass a
Expand All @@ -64,6 +79,29 @@ export const ProviderOptionsSchema = z.object({
entitlementCron: EntitlementCronSchema.optional(),
});

/** Provider options with the cross-field checks the object schema cannot express. */
export const ProviderOptionsSchema = ProviderOptionsObjectSchema.superRefine((options, ctx) => {
const custom = options.customOAuthClientMetadataURL?.trim();
if (custom === undefined) return;
// A self-hosted document and the root document would compete for the same URL; make the choice explicit.
if (options.useRootOAuthClientMetadata !== false) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['customOAuthClientMetadataURL'],
message: 'customOAuthClientMetadataURL requires useRootOAuthClientMetadata: false',
});
}
// atproto client ids must be https:// URLs (http://localhost is the spec's dev-only exception).
const isRootRelative = custom.startsWith('/') && !custom.startsWith('//');
if (!isRootRelative && !custom.startsWith('https://') && !custom.startsWith('http://localhost')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['customOAuthClientMetadataURL'],
message: 'customOAuthClientMetadataURL must be a root-relative path (e.g. "/oauth-client-metadata.json") or an https:// URL',
});
}
});

export const SessionConfigSchema = z.object({
/**
* Login session lifetime as a Duration (default 30 days). The session is a rolling window — renewed
Expand Down
Loading