diff --git a/.env.example b/.env.example index 08310fe..ebbb089 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ DATA_DIR=./data ROOT_DIR=./data APP_PORT=4321 IMAGE=git.atri.dad/atash/chronus:latest -JWT_SECRET=some-secret \ No newline at end of file +JWT_SECRET=some-secret +ORIGIN=https://chronus.example.com \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 5030c80..e7bd7a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - PORT=4321 - DATA_DIR=/app/data - JWT_SECRET=${JWT_SECRET} + - ORIGIN=${ORIGIN} volumes: - ${ROOT_DIR}:/app/data restart: unless-stopped diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d59a320..96bf0ca 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -55,6 +55,22 @@ export async function getUserFromToken(token: string) { return user ?? null; } +/** + * Get the public origin and hostname from the ORIGIN environment variable. + * This is required for WebAuthn/passkey rpID to match the browser's origin. + */ +export function getOrigin(): { hostname: string; origin: string } { + const origin = process.env.ORIGIN; + if (!origin) { + throw new Error("ORIGIN environment variable is not set"); + } + const url = new URL(origin); + return { + hostname: url.hostname, + origin: url.origin, + }; +} + export async function hashPassword(password: string) { return await bcrypt.hash(password, 10); } diff --git a/src/pages/api/auth/passkey/login/finish.ts b/src/pages/api/auth/passkey/login/finish.ts index 17d21df..b6b9fb6 100644 --- a/src/pages/api/auth/passkey/login/finish.ts +++ b/src/pages/api/auth/passkey/login/finish.ts @@ -3,7 +3,7 @@ import { verifyAuthenticationResponse } from "@simplewebauthn/server"; import { db } from "../../../../../db"; import { users, passkeys, passkeyChallenges } from "../../../../../db/schema"; import { eq, and, gt } from "drizzle-orm"; -import { setAuthCookie } from "../../../../../lib/auth"; +import { setAuthCookie, getOrigin } from "../../../../../lib/auth"; export const POST: APIRoute = async ({ request, cookies }) => { const body = await request.json(); @@ -50,11 +50,12 @@ export const POST: APIRoute = async ({ request, cookies }) => { let verification; try { + const { origin, hostname } = getOrigin(); verification = await verifyAuthenticationResponse({ response: body, expectedChallenge: challenge as string, - expectedOrigin: new URL(request.url).origin, - expectedRPID: new URL(request.url).hostname, + expectedOrigin: origin, + expectedRPID: hostname, credential: { id: passkey.id, publicKey: new Uint8Array(Buffer.from(passkey.publicKey, "base64")), diff --git a/src/pages/api/auth/passkey/login/start.ts b/src/pages/api/auth/passkey/login/start.ts index 409775f..e26b40e 100644 --- a/src/pages/api/auth/passkey/login/start.ts +++ b/src/pages/api/auth/passkey/login/start.ts @@ -3,14 +3,17 @@ import { generateAuthenticationOptions } from "@simplewebauthn/server"; import { db } from "../../../../../db"; import { passkeyChallenges } from "../../../../../db/schema"; import { lte } from "drizzle-orm"; +import { getOrigin } from "../../../../../lib/auth"; export const GET: APIRoute = async ({ request }) => { await db .delete(passkeyChallenges) .where(lte(passkeyChallenges.expiresAt, new Date())); + const { hostname } = getOrigin(); + const options = await generateAuthenticationOptions({ - rpID: new URL(request.url).hostname, + rpID: hostname, userVerification: "preferred", }); diff --git a/src/pages/api/auth/passkey/register/finish.ts b/src/pages/api/auth/passkey/register/finish.ts index 0c81661..c1c89e4 100644 --- a/src/pages/api/auth/passkey/register/finish.ts +++ b/src/pages/api/auth/passkey/register/finish.ts @@ -3,6 +3,7 @@ import { verifyRegistrationResponse } from "@simplewebauthn/server"; import { db } from "../../../../../db"; import { passkeys, passkeyChallenges } from "../../../../../db/schema"; import { eq, and, gt } from "drizzle-orm"; +import { getOrigin } from "../../../../../lib/auth"; export const POST: APIRoute = async ({ request, locals }) => { const user = locals.user; @@ -41,11 +42,12 @@ export const POST: APIRoute = async ({ request, locals }) => { let verification; try { + const { origin, hostname } = getOrigin(); verification = await verifyRegistrationResponse({ response: body, expectedChallenge: challenge, - expectedOrigin: new URL(request.url).origin, - expectedRPID: new URL(request.url).hostname, + expectedOrigin: origin, + expectedRPID: hostname, }); } catch (error) { console.error("Passkey registration verification failed:", error); diff --git a/src/pages/api/auth/passkey/register/start.ts b/src/pages/api/auth/passkey/register/start.ts index 4425080..3766dd4 100644 --- a/src/pages/api/auth/passkey/register/start.ts +++ b/src/pages/api/auth/passkey/register/start.ts @@ -3,6 +3,7 @@ import { generateRegistrationOptions } from "@simplewebauthn/server"; import { db } from "../../../../../db"; import { passkeys, passkeyChallenges } from "../../../../../db/schema"; import { eq, lte } from "drizzle-orm"; +import { getOrigin } from "../../../../../lib/auth"; export const GET: APIRoute = async ({ request, locals }) => { const user = locals.user; @@ -21,9 +22,11 @@ export const GET: APIRoute = async ({ request, locals }) => { where: eq(passkeys.userId, user.id), }); + const { hostname } = getOrigin(); + const options = await generateRegistrationOptions({ rpName: "Chronus", - rpID: new URL(request.url).hostname, + rpID: hostname, userName: user.email, attestationType: "none", excludeCredentials: userPasskeys.map((passkey) => ({