Skip to content
Merged
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
29 changes: 29 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,20 @@ jobs:
fi
}

# Whether the running image can actually sign anyone in. Absent on any
# image built before this check existed, which reads as empty and is
# deliberately not treated as a failure -- an old image has no opinion.
read_auth() {
if command -v jq >/dev/null 2>&1; then
# NOT `.integrations.auth // empty`. jq's // treats false as unset,
# so that form returns empty for auth:false -- the one case this
# check exists to catch -- and the gate would pass silently.
jq -r 'if (.integrations | has("auth")) then (.integrations.auth | tostring) else "" end' 2>/dev/null
else
sed -n 's/.*"auth":\(true\|false\).*/\1/p'
fi
}

# Watchtower polls every WATCHTOWER_POLL_INTERVAL seconds (300 by
# default), so 12 minutes gives it at least two chances plus time to
# pull an ARM image and restart.
Expand All @@ -166,6 +180,21 @@ jobs:

if [ -n "$live" ] && [ "$live" = "$expected" ]; then
echo "::notice::Rollout confirmed after ${SECONDS}s — ${APP_URL} is serving ${expected}."

# Shipping is not the same as working, in the same way that
# publishing was not the same as shipping. An image that reaches
# the VM before the Auth.js variables are set boots, serves pages
# and reports healthy while nobody can log in -- so a green deploy
# here would be the second lie this job exists to stop telling.
auth="$(printf '%s' "$body" | read_auth)"

if [ "$auth" = "false" ]; then
echo "::error::${APP_URL} is serving ${expected}, but it cannot sign anyone in."
echo "::error::The Auth.js environment variables are not set on the VM. The container log names the missing ones; it does not publish them, because /api/health is public."
echo "::error::Set AUTH_SECRET, AUTH_ADAPTER_SECRET and AUTH_JWT_PRIVATE_KEY / AUTH_JWT_PUBLIC_KEY / AUTH_JWT_KID, then redeploy. AUTH_ADAPTER_SECRET must be the SAME value in the Convex environment."
exit 1
fi

exit 0
fi

Expand Down
23 changes: 23 additions & 0 deletions src/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getValidatedServerEnv } from "@/lib/env";
import { getAuthReadiness } from "@/lib/auth/readiness";
import { getBuildVersion } from "@/lib/buildInfo";

export async function GET() {
Expand All @@ -12,6 +13,27 @@ export async function GET() {
try {
const env = getValidatedServerEnv();

/**
* Whether anyone can actually sign in.
*
* Not covered by the flags below, all of which predate Auth.js. An image
* that switches sign-in to Auth.js can reach a deployment before anyone
* sets the Auth.js variables, and the result is an app that boots, serves
* pages and reports healthy while nobody can log in.
*
* Reported as a bare boolean on purpose. This endpoint is public — the
* container healthcheck polls it with no session — and publishing the names
* of the secrets a deployment is missing is a map for anyone probing it.
* The names go to the server log instead, where an operator can act on them.
*/
const auth = getAuthReadiness(process.env);

if (!auth.ready) {
console.error(
`[health] auth is not configured; sign-in will fail. Missing: ${auth.missing.join(", ")}`,
);
}

return NextResponse.json({
status: "healthy",
version,
Expand All @@ -21,6 +43,7 @@ export async function GET() {
convex: !!env.NEXT_PUBLIC_CONVEX_URL,
stream: !!env.NEXT_PUBLIC_STREAM_API_KEY && !!env.STREAM_SECRET_KEY,
webhooks: !!env.CLERK_WEBHOOK_SECRET,
auth: auth.ready,
},
});
} catch (error) {
Expand Down
64 changes: 64 additions & 0 deletions src/lib/auth/readiness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { getAuthReadiness } from "./readiness.ts";

const complete = {
AUTH_SECRET: "s",
AUTH_ADAPTER_SECRET: "s",
AUTH_JWT_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----",
AUTH_JWT_PUBLIC_KEY: "-----BEGIN PUBLIC KEY-----",
AUTH_JWT_KID: "k1",
};

describe("getAuthReadiness", () => {
it("is ready when all five are set", () => {
assert.deepEqual(getAuthReadiness(complete), { ready: true, missing: [] });
});

it("reports every missing name rather than stopping at the first", () => {
// An operator setting one variable per deploy is the slowest possible way
// to fix this, and it is what a first-failure-only report produces.
const result = getAuthReadiness({});

assert.equal(result.ready, false);
assert.deepEqual(result.missing, [
"AUTH_SECRET",
"AUTH_ADAPTER_SECRET",
"AUTH_JWT_PRIVATE_KEY",
"AUTH_JWT_PUBLIC_KEY",
"AUTH_JWT_KID",
]);
});

it("treats whitespace as unset", () => {
// An easy thing to paste by accident, and identical to unset for all five.
const result = getAuthReadiness({ ...complete, AUTH_JWT_KID: " " });

assert.equal(result.ready, false);
assert.deepEqual(result.missing, ["AUTH_JWT_KID"]);
});

it("treats an empty string as unset", () => {
const result = getAuthReadiness({ ...complete, AUTH_SECRET: "" });

assert.equal(result.ready, false);
assert.deepEqual(result.missing, ["AUTH_SECRET"]);
});

it("does not require the OAuth client IDs", () => {
// A deployment with no Google app is degraded, not broken: credentials and
// magic-link sign-in still work. Firing this flag for something a user can
// work around would make it mean less when it fires for something they
// cannot.
assert.equal(getAuthReadiness(complete).ready, true);
});

it("never returns a value, only a name", () => {
const secret = "super-secret-value";
const result = getAuthReadiness({ ...complete, AUTH_SECRET: undefined, OTHER: secret });

assert.deepEqual(result.missing, ["AUTH_SECRET"]);
assert.ok(!JSON.stringify(result).includes(secret));
});
});
55 changes: 55 additions & 0 deletions src/lib/auth/readiness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Whether this deployment can actually sign anyone in.
*
* Written for a specific hazard in the Clerk migration. The image that switches
* sign-in to Auth.js can reach the VM before anyone sets the Auth.js
* environment variables, and the failure mode is total: the app boots, serves
* pages, reports healthy, and nobody can log in. `/api/health` would have said
* "healthy" throughout, because it only ever checked the Clerk-era variables.
*
* So this is a pre-flight, not a diagnostic after the fact. Check it against a
* deployment *before* letting the new image roll out to it.
*
* Names only, never values. The caller logs the missing names server-side and
* publishes a boolean, because /api/health is public — it is polled by the
* container healthcheck, which carries no session. A public endpoint listing
* exactly which secrets a deployment is missing is a map for anyone probing it.
*/

export type AuthReadiness = {
ready: boolean;
/** Variable names, in the order an operator should set them. Never values. */
missing: string[];
};

/**
* The five that auth cannot function without.
*
* Deliberately not the OAuth client IDs. Credentials and magic-link sign-in work
* without them — a deployment with no Google app is degraded, not broken, and
* conflating the two would make this flag fire for something a user can work
* around. These five have no workaround:
*
* AUTH_SECRET signs and encrypts the session cookie
* AUTH_ADAPTER_SECRET every adapter call to Convex is rejected without it
* AUTH_JWT_PRIVATE_KEY mints the token Convex verifies
* AUTH_JWT_PUBLIC_KEY served at /.well-known/jwks.json for that verification
* AUTH_JWT_KID names the key in both halves; a mismatch rejects every token
*/
const REQUIRED = [
"AUTH_SECRET",
"AUTH_ADAPTER_SECRET",
"AUTH_JWT_PRIVATE_KEY",
"AUTH_JWT_PUBLIC_KEY",
"AUTH_JWT_KID",
] as const;

export const getAuthReadiness = (
env: Record<string, string | undefined>,
): AuthReadiness => {
// Trimmed, because an environment variable set to whitespace is the same as
// unset for every one of these and is an easy thing to paste by accident.
const missing = REQUIRED.filter((name) => !env[name]?.trim());

return { ready: missing.length === 0, missing: [...missing] };
};
Loading