Fixed Origin mismatch for passkeys
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m9s

This commit is contained in:
2026-02-13 11:35:06 -07:00
parent 705358d44c
commit e99e042eea
7 changed files with 35 additions and 8 deletions

View File

@@ -2,4 +2,5 @@ DATA_DIR=./data
ROOT_DIR=./data ROOT_DIR=./data
APP_PORT=4321 APP_PORT=4321
IMAGE=git.atri.dad/atash/chronus:latest IMAGE=git.atri.dad/atash/chronus:latest
JWT_SECRET=some-secret JWT_SECRET=some-secret
ORIGIN=https://chronus.example.com

View File

@@ -9,6 +9,7 @@ services:
- PORT=4321 - PORT=4321
- DATA_DIR=/app/data - DATA_DIR=/app/data
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
- ORIGIN=${ORIGIN}
volumes: volumes:
- ${ROOT_DIR}:/app/data - ${ROOT_DIR}:/app/data
restart: unless-stopped restart: unless-stopped

View File

@@ -55,6 +55,22 @@ export async function getUserFromToken(token: string) {
return user ?? null; 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) { export async function hashPassword(password: string) {
return await bcrypt.hash(password, 10); return await bcrypt.hash(password, 10);
} }

View File

@@ -3,7 +3,7 @@ import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { db } from "../../../../../db"; import { db } from "../../../../../db";
import { users, passkeys, passkeyChallenges } from "../../../../../db/schema"; import { users, passkeys, passkeyChallenges } from "../../../../../db/schema";
import { eq, and, gt } from "drizzle-orm"; 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 }) => { export const POST: APIRoute = async ({ request, cookies }) => {
const body = await request.json(); const body = await request.json();
@@ -50,11 +50,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
let verification; let verification;
try { try {
const { origin, hostname } = getOrigin();
verification = await verifyAuthenticationResponse({ verification = await verifyAuthenticationResponse({
response: body, response: body,
expectedChallenge: challenge as string, expectedChallenge: challenge as string,
expectedOrigin: new URL(request.url).origin, expectedOrigin: origin,
expectedRPID: new URL(request.url).hostname, expectedRPID: hostname,
credential: { credential: {
id: passkey.id, id: passkey.id,
publicKey: new Uint8Array(Buffer.from(passkey.publicKey, "base64")), publicKey: new Uint8Array(Buffer.from(passkey.publicKey, "base64")),

View File

@@ -3,14 +3,17 @@ import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { db } from "../../../../../db"; import { db } from "../../../../../db";
import { passkeyChallenges } from "../../../../../db/schema"; import { passkeyChallenges } from "../../../../../db/schema";
import { lte } from "drizzle-orm"; import { lte } from "drizzle-orm";
import { getOrigin } from "../../../../../lib/auth";
export const GET: APIRoute = async ({ request }) => { export const GET: APIRoute = async ({ request }) => {
await db await db
.delete(passkeyChallenges) .delete(passkeyChallenges)
.where(lte(passkeyChallenges.expiresAt, new Date())); .where(lte(passkeyChallenges.expiresAt, new Date()));
const { hostname } = getOrigin();
const options = await generateAuthenticationOptions({ const options = await generateAuthenticationOptions({
rpID: new URL(request.url).hostname, rpID: hostname,
userVerification: "preferred", userVerification: "preferred",
}); });

View File

@@ -3,6 +3,7 @@ import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { db } from "../../../../../db"; import { db } from "../../../../../db";
import { passkeys, passkeyChallenges } from "../../../../../db/schema"; import { passkeys, passkeyChallenges } from "../../../../../db/schema";
import { eq, and, gt } from "drizzle-orm"; import { eq, and, gt } from "drizzle-orm";
import { getOrigin } from "../../../../../lib/auth";
export const POST: APIRoute = async ({ request, locals }) => { export const POST: APIRoute = async ({ request, locals }) => {
const user = locals.user; const user = locals.user;
@@ -41,11 +42,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
let verification; let verification;
try { try {
const { origin, hostname } = getOrigin();
verification = await verifyRegistrationResponse({ verification = await verifyRegistrationResponse({
response: body, response: body,
expectedChallenge: challenge, expectedChallenge: challenge,
expectedOrigin: new URL(request.url).origin, expectedOrigin: origin,
expectedRPID: new URL(request.url).hostname, expectedRPID: hostname,
}); });
} catch (error) { } catch (error) {
console.error("Passkey registration verification failed:", error); console.error("Passkey registration verification failed:", error);

View File

@@ -3,6 +3,7 @@ import { generateRegistrationOptions } from "@simplewebauthn/server";
import { db } from "../../../../../db"; import { db } from "../../../../../db";
import { passkeys, passkeyChallenges } from "../../../../../db/schema"; import { passkeys, passkeyChallenges } from "../../../../../db/schema";
import { eq, lte } from "drizzle-orm"; import { eq, lte } from "drizzle-orm";
import { getOrigin } from "../../../../../lib/auth";
export const GET: APIRoute = async ({ request, locals }) => { export const GET: APIRoute = async ({ request, locals }) => {
const user = locals.user; const user = locals.user;
@@ -21,9 +22,11 @@ export const GET: APIRoute = async ({ request, locals }) => {
where: eq(passkeys.userId, user.id), where: eq(passkeys.userId, user.id),
}); });
const { hostname } = getOrigin();
const options = await generateRegistrationOptions({ const options = await generateRegistrationOptions({
rpName: "Chronus", rpName: "Chronus",
rpID: new URL(request.url).hostname, rpID: hostname,
userName: user.email, userName: user.email,
attestationType: "none", attestationType: "none",
excludeCredentials: userPasskeys.map((passkey) => ({ excludeCredentials: userPasskeys.map((passkey) => ({