diff --git a/.env.example b/.env.example index ebdf694..b54a7d4 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,13 @@ APPLE_CLIENT_ID= APPLE_TEAM_ID= APPLE_KEY_ID= +# Local development sign-in. Set an address here and a `dev` provider appears +# that signs you in as that user without contacting Google, GitHub or Apple, so +# a checkout can be run end to end without registering an OAuth application. +# It is ignored unless NODE_ENV is `dev`, and `central:start` hardcodes prod. +# DEV_AUTH_EMAIL=you@example.com +# DEV_AUTH_NAME=Local Developer + # ── Relay (DB-free WebSocket relay) ─────────────────────────────────────────── RELAY_HOST=127.0.0.1 RELAY_PORT=6970 diff --git a/central/auth/providers.ts b/central/auth/providers.ts index e6ed915..c30d331 100644 --- a/central/auth/providers.ts +++ b/central/auth/providers.ts @@ -11,8 +11,14 @@ import { saveLoginState, } from "./store"; -const PROVIDERS = ["google", "github", "apple"] as const; +const PROVIDERS = ["google", "github", "apple", "dev"] as const; const APPLE_PRIVATE_KEY_FILE = "apple_key.p8"; +/** + * Placeholder in the `code` slot of the dev provider's callback. The dev flow + * carries no provider code, but the callback route requires a non-empty one, + * and the value that actually matters is the single-use `state`. + */ +const DEV_AUTHORIZATION_CODE = "dev"; type ProviderStatus = { id: AuthProvider; @@ -28,7 +34,12 @@ type AppleUser = { }; export function listProviders(): ProviderStatus[] { - return PROVIDERS.map((id) => ({ id, enabled: isProviderEnabled(id) })); + return PROVIDERS.map((id) => ({ id, enabled: isProviderEnabled(id) })).filter( + // The dev provider is an artefact of a local checkout, not a sign-in option + // users should ever hear about. Omitting it while disabled keeps this + // response byte-identical to what a production instance returned before. + (provider) => provider.id !== "dev" || provider.enabled, + ); } export function isProviderEnabled(provider: AuthProvider): boolean { @@ -44,6 +55,11 @@ export function isProviderEnabled(provider: AuthProvider): boolean { env.APPLE_KEY_ID && hasApplePrivateKey(), ); + case "dev": + // Two independent gates. NODE_ENV is set by the start script rather than + // by .env (`central:start` hardcodes prod), so a production instance + // cannot switch this on through configuration alone. + return env.NODE_ENV === "dev" && Boolean(env.DEV_AUTH_EMAIL); } } @@ -84,6 +100,22 @@ function createOAuthAuthorizationUrl( ensureProviderEnabled(provider); const state = arctic.generateState(); + if (provider === "dev") { + saveLoginState(state, provider, { + callbackUrl: options.callbackUrl, + purpose: options.purpose, + userId: options.userId, + }); + // There is no external authorization server to visit, so the + // "authorization URL" is our own callback. Everything downstream of the + // callback (state consumption, exchange codes, browser cookies, deep + // links) is then the same code path every real provider takes. + const url = new URL(callbackUrl("dev")); + url.searchParams.set("code", DEV_AUTHORIZATION_CODE); + url.searchParams.set("state", state); + return url.toString(); + } + if (provider === "google") { const codeVerifier = arctic.generateCodeVerifier(); saveLoginState(state, provider, { @@ -131,6 +163,23 @@ export async function getProfileFromCallback( ensureProviderEnabled(provider); const loginState = consumeLoginState(state, provider); + if (provider === "dev") { + const email = required(env.DEV_AUTH_EMAIL, "DEV_AUTH_EMAIL"); + return { + loginState, + profile: { + provider, + // Derived from the address so that changing DEV_AUTH_EMAIL yields a + // different account rather than renaming the existing one. + providerAccountId: `dev:${email.toLowerCase()}`, + email, + emailVerified: true, + name: env.DEV_AUTH_NAME ?? "Local Developer", + avatarUrl: null, + }, + }; + } + if (provider === "google") { if (!loginState.codeVerifier) { throw new BadRequestError("Invalid sign-in request."); diff --git a/central/auth/store.ts b/central/auth/store.ts index 2d22d55..d0e6283 100644 --- a/central/auth/store.ts +++ b/central/auth/store.ts @@ -7,7 +7,7 @@ import { import { nanoid } from "nanoid"; import { createToken, hashToken } from "./crypto"; -export type AuthProvider = "google" | "github" | "apple"; +export type AuthProvider = "google" | "github" | "apple" | "dev"; export type AuthLinkedAccount = { provider: AuthProvider; diff --git a/central/env.ts b/central/env.ts index f0c2358..b7c0424 100644 --- a/central/env.ts +++ b/central/env.ts @@ -30,6 +30,14 @@ const envSchema = sharedEnvSchema.extend({ APPLE_CLIENT_ID: z.string().optional(), APPLE_TEAM_ID: z.string().optional(), APPLE_KEY_ID: z.string().optional(), + /** + * Enables the `dev` sign-in provider, which signs in as this address without + * contacting any external identity provider. Ignored unless NODE_ENV is + * `dev`, so setting it on a production instance does nothing. + */ + DEV_AUTH_EMAIL: z.email().optional(), + /** Display name for the `dev` provider's user. */ + DEV_AUTH_NAME: z.string().optional(), }); export const env = envSchema.parse(process.env); diff --git a/docs/oauth-flow.md b/docs/oauth-flow.md index d7de12e..7b4e795 100644 --- a/docs/oauth-flow.md +++ b/docs/oauth-flow.md @@ -21,6 +21,14 @@ Provider settings: For Apple, the private key is the PKCS#8 `.p8` file from Apple Developer. Place it at `server/apple_key.p8`; this file is ignored by git and read directly by the server. +### Local development without an OAuth application + +Registering a Google, GitHub or Apple application is a prerequisite for signing in at all, which makes a fresh checkout impossible to run end to end. For that case there is a fourth provider, `dev`. + +Set `DEV_AUTH_EMAIL` (and optionally `DEV_AUTH_NAME`) and a `dev` entry appears in `GET /auth/providers`. Choosing it signs you in as that address: no external identity provider is contacted, the "authorization URL" is the server's own callback, and everything after the callback (single-use state, exchange codes, browser cookies, deep links) is the same code path the real providers take. The account is upserted by email like any other, so it behaves as a normal user afterwards. + +Two independent gates keep it out of production. It is enabled only when `NODE_ENV` is `dev`, which the `central:start` script hardcodes to `prod`, and only when `DEV_AUTH_EMAIL` is set. While disabled it is omitted from `GET /auth/providers` entirely, so a production response is unchanged. Note that `NODE_ENV=dev` already relaxes host verification, CORS and the trusted-web-origin check, so an instance running in that mode was never safe to expose; this provider does not widen that. + Provider redirect URLs to register: - Google: `{AUTH_PUBLIC_BASE_URL}/auth/oauth/google/callback` diff --git a/readme.md b/readme.md index c8029a6..7c551c3 100644 --- a/readme.md +++ b/readme.md @@ -62,6 +62,8 @@ pnpm run relay:pm2:restart See [docs/oauth-flow.md](docs/oauth-flow.md) for provider setup, token lifecycle, and the app/WebSocket authentication flow. +To run a checkout end to end without registering an OAuth application, set `DEV_AUTH_EMAIL` in `.env`. A `dev` provider then appears and signs you in as that address. It is ignored unless `NODE_ENV` is `dev`. + ## App WebSocket Authentication The app no longer sends access tokens or device metadata in the `/app` WebSocket URL. Instead: