diff --git a/README.md b/README.md
index 7ef51e9..8089d45 100644
--- a/README.md
+++ b/README.md
@@ -124,6 +124,8 @@ the pull request closes. Both actions run the `deploy` package script. Keep its
`pnpm run deploy` runs `npx convex deploy` with the checks, the build, and the upload as its
`--cmd`, so a failed check stops the release before the push switches the site. Inside that build,
+`scripts/register-hub-client.ts` registers a deployment with an identity hub when `HUB_ISSUER` and
+`HUB_REGISTRATION_TOKEN` are set; see docs/identity-hub.md.
`scripts/ensure-convex-auth.ts` creates the Convex Auth keys on a new deployment or preview and
keeps existing keys; it stops the deploy when the deployment it selects is not the one that the
deploy targets. Deploy with `pnpm run deploy`, never with a plain `npx convex deploy`: a plain
diff --git a/docs/identity-hub.md b/docs/identity-hub.md
new file mode 100644
index 0000000..7f46225
--- /dev/null
+++ b/docs/identity-hub.md
@@ -0,0 +1,93 @@
+# Sign in through an identity hub
+
+An [identity hub](https://github.com/samebase/identity-hub) is one Convex deployment that holds a
+team's GitHub, Google and email connections and signs users in to all its apps. This starter can
+be one of those apps: each deployment registers itself with the hub at deploy time, so previews get
+GitHub sign-in without a GitHub app of their own.
+
+The hub is optional. Without `HUB_ISSUER` and `HUB_REGISTRATION_TOKEN` in the environment, the
+deploy scripts skip registration and nothing below is needed.
+
+## 1. Register the app at the hub
+
+On the hub:
+
+```sh
+npx convex run --prod identityBroker:createApp '{"name":"My app","firstParty":true,"allowedCallbackHosts":["*.convex.site","127.0.0.1"]}'
+npx convex run --prod identityBroker:mintRegistrationToken '{"appId":"my-app-ab12c"}'
+```
+
+Store the token as the `HUB_REGISTRATION_TOKEN` secret of this repository and `HUB_ISSUER` as a
+variable, next to the deploy keys. Locally, put both in `.env.local`. The token never goes on a
+deployment: `scripts/register-hub-client.ts` reads it, registers the deployment's callback, and
+stores only the resulting `HUB_CLIENT_ID` and `HUB_CLIENT_SECRET` on that deployment.
+
+## 2. Mount the hub as a sign-in method
+
+`convex/convex.config.ts`: declare the variables and install the generic OAuth component of Convex
+Auth once, under the `/oauth/hub` prefix. The component needs them on every deployment, which the
+registration script guarantees.
+
+```ts
+import oauth from "@convex-dev/auth/components/oauth/convex.config";
+
+const app = defineApp({
+ env: {
+ AUTH_PRIVATE_KEY: v.string(),
+ AUTH_JWKS: v.string(),
+ HUB_ISSUER: v.string(),
+ HUB_CLIENT_ID: v.string(),
+ HUB_CLIENT_SECRET: v.string(),
+ },
+});
+app.use(oauth, {
+ name: "authHub",
+ httpPrefix: "/oauth/hub",
+ env: { CLIENT_ID: app.env.HUB_CLIENT_ID, CLIENT_SECRET: app.env.HUB_CLIENT_SECRET },
+});
+```
+
+`convex/auth.ts`: the hub is a plain OpenID Provider, so the catalog is three URLs and a mapping
+from the `id_token` claims. `sub` is pairwise per app: stable for this app, different elsewhere.
+
+```ts
+import { setupOauth } from "@convex-dev/auth/methods/oauth/server";
+import { env } from "./_generated/server";
+
+export const { startSignIn: startSignInWithHub, completeSignIn: completeSignInWithHub } =
+ setupOauth(
+ auth,
+ "hub",
+ {
+ authorizationEndpoint: `${env.HUB_ISSUER}/authorize`,
+ tokenEndpoint: `${env.HUB_ISSUER}/token`,
+ issuer: env.HUB_ISSUER,
+ scopes: ["openid", "email", "profile"],
+ profile: (claims) => ({
+ id: claims.sub,
+ email: typeof claims.email === "string" ? claims.email : undefined,
+ name: typeof claims.name === "string" ? claims.name : undefined,
+ }),
+ },
+ { createUser: internal.users.createUser, onSignIn: internal.users.onSignIn },
+ { component: components.authHub, allowedRedirectOrigins: [env.SITE_URL] },
+ );
+```
+
+`createUser` receives `{ provider: { name: "hub", accountId, profile } }`: `accountId` is the hub
+subject. Create the `users` row from the profile the first time and return its id.
+
+## 3. Add the button
+
+```tsx
+import { useOauthSignIn } from "@convex-dev/auth/methods/oauth/react";
+
+const { signIn } = useOauthSignIn({
+ start: api.auth.startSignInWithHub,
+ complete: api.auth.completeSignInWithHub,
+});
+;
+```
+
+The hub shows its own chooser, or goes straight to the one connector it has. Add `connector=github`
+to the authorization request to skip the chooser for a first-party app.
diff --git a/package.json b/package.json
index a357dcd..f5de6fa 100644
--- a/package.json
+++ b/package.json
@@ -16,8 +16,8 @@
"dev:worktree": "node ./scripts/run-worktree-dev.ts",
"check": "vp check && vp run typecheck && vp test",
"build": "vp run check && vp build",
- "deploy": "convex-hosting deploy --build \"vp run build && node ./scripts/ensure-convex-auth.ts --prod\" --dist dist/client --html-handling drop-trailing-slash",
- "deploy:dev": "convex-hosting deploy --dev --build \"vp run build && node ./scripts/ensure-convex-auth.ts\" --dist dist/client --html-handling drop-trailing-slash",
+ "deploy": "convex-hosting deploy --build \"vp run build && node ./scripts/ensure-convex-auth.ts --prod && node ./scripts/register-hub-client.ts --prod\" --dist dist/client --html-handling drop-trailing-slash",
+ "deploy:dev": "convex-hosting deploy --dev --build \"vp run build && node ./scripts/ensure-convex-auth.ts && node ./scripts/register-hub-client.ts\" --dist dist/client --html-handling drop-trailing-slash",
"format": "vp fmt",
"format:check": "vp fmt --check",
"lint": "vp lint --report-unused-disable-directives",
diff --git a/scripts/register-hub-client.test.ts b/scripts/register-hub-client.test.ts
new file mode 100644
index 0000000..ede9317
--- /dev/null
+++ b/scripts/register-hub-client.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, test, vi } from "vite-plus/test";
+import { registerHubClient, selectDeployment } from "./register-hub-client.ts";
+
+function fakeConvex(initial: Record) {
+ const env = { ...initial };
+ const calls: string[][] = [];
+ const runConvex = async (args: string[]) => {
+ calls.push(args);
+ if (args[0] === "env" && args[1] === "get") {
+ return { code: 0, stdout: `${env[args[2] ?? ""] ?? ""}\n` };
+ }
+ if (args[0] === "env" && args[1] === "set") {
+ const separator = args.indexOf("--");
+ env[args[separator + 1] ?? ""] = args[separator + 2] ?? "";
+ return { code: 0, stdout: "" };
+ }
+ if (args[0] === "run") {
+ return { code: 0, stdout: JSON.stringify("https://happy-otter-123.convex.site") };
+ }
+ throw new Error(`unexpected convex ${args.join(" ")}`);
+ };
+ return { env, calls, runConvex };
+}
+
+const hubEnv = {
+ HUB_ISSUER: "https://auth.test/",
+ HUB_REGISTRATION_TOKEN: "rt_token",
+ HUB_CLIENT_NAME: "Starter",
+};
+
+describe("selectDeployment", () => {
+ test("preview key, production flag, development", () => {
+ expect(
+ selectDeployment({ CONVEX_DEPLOY_KEY: "preview:abc", GITHUB_HEAD_REF: "feature" }, false),
+ ).toEqual({ selectionArgs: ["--preview-name", "feature"], deploymentType: "preview" });
+ expect(selectDeployment({}, true)).toEqual({
+ selectionArgs: ["--prod"],
+ deploymentType: "production",
+ });
+ expect(selectDeployment({}, false)).toEqual({
+ selectionArgs: [],
+ deploymentType: "development",
+ });
+ expect(selectDeployment({ CONVEX_DEPLOY_KEY: "prod:abc" }, false).deploymentType).toBe(
+ "production",
+ );
+ });
+});
+
+describe("registerHubClient", () => {
+ test("does nothing without the hub variables", async () => {
+ const convex = fakeConvex({});
+ expect(await registerHubClient({}, { runConvex: convex.runConvex })).toBeNull();
+ expect(convex.calls).toEqual([]);
+ });
+
+ test("refuses half a configuration", async () => {
+ await expect(registerHubClient({ HUB_ISSUER: "https://auth.test" })).rejects.toThrow(
+ /together/,
+ );
+ });
+
+ test("registers the deployment's callback once and stores the credentials", async () => {
+ const convex = fakeConvex({});
+ const fetchSpy = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const request = new Request(input, init);
+ expect(request.url).toBe("https://auth.test/register");
+ expect(request.headers.get("authorization")).toBe("Bearer rt_token");
+ const body = JSON.parse(await request.text());
+ expect(body).toEqual({
+ redirect_uris: ["https://happy-otter-123.convex.site/oauth/hub/callback"],
+ client_name: "Starter preview",
+ software_version: "happy-otter-123",
+ deployment_type: "preview",
+ });
+ return Response.json({ client_id: "cl_1", client_secret: "s3cret" }, { status: 201 });
+ });
+ const registration = await registerHubClient(
+ { ...hubEnv, CONVEX_DEPLOY_KEY: "preview:abc", GITHUB_HEAD_REF: "feature" },
+ { runConvex: convex.runConvex, fetch: fetchSpy },
+ );
+ expect(registration).toEqual({
+ clientId: "cl_1",
+ clientSecret: "s3cret",
+ callbackUrl: "https://happy-otter-123.convex.site/oauth/hub/callback",
+ });
+ expect(convex.env).toMatchObject({
+ HUB_ISSUER: "https://auth.test",
+ HUB_CLIENT_ID: "cl_1",
+ HUB_CLIENT_SECRET: "s3cret",
+ });
+ expect(convex.calls[0]).toEqual(["env", "get", "HUB_CLIENT_ID", "--preview-name", "feature"]);
+
+ // The second run finds the client id and stops.
+ fetchSpy.mockClear();
+ expect(
+ await registerHubClient(
+ { ...hubEnv, CONVEX_DEPLOY_KEY: "preview:abc", GITHUB_HEAD_REF: "feature" },
+ { runConvex: convex.runConvex, fetch: fetchSpy },
+ ),
+ ).toBeNull();
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ test("a refused registration is an error with the hub's reason", async () => {
+ const convex = fakeConvex({});
+ const fetchSpy = vi.fn(async () =>
+ Response.json(
+ { error: "invalid_redirect_uri", error_description: "host not allowed" },
+ { status: 400 },
+ ),
+ );
+ await expect(
+ registerHubClient(hubEnv, { runConvex: convex.runConvex, fetch: fetchSpy, production: true }),
+ ).rejects.toThrow(/400: invalid_redirect_uri host not allowed/);
+ expect(convex.env["HUB_CLIENT_ID"]).toBeUndefined();
+ });
+});
diff --git a/scripts/register-hub-client.ts b/scripts/register-hub-client.ts
new file mode 100644
index 0000000..0d1036c
--- /dev/null
+++ b/scripts/register-hub-client.ts
@@ -0,0 +1,141 @@
+// Registers the selected Convex deployment as a client of the team's identity hub, once, and stores
+// the client credentials on that deployment. Opt-in: without HUB_ISSUER and HUB_REGISTRATION_TOKEN
+// in the environment it does nothing. The deploy scripts run it after the deployment exists; a
+// preview deploy key selects the preview, `--prod` the production deployment, nothing the
+// development deployment of this checkout. The registration token stays where the deploy key is:
+// CI secrets or .env.local, never on a deployment.
+///
+import { spawn } from "node:child_process";
+import process from "node:process";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const CONVEX_CLI_PATH = fileURLToPath(
+ new URL("../node_modules/convex/bin/main.js", import.meta.url),
+);
+const CALLBACK_PATH = "/oauth/hub/callback";
+
+type RunResult = { code: number; stdout: string };
+type RunConvex = (args: string[], options?: { sensitive?: boolean }) => Promise;
+
+function runConvexCli(args: string[], options: { sensitive?: boolean } = {}) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [CONVEX_CLI_PATH, ...args], {
+ env: process.env,
+ stdio: ["ignore", "pipe", "inherit"],
+ });
+ const chunks: Buffer[] = [];
+ child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
+ child.on("error", reject);
+ child.on("close", (code) => {
+ const result = { code: code ?? 1, stdout: Buffer.concat(chunks).toString("utf8") };
+ if (result.code === 0) {
+ resolve(result);
+ return;
+ }
+ const label = options.sensitive ? "convex env set" : `convex ${args.join(" ")}`;
+ reject(new Error(`${label} failed with exit code ${result.code}`));
+ });
+ });
+}
+
+export type Registration = {
+ clientId: string;
+ clientSecret: string;
+ callbackUrl: string;
+};
+
+/** The deployment selection and type, from the same signals as scripts/ensure-convex-auth.ts. */
+export function selectDeployment(env: NodeJS.ProcessEnv, production: boolean) {
+ const deployKey = env["CONVEX_DEPLOY_KEY"];
+ if (deployKey?.startsWith("preview:")) {
+ const previewName = env["GITHUB_HEAD_REF"];
+ if (!previewName) {
+ throw new Error("A preview deploy key needs GITHUB_HEAD_REF, the name of its preview.");
+ }
+ return { selectionArgs: ["--preview-name", previewName], deploymentType: "preview" as const };
+ }
+ if (!deployKey && production) {
+ return { selectionArgs: ["--prod"], deploymentType: "production" as const };
+ }
+ return {
+ selectionArgs: [],
+ deploymentType: deployKey ? ("production" as const) : ("development" as const),
+ };
+}
+
+export async function registerHubClient(
+ env: NodeJS.ProcessEnv,
+ options: { runConvex?: RunConvex; fetch?: typeof fetch; production?: boolean } = {},
+): Promise {
+ const runConvex = options.runConvex ?? runConvexCli;
+ const fetchImpl = options.fetch ?? fetch;
+ const issuer = env["HUB_ISSUER"]?.replace(/\/$/, "");
+ const token = env["HUB_REGISTRATION_TOKEN"];
+ if (!issuer && !token) {
+ console.log("Hub registration skipped: HUB_ISSUER and HUB_REGISTRATION_TOKEN are not set.");
+ return null;
+ }
+ if (!issuer || !token) {
+ throw new Error("HUB_ISSUER and HUB_REGISTRATION_TOKEN must be set together.");
+ }
+ const { selectionArgs, deploymentType } = selectDeployment(env, options.production === true);
+
+ const existing = await runConvex(["env", "get", "HUB_CLIENT_ID", ...selectionArgs]);
+ if (existing.stdout.trim().length > 0) {
+ console.log("Hub client already registered.");
+ return null;
+ }
+
+ const siteResult = await runConvex([
+ "run",
+ "--inline-query",
+ "process.env.CONVEX_SITE_URL",
+ ...selectionArgs,
+ ]);
+ const siteUrl: unknown = JSON.parse(siteResult.stdout);
+ if (typeof siteUrl !== "string") {
+ throw new Error("Could not read CONVEX_SITE_URL of the selected deployment.");
+ }
+ const callbackUrl = `${siteUrl.replace(/\/$/, "")}${CALLBACK_PATH}`;
+ const deploymentName = new URL(siteUrl).hostname.split(".")[0] ?? siteUrl;
+
+ const response = await fetchImpl(`${issuer}/register`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
+ body: JSON.stringify({
+ redirect_uris: [callbackUrl],
+ client_name: `${env["HUB_CLIENT_NAME"] ?? "app"} ${deploymentType}`,
+ software_version: deploymentName,
+ deployment_type: deploymentType,
+ }),
+ });
+ const body: unknown = await response.json();
+ const record =
+ typeof body === "object" && body !== null ? Object.fromEntries(Object.entries(body)) : {};
+ if (
+ response.status !== 201 ||
+ typeof record["client_id"] !== "string" ||
+ typeof record["client_secret"] !== "string"
+ ) {
+ throw new Error(
+ `Hub registration failed with ${response.status}: ${String(record["error"] ?? "")} ${String(record["error_description"] ?? "")}`.trim(),
+ );
+ }
+ await runConvex(["env", "set", ...selectionArgs, "--", "HUB_ISSUER", issuer], {
+ sensitive: true,
+ });
+ await runConvex(["env", "set", ...selectionArgs, "--", "HUB_CLIENT_ID", record["client_id"]], {
+ sensitive: true,
+ });
+ await runConvex(
+ ["env", "set", ...selectionArgs, "--", "HUB_CLIENT_SECRET", record["client_secret"]],
+ { sensitive: true },
+ );
+ console.log(`Hub client registered for ${callbackUrl}.`);
+ return { clientId: record["client_id"], clientSecret: record["client_secret"], callbackUrl };
+}
+
+const entrypoint = process.argv[1];
+if (entrypoint && import.meta.url === pathToFileURL(entrypoint).href) {
+ await registerHubClient(process.env, { production: process.argv.includes("--prod") });
+}