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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ AUTH_GITHUB_SECRET=replace-me
# compromises both.
AUTH_ADAPTER_SECRET=replace-with-a-long-random-secret

# Origin Auth.js builds redirect and error URLs from.
#
# Usually leave this unset. The Docker image defaults it to the
# NEXT_PUBLIC_APP_URL it was built with, which is what makes OAuth work behind a
# reverse proxy that does not forward the Host header -- without it, Auth.js
# resolves the origin to the container's bind address and sends the browser to
# https://0.0.0.0:3000/...
#
# Set it only when a deployment's public URL differs from the one baked into the
# image, or when running outside Docker behind a proxy.
# AUTH_URL=https://commit.example.com

# Set this on the Convex deployment with `npx convex env set SITE_URL ...`.
# It must match the `iss` claim of the tokens we mint, so no trailing slash.
# Keep it here only as a setup checklist item.
Expand Down
16 changes: 16 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME="0.0.0.0"

# Redeclared because ARGs do not cross build stages, and needed at RUNTIME here
# rather than at build time.
#
# HOSTNAME above is the bind address, and behind the VM's nginx it is also what
# Auth.js resolves the request origin to, because that proxy does not forward
# the Host header. Without AUTH_URL, every OAuth redirect and error URL Auth.js
# builds comes out as https://0.0.0.0:3000/... which the browser cannot reach,
# so a failed sign-in lands on ERR_ADDRESS_INVALID instead of on our sign-in
# page with the reason on it.
#
# Defaulting it from the build arg means this works behind any proxy with no
# extra variable to set, which is the fix docs/HANDOFF.md prefers over depending
# on `proxy_set_header Host $host`. docker-compose.yml can still override it.
ARG NEXT_PUBLIC_APP_URL
ENV AUTH_URL=$NEXT_PUBLIC_APP_URL

# Required so /api/execute can spawn ephemeral runtime containers via the
# mounted /var/run/docker.sock. Without this the spawn fails with ENOENT.
RUN apk add --no-cache docker-cli
Expand Down
26 changes: 26 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,32 @@ services:
SMTP_PASS: ${SMTP_PASS}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL}

# Auth.js — authentication
#
# Every one of these has to be listed here, not merely present in the VM's
# .env: compose passes nothing to a container that is not named in this
# block. Their absence is invisible from outside — the app boots, serves
# every page and reports healthy, and nobody can sign in.
AUTH_SECRET: ${AUTH_SECRET}
# Must be the SAME value as `npx convex env set AUTH_ADAPTER_SECRET` on the
# Convex deployment. A mismatch rejects every adapter call with a bare
# "Unauthorized", which surfaces as sign-in failing for everyone.
AUTH_ADAPTER_SECRET: ${AUTH_ADAPTER_SECRET}
AUTH_JWT_PRIVATE_KEY: ${AUTH_JWT_PRIVATE_KEY}
AUTH_JWT_PUBLIC_KEY: ${AUTH_JWT_PUBLIC_KEY}
AUTH_JWT_KID: ${AUTH_JWT_KID}
AUTH_GOOGLE_ID: ${AUTH_GOOGLE_ID}
AUTH_GOOGLE_SECRET: ${AUTH_GOOGLE_SECRET}
AUTH_GITHUB_ID: ${AUTH_GITHUB_ID}
AUTH_GITHUB_SECRET: ${AUTH_GITHUB_SECRET}
# Overrides the origin Auth.js builds redirect and error URLs from.
#
# The image already defaults this to the NEXT_PUBLIC_APP_URL it was built
# with, so it normally needs no value here. Left overridable because the
# image default is baked at build time and a deployment may need to differ
# from it without a rebuild.
AUTH_URL: ${AUTH_URL:-}

# App
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
Expand Down
7 changes: 6 additions & 1 deletion src/app/signin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ function SignInContent() {
// page the user has just typed their password into.
const redirectTo = safeRedirectTarget(searchParams.get("redirect_url"));

return <SignInForm redirectTo={redirectTo} />;
// Auth.js sends failures back here as ?error=<code>, because pages.error
// points at this route. Without passing it on, the page renders identically
// to a fresh visit and the failure is invisible.
return (
<SignInForm redirectTo={redirectTo} errorCode={searchParams.get("error")} />
);
}

export default function SignInPage() {
Expand Down
39 changes: 33 additions & 6 deletions src/components/auth/SignInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { GithubIcon, GoogleIcon } from "@/components/auth/ProviderIcons";
import { describeSignInError } from "@/lib/auth/signInError";

/**
* The sign-in form.
Expand All @@ -30,7 +31,20 @@ const GENERIC_CREDENTIAL_ERROR =

type Mode = "password" | "link";

export function SignInForm({ redirectTo }: { redirectTo: string }) {
export function SignInForm({
redirectTo,
/**
* Auth.js error code from the query string.
*
* `pages.error` points back at this page, so a failed OAuth round trip
* returns here as `?error=<code>` and nothing else. Rendering it is what
* separates "the provider refused" from "the button did nothing".
*/
errorCode,
}: {
redirectTo: string;
errorCode?: string | null;
}) {
const [mode, setMode] = useState<Mode>("password");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
Expand All @@ -40,6 +54,15 @@ export function SignInForm({ redirectTo }: { redirectTo: string }) {

const busy = pending !== null;

// An error from the redirect, shown until the user does something that could
// clear it. A local error from a submit takes precedence, because it is the
// more recent thing that happened.
const inbound = describeSignInError(errorCode);
const shownError = error ?? inbound?.message ?? null;
// Nothing here can succeed while the server is misconfigured, so the form is
// not offered as though it might.
const disableSubmit = busy || inbound?.retryable === false;

const startOAuth = async (provider: "google" | "github") => {
setError(null);
setPending("oauth");
Expand Down Expand Up @@ -137,7 +160,7 @@ export function SignInForm({ redirectTo }: { redirectTo: string }) {
variant="outline"
size="lg"
className="w-full justify-center"
disabled={busy}
disabled={disableSubmit}
onClick={() => startOAuth("google")}>
<GoogleIcon className="size-4" aria-hidden="true" />
Continue with Google
Expand All @@ -147,7 +170,7 @@ export function SignInForm({ redirectTo }: { redirectTo: string }) {
variant="outline"
size="lg"
className="w-full justify-center"
disabled={busy}
disabled={disableSubmit}
onClick={() => startOAuth("github")}>
<GithubIcon className="size-4" aria-hidden="true" />
Continue with GitHub
Expand Down Expand Up @@ -199,15 +222,19 @@ export function SignInForm({ redirectTo }: { redirectTo: string }) {
</div>
)}

{error && (
{shownError && (
// role="alert" so it is announced. A sighted user sees it appear; a
// screen-reader user otherwise gets no indication the submit failed.
<p role="alert" className="text-sm text-destructive">
{error}
{shownError}
</p>
)}

<Button type="submit" size="lg" className="w-full justify-center" disabled={busy}>
<Button
type="submit"
size="lg"
className="w-full justify-center"
disabled={disableSubmit}>
{busy && <LoaderCircleIcon className="size-4 animate-spin" aria-hidden="true" />}
{mode === "password" ? "Sign in" : "Email me a sign-in link"}
</Button>
Expand Down
60 changes: 60 additions & 0 deletions src/lib/auth/signInError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { describeSignInError } from "./signInError.ts";

describe("describeSignInError", () => {
it("returns nothing when there is no error", () => {
assert.equal(describeSignInError(null), null);
assert.equal(describeSignInError(undefined), null);
assert.equal(describeSignInError(""), null);
});

it("says a Configuration error is ours and not retryable", () => {
const result = describeSignInError("Configuration");

// The case that produced this module: a deployment missing its Auth.js
// variables bounces every OAuth attempt back here. Telling the user to try
// again sends them into a loop that cannot succeed.
assert.equal(result?.retryable, false);
assert.match(result!.message, /not something you can fix/i);
});

it("explains the way forward when linking was refused", () => {
// AccessDenied is what our own signIn callback produces when it refuses to
// attach an OAuth identity to an existing account.
const result = describeSignInError("AccessDenied");

assert.equal(result?.retryable, false);
assert.match(result!.message, /sign in the way you did originally|reset your password/i);
});

it("treats an expired magic link as retryable", () => {
const result = describeSignInError("Verification");

assert.equal(result?.retryable, true);
assert.match(result!.message, /expired|already been used/i);
});

it("falls back for a code it does not know", () => {
// Auth.js adds codes between versions, and rendering the raw code on a
// login page is noise to everyone who is not us.
const result = describeSignInError("OAuthCallbackError");

assert.equal(result?.retryable, true);
assert.equal(result?.message, "We could not sign you in. Please try again.");
});

it("never echoes the raw code back to the user", () => {
const result = describeSignInError("SomethingInternal_v5");

assert.ok(!result!.message.includes("SomethingInternal_v5"));
});

it("ignores surrounding whitespace in the code", () => {
assert.equal(
describeSignInError(" Configuration ")?.retryable,
false,
);
});
});
83 changes: 83 additions & 0 deletions src/lib/auth/signInError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Turns an Auth.js error code into something a person can act on.
*
* `pages.error` points at /signin, so a failed sign-in arrives back here as
* `?error=<code>` and nothing else. Without this the page renders as though
* nothing happened — the user clicks "Continue with Google", is bounced through
* the provider, and lands on an identical sign-in form with no explanation.
* That is indistinguishable from the button being broken.
*
* The codes are Auth.js's own. Two matter more than the rest here:
*
* `Configuration` means the server is misconfigured — a missing client secret,
* a missing AUTH_SECRET. It is nobody's fault but ours, and saying "try again"
* would send someone into a loop that cannot succeed.
*
* `AccessDenied` is what our own signIn callback produces when it refuses to
* link an OAuth identity to an existing account (see mayLinkToExistingUser).
* That refusal is deliberate and the message has to explain the way forward
* rather than read as a generic failure.
*/

export type SignInErrorMessage = {
/** Shown to the user. */
message: string;
/**
* Whether retrying could plausibly work.
*
* False for server misconfiguration, where a retry button is an invitation to
* click it repeatedly and get the same result.
*/
retryable: boolean;
};

const MESSAGES: Record<string, SignInErrorMessage> = {
Configuration: {
message:
"Sign-in is not configured correctly on our side. This is not something you can fix — please try again later or contact support.",
retryable: false,
},
AccessDenied: {
message:
"We could not use that account to sign you in. If you already have a Commit account with this email address, sign in the way you did originally, or reset your password.",
retryable: false,
},
Verification: {
message:
"That sign-in link has expired or has already been used. Request a new one.",
retryable: true,
},
OAuthAccountNotLinked: {
message:
"An account already exists for this email address using a different sign-in method. Use that method, or reset your password to add one.",
retryable: false,
},
EmailSignin: {
message: "We could not send the sign-in link. Please try again.",
retryable: true,
},
CredentialsSignin: {
message: "That email and password do not match an account.",
retryable: true,
},
SessionRequired: {
message: "Please sign in to continue.",
retryable: true,
},
};

const FALLBACK: SignInErrorMessage = {
message: "We could not sign you in. Please try again.",
retryable: true,
};

export const describeSignInError = (
code: string | null | undefined,
): SignInErrorMessage | null => {
if (!code) return null;

// Unknown codes fall back rather than rendering the raw code. Auth.js adds
// new ones between versions, and "OAuthCallbackError" on a login page is
// noise to everyone who is not us.
return MESSAGES[code.trim()] ?? FALLBACK;
};
Loading