Auth Fix
CI / ci (push) Canceled after 0s

Fixed an issue where the user would be redirected to a localhost address because an auth url was not set in the env.
This commit is contained in:
Stefan Lazic committed 2026-09-06 21:01:54 +01:00
1 parent e9e28a243f
commit cbeccc6c1f
8 files changed
+420 -71

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
import { finalizeOtp, verifyOtp } from "@/lib/auth/otp.service";
import { prisma } from "@/lib/db/prisma";
import { checkRateLimit } from "@/lib/security/rate-limit";
import { secretsMatch } from "@/lib/security/secrets-match";
/**
* Two credential shapes — see src/lib/auth/otp.service.ts's own header
* for the full verify/finalize design this builds on.
*
* `{ email, code }` — the normal path, used by sign-in, registration, and
* (since the "administrator invite verifies via OTP too" work) invitation
* acceptance as well: verifyOtp then finalizeOtp back to back in this one
* request (functionally "verify and consume in one shot" from the
* caller's point of view).
*
* `{ email, finalize: "true", token }` — used only by the installer's own
* immediate post-completion sign-in. Deliberately does NOT call
* finalizeOtp itself (unlike the branch above) — installApplication's own
* transaction already did, consuming the row as part of creating the
* administrator (see installer.service.ts). finalizeOtp is one-time-use
* (it sets consumedAt and refuses a second call on the same row), so
* calling it again here would always fail and turn every successful
* install into a CredentialsSignin error on this very last step — this
* was exactly that bug, caught because it reproduces on every install.
* `token` matching the live INSTALLATION_TOKEN is this branch's entire
* proof of legitimacy instead — required because
* /api/auth/callback/credentials is a public route regardless of this
* change (true of any Credentials provider), so this path must
* independently prove the caller already holds a secret an attacker
* can't have, not just trust that it's being called "from the right
* place". (Invitation acceptance doesn't use this path — it has its own
* real OTP code to re-enter here instead.)
*/
function verifyFinalizeToken(token: string): boolean {
if (!token) return false;
const installToken = process.env.INSTALLATION_TOKEN;
return !!installToken && secretsMatch(token, installToken);
}
/**
* The Credentials provider's `authorize` logic, pulled out of src/auth.ts
* into this module specifically so it's importable in a plain Vitest
* test without dragging in `next-auth`'s `NextAuth(...)` construction
* (which needs the Next.js runtime and fails to resolve under Vitest —
* see auth-finalize.integration.test.ts's own header for why this
* function's correctness matters enough to need that). src/auth.ts wires
* this in verbatim as its Credentials provider's `authorize` option.
*/
export async function authorizeCredentials(
raw: Partial<Record<"email" | "code" | "finalize" | "token", unknown>> | undefined,
): Promise<{ id: string; email: string | null; name: string | null; image: string | null } | null> {
const email = typeof raw?.email === "string" ? raw.email.trim().toLowerCase() : "";
if (!email) return null;
// Keyed by the attempted email, not IP — this is what actually
// stops credential stuffing against one account regardless of how
// many source addresses it's spread across. 30 attempts per 15
// minutes is well above anything a real forgetful user would ever
// hit, while still making online guessing impractical.
const { allowed } = checkRateLimit(`signin:${email}`, { maxAttempts: 30, windowMs: 15 * 60 * 1000 });
if (!allowed) return null;
if (raw?.finalize === "true") {
const token = typeof raw?.token === "string" ? raw.token : "";
if (!verifyFinalizeToken(token)) return null;
// No finalizeOtp call here — installApplication's own
// transaction already consumed it. See this file's own header.
} else {
const code = typeof raw?.code === "string" ? raw.code : "";
if (!code) return null;
const verified = await verifyOtp(prisma, email, code);
if (!verified.ok) return null;
if (!(await finalizeOtp(prisma, email))) return null;
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return null;
return { id: user.id, email: user.email, name: user.name, image: user.image };
}