Skip to content
Open
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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 51 additions & 2 deletions central/auth/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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.");
Expand Down
2 changes: 1 addition & 1 deletion central/auth/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions central/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
8 changes: 8 additions & 0 deletions docs/oauth-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down