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:
170
apps/api-server/src/procedures/auth/login-if-completed.ts
Normal file
170
apps/api-server/src/procedures/auth/login-if-completed.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user