Two cleanups in one pass: 1. Update import paths app-wide from `~/lib/auth.server` to `~/lib/auth/session.server` for the four session helpers (sessionStorage, createSession, getSessionUser, destroySession). ~40 files: 33 simple path swaps where the file imported only session symbols, 5 splits where it also imported per-method auth functions (auth.verify.tsx, api.settings.email.ts, activities.\$id.tsx, routes.\$id.tsx, auth.accept-terms.tsx) — those keep one import from auth.server (for verifyMagicToken, canView, recordTermsAcceptance, etc.) and gain a second import from auth/session.server. Two more files used relative paths and were missed by the first grep pass (lib/oauth.server.ts and routes/oauth.authorize.tsx) — migrated too. The @deprecated re-exports block in auth.server.ts is gone. 2. Rename the new auth files to follow the project's `.server.ts` convention so Vite/React Router treat them as server-only (they read process.env.SESSION_SECRET, hit the DB, etc. — must NOT enter the client bundle): - auth/session.ts → auth/session.server.ts - auth/completion.ts → auth/completion.server.ts - auth/completion.test.ts → auth/completion.server.test.ts Done with `git mv` so blame is preserved. Verified: typecheck + lint green; 126 unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
272 lines
6.6 KiB
TypeScript
272 lines
6.6 KiB
TypeScript
import { randomUUID, randomBytes, createHash } from "node:crypto";
|
|
import { eq, and, gt, isNull } from "drizzle-orm";
|
|
import { getDb } from "./db.ts";
|
|
import {
|
|
users,
|
|
oauthClients,
|
|
oauthCodes,
|
|
oauthTokens,
|
|
} from "@trails-cool/db/schema/journal";
|
|
import { getSessionUser } from "./auth/session.server.ts";
|
|
|
|
const CODE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes
|
|
const ACCESS_TOKEN_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
|
|
|
|
function generateToken(): string {
|
|
return randomBytes(32).toString("base64url");
|
|
}
|
|
|
|
// --- Client validation ---
|
|
|
|
export async function getOAuthClient(clientId: string) {
|
|
const db = getDb();
|
|
const [client] = await db
|
|
.select()
|
|
.from(oauthClients)
|
|
.where(eq(oauthClients.clientId, clientId));
|
|
return client ?? null;
|
|
}
|
|
|
|
export function validateRedirectUri(
|
|
client: { redirectUri: string },
|
|
redirectUri: string,
|
|
): boolean {
|
|
return client.redirectUri === redirectUri;
|
|
}
|
|
|
|
// --- Authorization code ---
|
|
|
|
export async function createAuthorizationCode(params: {
|
|
userId: string;
|
|
clientId: string;
|
|
codeChallenge: string;
|
|
codeChallengeMethod: string;
|
|
redirectUri: string;
|
|
}): Promise<string> {
|
|
const db = getDb();
|
|
const code = generateToken();
|
|
const expiresAt = new Date(Date.now() + CODE_EXPIRY_MS);
|
|
|
|
await db.insert(oauthCodes).values({
|
|
id: randomUUID(),
|
|
code,
|
|
userId: params.userId,
|
|
clientId: params.clientId,
|
|
codeChallenge: params.codeChallenge,
|
|
codeChallengeMethod: params.codeChallengeMethod,
|
|
redirectUri: params.redirectUri,
|
|
expiresAt,
|
|
});
|
|
|
|
return code;
|
|
}
|
|
|
|
// --- PKCE verification ---
|
|
|
|
function verifyCodeChallenge(
|
|
codeVerifier: string,
|
|
codeChallenge: string,
|
|
method: string,
|
|
): boolean {
|
|
if (method === "S256") {
|
|
const hash = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
return hash === codeChallenge;
|
|
}
|
|
if (method === "plain") {
|
|
return codeVerifier === codeChallenge;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// --- Token exchange ---
|
|
|
|
export async function exchangeCodeForTokens(params: {
|
|
code: string;
|
|
clientId: string;
|
|
redirectUri: string;
|
|
codeVerifier: string;
|
|
deviceName?: string;
|
|
}) {
|
|
const db = getDb();
|
|
|
|
const [record] = await db
|
|
.select()
|
|
.from(oauthCodes)
|
|
.where(
|
|
and(
|
|
eq(oauthCodes.code, params.code),
|
|
eq(oauthCodes.clientId, params.clientId),
|
|
gt(oauthCodes.expiresAt, new Date()),
|
|
isNull(oauthCodes.usedAt),
|
|
),
|
|
);
|
|
|
|
if (!record) {
|
|
throw new OAuthError("invalid_grant", "Invalid or expired authorization code");
|
|
}
|
|
|
|
if (record.redirectUri !== params.redirectUri) {
|
|
throw new OAuthError("invalid_grant", "Redirect URI mismatch");
|
|
}
|
|
|
|
if (!verifyCodeChallenge(params.codeVerifier, record.codeChallenge, record.codeChallengeMethod)) {
|
|
throw new OAuthError("invalid_grant", "PKCE verification failed");
|
|
}
|
|
|
|
// Mark code as used
|
|
await db
|
|
.update(oauthCodes)
|
|
.set({ usedAt: new Date() })
|
|
.where(eq(oauthCodes.id, record.id));
|
|
|
|
return issueTokens(record.userId, params.clientId, params.deviceName);
|
|
}
|
|
|
|
// --- Refresh token ---
|
|
|
|
export async function refreshAccessToken(params: {
|
|
refreshToken: string;
|
|
clientId: string;
|
|
deviceName?: string;
|
|
}) {
|
|
const db = getDb();
|
|
|
|
const [record] = await db
|
|
.select()
|
|
.from(oauthTokens)
|
|
.where(
|
|
and(
|
|
eq(oauthTokens.refreshToken, params.refreshToken),
|
|
eq(oauthTokens.clientId, params.clientId),
|
|
isNull(oauthTokens.revokedAt),
|
|
),
|
|
);
|
|
|
|
if (!record) {
|
|
throw new OAuthError("invalid_grant", "Invalid refresh token");
|
|
}
|
|
|
|
// Revoke old token pair
|
|
await db
|
|
.update(oauthTokens)
|
|
.set({ revokedAt: new Date() })
|
|
.where(eq(oauthTokens.id, record.id));
|
|
|
|
// Issue fresh tokens (token rotation)
|
|
return issueTokens(record.userId, params.clientId, params.deviceName ?? record.deviceName ?? undefined);
|
|
}
|
|
|
|
// --- Token issuance ---
|
|
|
|
async function issueTokens(userId: string, clientId: string, deviceName?: string) {
|
|
const db = getDb();
|
|
const accessToken = generateToken();
|
|
const refreshToken = generateToken();
|
|
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY_MS);
|
|
|
|
await db.insert(oauthTokens).values({
|
|
id: randomUUID(),
|
|
accessToken,
|
|
refreshToken,
|
|
userId,
|
|
clientId,
|
|
deviceName: deviceName ?? null,
|
|
expiresAt,
|
|
lastActiveAt: new Date(),
|
|
});
|
|
|
|
return {
|
|
access_token: accessToken,
|
|
refresh_token: refreshToken,
|
|
token_type: "Bearer" as const,
|
|
expires_in: Math.floor(ACCESS_TOKEN_EXPIRY_MS / 1000),
|
|
};
|
|
}
|
|
|
|
// --- Bearer token validation (middleware) ---
|
|
|
|
export async function validateBearerToken(request: Request) {
|
|
const db = getDb();
|
|
const authHeader = request.headers.get("Authorization");
|
|
if (!authHeader?.startsWith("Bearer ")) return null;
|
|
|
|
const token = authHeader.slice(7);
|
|
const [record] = await db
|
|
.select()
|
|
.from(oauthTokens)
|
|
.where(
|
|
and(
|
|
eq(oauthTokens.accessToken, token),
|
|
isNull(oauthTokens.revokedAt),
|
|
),
|
|
);
|
|
|
|
if (!record) return null;
|
|
|
|
// Check if access token is expired — but don't reject (client should refresh)
|
|
// We still allow expired access tokens for a grace period; the client
|
|
// gets a 401 which triggers a refresh flow
|
|
if (record.expiresAt < new Date()) return null;
|
|
|
|
// Update last active timestamp
|
|
await db
|
|
.update(oauthTokens)
|
|
.set({ lastActiveAt: new Date() })
|
|
.where(eq(oauthTokens.id, record.id));
|
|
|
|
return { userId: record.userId, tokenId: record.id };
|
|
}
|
|
|
|
// --- Seed trusted client ---
|
|
|
|
export async function seedOAuthClient(
|
|
clientId: string,
|
|
redirectUri: string,
|
|
trusted: boolean,
|
|
) {
|
|
const db = getDb();
|
|
await db
|
|
.insert(oauthClients)
|
|
.values({
|
|
clientId,
|
|
redirectUri,
|
|
trusted: trusted ? 1 : 0,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: oauthClients.clientId,
|
|
set: { redirectUri, trusted: trusted ? 1 : 0 },
|
|
});
|
|
}
|
|
|
|
// --- Combined auth: cookie session OR bearer token ---
|
|
|
|
/**
|
|
* Authenticate a request via cookie session or OAuth2 bearer token.
|
|
* Use this in API routes that both the web UI and mobile app call.
|
|
*/
|
|
export async function getAuthenticatedUser(request: Request) {
|
|
// Try cookie session first (web UI)
|
|
const sessionUser = await getSessionUser(request);
|
|
if (sessionUser) return sessionUser;
|
|
|
|
// Try bearer token (mobile app)
|
|
const tokenResult = await validateBearerToken(request);
|
|
if (!tokenResult) return null;
|
|
|
|
const db = getDb();
|
|
const [user] = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.id, tokenResult.userId));
|
|
return user ?? null;
|
|
}
|
|
|
|
// --- Error type ---
|
|
|
|
export class OAuthError extends Error {
|
|
code: string;
|
|
constructor(code: string, message: string) {
|
|
super(message);
|
|
this.code = code;
|
|
}
|
|
}
|