From 36dd5102ad9bb021045c13dba9e5067eaf4ceb0b Mon Sep 17 00:00:00 2001 From: Kunj Hirapara Date: Sun, 13 Sep 2026 20:32:22 +0530 Subject: [PATCH] fix(auth): pass the Auth.js variables to the container and fix redirect origins Three faults, found from one screenshot of a failed Google sign-in on production. Sign-in could not have worked with any of them present. docker-compose.yml never passed a single AUTH_* variable to the app service. Compose gives a container nothing that is not named in its environment block, so setting them in the VM's .env would have changed nothing and looked like the values were wrong rather than absent. This is the one that made the outage unfixable from the operator's side. Auth.js built its redirect and error URLs from the request origin, which behind the VM's nginx resolves to the container's bind address -- the Dockerfile sets HOSTNAME=0.0.0.0 and that proxy does not forward Host. Every failed sign-in sent the browser to https://0.0.0.0:3000/signin?error=... and died on ERR_ADDRESS_INVALID, so the one page that could have explained the failure was the one page unreachable. The runner stage now defaults AUTH_URL from the NEXT_PUBLIC_APP_URL build arg, which needs no new variable and no proxy cooperation -- the fix docs/HANDOFF.md already prefers over relying on `proxy_set_header Host $host`. Compose can still override it. The sign-in page rendered ?error= as nothing at all. pages.error points here, so a failed OAuth round trip returns with a code and no other signal, and the page looked exactly like a fresh visit -- indistinguishable from the button being broken. Codes are now mapped to sentences, with two that matter: Configuration says the fault is ours and is not retryable, because telling someone to try again against a misconfigured server is a loop that cannot succeed; and AccessDenied, which is what our own signIn callback returns when it refuses to link an OAuth identity to an existing account, explains the way forward instead of reading as a generic failure. Unknown codes fall back rather than printing the raw code at a user. The provider buttons and the submit are disabled when the error is one retrying cannot clear. --- .env.example | 12 +++++ Dockerfile | 16 ++++++ docker-compose.yml | 26 ++++++++++ src/app/signin/page.tsx | 7 ++- src/components/auth/SignInForm.tsx | 39 +++++++++++--- src/lib/auth/signInError.test.ts | 60 +++++++++++++++++++++ src/lib/auth/signInError.ts | 83 ++++++++++++++++++++++++++++++ 7 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 src/lib/auth/signInError.test.ts create mode 100644 src/lib/auth/signInError.ts 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")}>