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")}>