Implement auth procedures with code review fixes

Add complete auth backend (Workstream D):
- Auth middleware for session/API key authentication
- Signup with password or passkey (WebAuthn)
- Login flow with device trust and email confirmation
- Password reset and email verification
- Session management and logout

Utilities created:
- cookies.ts: Cookie helpers and configuration
- crypto.ts: Token generation and hashing
- password.ts: zxcvbn validation, argon2id hashing
- geo.ts: IP/location extraction from headers
- email.ts: Stubbed email sending
- session.ts: Session creation and device trust

Code review improvements applied:
- Use ORPCError instead of Error in procedures
- Add ast-grep rule to enforce ORPCError usage
- Remove error info leakage (generic messages)
- Optimize N+1 query with JOIN in login-password
- Extract signupWithPassword/signupWithPasskey for testability
- Add 15-minute WebAuthn challenge expiry check
- Strengthen CookieOptions type definitions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-09 15:19:15 +08:00
parent 8de88472b1
commit 829d365e80
24 changed files with 1739 additions and 47 deletions

View File

@@ -0,0 +1,170 @@
/**
* Check if login request is completed and create session if so
* Public procedure - no authentication required
*
* Flow:
* 1. Read rev.login_request_token cookie (could be real ID or fake UUID)
* 2. If fake token (not found in DB): return { status: 'pending' }
* 3. If valid login request ID:
* - Check if expired: return { status: 'expired' }
* - Check if not completed: return { status: 'pending' }
* - If completed:
* a. Create user_device record
* b. Create session (trusted_mode = true)
* c. Delete login_request row
* d. Set session cookie, clear login_request cookie
* e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' }
*/
import type { APIContext } from "../../context.js";
import { implement } from "@orpc/server";
import { contract } from "@reviq/api-contract";
import {
COOKIE_NAMES,
COOKIE_OPTIONS,
deleteCookie,
getCookie,
setCookie,
} from "../../utils/cookies.js";
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
import {
createSession,
isDeviceTrusted,
upsertUserDevice,
} from "../../utils/session.js";
const os = implement(contract);
/**
* Check if a string looks like a UUID (fake token)
*/
const isUUID = (str: string): boolean => {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
};
/**
* Login if request is completed handler
* Polls for login completion and creates session when ready
*/
export const loginIfRequestIsCompleted =
os.auth.loginIfRequestIsCompleted.handler(async ({ context }) => {
const ctx = context as APIContext;
// Read login request token from cookie
const loginRequestToken = getCookie(
ctx.reqHeaders,
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
);
// No cookie - return pending (shouldn't happen in normal flow)
if (!loginRequestToken) {
return { status: "pending" as const };
}
// Check if it's a fake token (UUID)
if (isUUID(loginRequestToken)) {
// Fake token - user doesn't exist
// The cookie will expire naturally after 15 minutes
return { status: "pending" as const };
}
// Try to parse as login request ID
const loginRequestId = Number.parseInt(loginRequestToken, 10);
if (Number.isNaN(loginRequestId)) {
// Invalid format - treat as pending
return { status: "pending" as const };
}
// Fetch login request from database
const loginRequest = await ctx.db
.selectFrom("login_requests")
.select([
"id",
"user_id",
"device_fingerprint",
"completed_at",
"expires_at",
])
.where("id", "=", String(loginRequestId))
.executeTakeFirst();
// Login request not found - might have been deleted or invalid ID
if (!loginRequest) {
return { status: "pending" as const };
}
// Check if expired
if (new Date() > loginRequest.expires_at) {
return { status: "expired" as const };
}
// Check if not completed yet
if (loginRequest.completed_at === null) {
return { status: "pending" as const };
}
// Login request is completed - create session
const userId = loginRequest.user_id;
const deviceFingerprint = loginRequest.device_fingerprint;
// Device fingerprint should always be present, but handle null case defensively
if (!deviceFingerprint) {
return { status: "pending" as const };
}
// Get current request info
const geo = getGeoInfo(ctx.reqHeaders);
const userAgent = getUserAgent(ctx.reqHeaders);
// Upsert user device
const deviceId = await upsertUserDevice(
ctx.db,
userId,
deviceFingerprint,
geo,
userAgent,
);
// Check if device is already trusted
const deviceTrusted = await isDeviceTrusted(
ctx.db,
userId,
deviceFingerprint,
);
// Create session with trusted mode = true (email-confirmed login)
const session = await createSession(ctx.db, {
userId,
deviceId,
trustedMode: true,
geo,
userAgent,
});
// Delete the login request (it's been consumed)
await ctx.db
.deleteFrom("login_requests")
.where("id", "=", String(loginRequestId))
.execute();
// Set session cookie
setCookie(
ctx.resHeaders,
COOKIE_NAMES.SESSION_TOKEN,
session.token,
COOKIE_OPTIONS.session,
);
// Clear login request cookie
deleteCookie(ctx.resHeaders, COOKIE_NAMES.LOGIN_REQUEST_TOKEN);
// Determine redirect path based on device trust status
const redirectTo = deviceTrusted ? "/dashboard" : "/auth/trust-device";
return {
status: "completed" as const,
redirectTo,
};
});