diff --git a/.env.example b/.env.example
index b50a789..938c95f 100644
--- a/.env.example
+++ b/.env.example
@@ -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.
diff --git a/Dockerfile b/Dockerfile
index f49444e..8de1ad4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index 57fe1ec..9363550 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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}
diff --git a/src/app/signin/page.tsx b/src/app/signin/page.tsx
index fd5bc1f..fcc9b98 100644
--- a/src/app/signin/page.tsx
+++ b/src/app/signin/page.tsx
@@ -19,7 +19,12 @@ function SignInContent() {
// page the user has just typed their password into.
const redirectTo = safeRedirectTarget(searchParams.get("redirect_url"));
- return ;
+ // Auth.js sends failures back here as ?error=, 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 (
+
+ );
}
export default function SignInPage() {
diff --git a/src/components/auth/SignInForm.tsx b/src/components/auth/SignInForm.tsx
index 1c90d29..3a3b41e 100644
--- a/src/components/auth/SignInForm.tsx
+++ b/src/components/auth/SignInForm.tsx
@@ -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.
@@ -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=` 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("password");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -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");
@@ -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")}>
Continue with Google
@@ -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")}>
Continue with GitHub
@@ -199,15 +222,19 @@ export function SignInForm({ redirectTo }: { redirectTo: string }) {
)}
- {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.