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") }); +}