Skip to content
Closed
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 93 additions & 0 deletions docs/identity-hub.md
Original file line number Diff line number Diff line change
@@ -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,
});
<button onClick={() => signIn({ redirectTo: window.location.origin })}>Sign in</button>;
```

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.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
118 changes: 118 additions & 0 deletions scripts/register-hub-client.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
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();
});
});
141 changes: 141 additions & 0 deletions scripts/register-hub-client.ts
Original file line number Diff line number Diff line change
@@ -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.
/// <reference types="node" />
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<RunResult>;

function runConvexCli(args: string[], options: { sensitive?: boolean } = {}) {
return new Promise<RunResult>((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<Registration | null> {
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") });
}
Loading