From e8654ed0e36d2283e2479f518172d819c8311f07 Mon Sep 17 00:00:00 2001 From: Jan Karres Date: Wed, 19 Aug 2026 08:40:08 +0200 Subject: [PATCH] feat(auth): add a development sign-in provider A fresh checkout cannot be signed into. Every provider needs credentials from Google, GitHub or Apple, so `GET /auth/providers` returns three disabled entries and the app stops at its login gate. Running the stack end to end is therefore gated on registering an OAuth application, which is a lot of setup for someone who only wants to see their change work. Set DEV_AUTH_EMAIL and a fourth provider, `dev`, appears. Choosing it signs you in as that address: nothing external 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. Two independent gates keep it out of production. It is enabled only when NODE_ENV is `dev`, which `central:start` hardcodes to `prod`, and only when DEV_AUTH_EMAIL is set. While disabled it is filtered out of `GET /auth/providers` entirely, so a production response is byte-identical to what it was before. --- .env.example | 7 ++++++ central/auth/providers.ts | 53 +++++++++++++++++++++++++++++++++++++-- central/auth/store.ts | 2 +- central/env.ts | 8 ++++++ docs/oauth-flow.md | 8 ++++++ readme.md | 2 ++ 6 files changed, 77 insertions(+), 3 deletions(-) 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: