Implement OAuth2 PKCE auth, discovery, and mobile API client

Journal server (Phase 1.4 + 1.5):
- Add oauth_clients, oauth_codes, oauth_tokens tables to journal schema
- Implement GET /oauth/authorize with PKCE flow and login redirect
- Implement POST /oauth/token (authorization_code + refresh_token grants)
- Add validateBearerToken() + getAuthenticatedUser() middleware
- Seed trails-cool-mobile as trusted OAuth client on server startup
- Add GET /.well-known/trails-cool discovery endpoint
- Add returnTo support to login page and magic link verify
- Add @trails-cool/api workspace dependency to journal

Mobile app (Phase 1.5 + 1.6):
- Login screen with server URL input and discovery validation
- OAuth2 PKCE login via expo-web-browser with expo-crypto for Hermes
- Token storage in expo-secure-store with auto-refresh on 401
- API client with bearer token injection and typed errors
- Server URL persistence with localhost default in dev mode
- API version compatibility check on app foreground
- Log out + switch server on Profile tab
- iOS ATS exception for local networking

Tests:
- PKCE crypto verification, OAuthError, token generation
- Discovery endpoint response shape
- API version semver compatibility
- API client error types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-13 00:41:40 +02:00
parent 998db213d6
commit 1ae406a8aa
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
25 changed files with 1402 additions and 134 deletions

View file

@ -0,0 +1,106 @@
import { describe, it, expect, vi } from "vitest";
import { createHash, randomBytes } from "node:crypto";
// Mock the database module before importing oauth
vi.mock("./db.ts", () => {
const store: Record<string, unknown[]> = {
oauth_clients: [],
oauth_codes: [],
oauth_tokens: [],
};
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => {
// Return empty array by default; tests override via __store
return [];
},
}),
}),
insert: () => ({
values: () => ({
onConflictDoUpdate: () => Promise.resolve(),
}),
}),
update: () => ({
set: () => ({
where: () => Promise.resolve(),
}),
}),
}),
__store: store,
};
});
/**
* Test PKCE verification logic directly.
* The full OAuth flow requires a real database, so we test the crypto
* primitives and error handling here.
*/
describe("PKCE verification", () => {
it("S256: verifier matches challenge", () => {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
// Re-derive the challenge from the verifier
const derived = createHash("sha256").update(verifier).digest("base64url");
expect(derived).toBe(challenge);
});
it("S256: wrong verifier does not match", () => {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const wrongVerifier = randomBytes(32).toString("base64url");
const wrongDerived = createHash("sha256").update(wrongVerifier).digest("base64url");
expect(wrongDerived).not.toBe(challenge);
});
it("S256: challenge is base64url-encoded SHA-256", () => {
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const challenge = createHash("sha256").update(verifier).digest("base64url");
// base64url has no padding, no +, no /
expect(challenge).not.toContain("=");
expect(challenge).not.toContain("+");
expect(challenge).not.toContain("/");
expect(challenge.length).toBeGreaterThan(0);
});
});
describe("OAuthError", () => {
it("has code and message", async () => {
const { OAuthError } = await import("./oauth.server.ts");
const err = new OAuthError("invalid_grant", "Bad code");
expect(err.code).toBe("invalid_grant");
expect(err.message).toBe("Bad code");
expect(err).toBeInstanceOf(Error);
});
});
describe("token generation", () => {
it("generates unique tokens", async () => {
const { randomBytes } = await import("node:crypto");
const tokens = new Set<string>();
for (let i = 0; i < 100; i++) {
tokens.add(randomBytes(32).toString("base64url"));
}
expect(tokens.size).toBe(100);
});
it("tokens are base64url encoded", async () => {
const token = randomBytes(32).toString("base64url");
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
expect(token.length).toBe(43); // 32 bytes → 43 base64url chars
});
});
describe("seedOAuthClient", () => {
it("can be imported and called", async () => {
const { seedOAuthClient } = await import("./oauth.server.ts");
// Should not throw with mocked DB
await seedOAuthClient("test-client", "testapp://callback", true);
});
});

View file

@ -0,0 +1,270 @@
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.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;
}) {
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);
}
// --- Refresh token ---
export async function refreshAccessToken(params: {
refreshToken: string;
clientId: 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);
}
// --- Token issuance ---
async function issueTokens(userId: string, clientId: 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,
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 {
constructor(
public code: string,
message: string,
) {
super(message);
}
}

View file

@ -2,6 +2,9 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route(".well-known/trails-cool", "routes/api.well-known.trails-cool.ts"),
route("oauth/authorize", "routes/oauth.authorize.tsx"),
route("oauth/token", "routes/oauth.token.ts"),
route("auth/register", "routes/auth.register.tsx"),
route("auth/login", "routes/auth.login.tsx"),
route("auth/verify", "routes/auth.verify.tsx"),

View file

@ -0,0 +1,33 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.stubEnv("DOMAIN", "trails.cool");
vi.stubEnv("ORIGIN", "https://trails.cool");
describe("GET /.well-known/trails-cool", () => {
beforeEach(() => {
vi.resetModules();
});
it("returns discovery response with API version", async () => {
const { loader } = await import("./api.well-known.trails-cool.ts");
const resp = loader() as Response;
const data = await resp.json();
expect(data.apiVersion).toMatch(/^\d+\.\d+\.\d+$/);
expect(data.instanceName).toBe("trails.cool");
expect(data.apiBaseUrl).toBe("https://trails.cool/api/v1");
});
it("uses localhost defaults when env vars are unset", async () => {
vi.stubEnv("DOMAIN", "");
vi.stubEnv("ORIGIN", "");
// Re-import to pick up changed env
const mod = await import("./api.well-known.trails-cool.ts");
const resp = mod.loader() as Response;
const data = await resp.json();
// Falls back to process.env values (empty strings trigger ?? fallback)
expect(data).toHaveProperty("apiVersion");
expect(data).toHaveProperty("apiBaseUrl");
});
});

View file

@ -0,0 +1,18 @@
import { API_VERSION } from "@trails-cool/api";
/**
* GET /.well-known/trails-cool
*
* Discovery endpoint for mobile apps and federation.
* Returns instance metadata including API version, name, and base URL.
*/
export function loader() {
const domain = process.env.DOMAIN ?? "localhost";
const origin = process.env.ORIGIN ?? "http://localhost:3000";
return Response.json({
apiVersion: API_VERSION,
instanceName: domain,
apiBaseUrl: `${origin}/api/v1`,
});
}

View file

@ -1,8 +1,11 @@
import { useState, useEffect } from "react";
import { useSearchParams } from "react-router";
import { useTranslation } from "react-i18next";
export default function LoginPage() {
const { t } = useTranslation("journal");
const [searchParams] = useSearchParams();
const returnTo = searchParams.get("returnTo");
const [supportsPasskey, setSupportsPasskey] = useState(true);
const [mode, setMode] = useState<"passkey" | "magic-link">("passkey");
@ -53,7 +56,7 @@ export default function LoginPage() {
if (finishData.error) {
setError(finishData.error);
} else if (finishData.step === "done") {
window.location.href = "/";
window.location.href = returnTo ?? "/";
}
} catch (err) {
const message = (err as Error).message;
@ -84,7 +87,10 @@ export default function LoginPage() {
setError(result.error);
} else if (result.devLink) {
// Dev mode: auto-redirect to magic link
window.location.href = result.devLink;
const devUrl = returnTo
? `${result.devLink}&returnTo=${encodeURIComponent(returnTo)}`
: result.devLink;
window.location.href = devUrl;
} else {
setMagicLinkSent(true);
}

View file

@ -21,7 +21,9 @@ export async function loader({ request }: Route.LoaderArgs) {
const userId = await verifyMagicToken(token);
const cookie = await createSession(userId, request);
return redirect("/?add-passkey=1", { headers: { "Set-Cookie": cookie } });
const returnTo = url.searchParams.get("returnTo");
const destination = returnTo?.startsWith("/") ? returnTo : "/?add-passkey=1";
return redirect(destination, { headers: { "Set-Cookie": cookie } });
} catch (e) {
return data({ error: (e as Error).message }, { status: 400 });
}

View file

@ -0,0 +1,80 @@
import type { Route } from "./+types/oauth.authorize";
import { redirect } from "react-router";
import { getSessionUser } from "../lib/auth.server.ts";
import {
getOAuthClient,
validateRedirectUri,
createAuthorizationCode,
} from "../lib/oauth.server.ts";
/**
* GET /oauth/authorize
*
* OAuth2 authorization endpoint. If the user is already logged in,
* generates an authorization code and redirects. Otherwise redirects
* to the login page with a return URL.
*
* Query params:
* - client_id
* - redirect_uri
* - code_challenge
* - code_challenge_method (default: S256)
* - response_type (must be "code")
* - state (opaque, passed back to client)
*/
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const clientId = url.searchParams.get("client_id");
const redirectUri = url.searchParams.get("redirect_uri");
const codeChallenge = url.searchParams.get("code_challenge");
const codeChallengeMethod = url.searchParams.get("code_challenge_method") ?? "S256";
const responseType = url.searchParams.get("response_type");
const state = url.searchParams.get("state") ?? "";
// Validate required params
if (!clientId || !redirectUri || !codeChallenge || responseType !== "code") {
return new Response(
JSON.stringify({ error: "invalid_request", error_description: "Missing required parameters" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
if (codeChallengeMethod !== "S256" && codeChallengeMethod !== "plain") {
return new Response(
JSON.stringify({ error: "invalid_request", error_description: "Unsupported code_challenge_method" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
// Validate client
const client = await getOAuthClient(clientId);
if (!client || !validateRedirectUri(client, redirectUri)) {
return new Response(
JSON.stringify({ error: "invalid_client", error_description: "Unknown client or redirect URI" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
// Check if user is already authenticated
const user = await getSessionUser(request);
if (!user) {
// Redirect to login with return URL so we come back here after auth
const returnUrl = url.pathname + url.search;
return redirect(`/auth/login?returnTo=${encodeURIComponent(returnUrl)}`);
}
// User is authenticated — issue code and redirect to client
const code = await createAuthorizationCode({
userId: user.id,
clientId,
codeChallenge,
codeChallengeMethod,
redirectUri,
});
const callbackUrl = new URL(redirectUri);
callbackUrl.searchParams.set("code", code);
if (state) callbackUrl.searchParams.set("state", state);
return redirect(callbackUrl.toString());
}

View file

@ -0,0 +1,70 @@
import type { Route } from "./+types/oauth.token";
import {
exchangeCodeForTokens,
refreshAccessToken,
OAuthError,
} from "../lib/oauth.server.ts";
/**
* POST /oauth/token
*
* OAuth2 token endpoint. Supports two grant types:
* - authorization_code: Exchange code + PKCE verifier for tokens
* - refresh_token: Exchange refresh token for new token pair
*/
export async function action({ request }: Route.ActionArgs) {
if (request.method !== "POST") {
return new Response(null, { status: 405 });
}
const body = await request.formData();
const grantType = body.get("grant_type") as string | null;
try {
if (grantType === "authorization_code") {
const code = body.get("code") as string | null;
const clientId = body.get("client_id") as string | null;
const redirectUri = body.get("redirect_uri") as string | null;
const codeVerifier = body.get("code_verifier") as string | null;
if (!code || !clientId || !redirectUri || !codeVerifier) {
return oauthErrorResponse("invalid_request", "Missing required parameters");
}
const tokens = await exchangeCodeForTokens({
code,
clientId,
redirectUri,
codeVerifier,
});
return Response.json(tokens);
}
if (grantType === "refresh_token") {
const refreshToken = body.get("refresh_token") as string | null;
const clientId = body.get("client_id") as string | null;
if (!refreshToken || !clientId) {
return oauthErrorResponse("invalid_request", "Missing required parameters");
}
const tokens = await refreshAccessToken({ refreshToken, clientId });
return Response.json(tokens);
}
return oauthErrorResponse("unsupported_grant_type", `Unsupported grant type: ${grantType}`);
} catch (err) {
if (err instanceof OAuthError) {
return oauthErrorResponse(err.code, err.message);
}
throw err;
}
}
function oauthErrorResponse(error: string, description: string) {
return Response.json(
{ error, error_description: description },
{ status: 400 },
);
}

View file

@ -18,6 +18,7 @@
"@sentry/react": "catalog:",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.0",
"@trails-cool/api": "workspace:*",
"@trails-cool/db": "workspace:*",
"@trails-cool/gpx": "workspace:*",
"@trails-cool/i18n": "workspace:*",

View file

@ -92,6 +92,10 @@ const server = createServer((req, res) => {
}
});
server.listen(port, () => {
server.listen(port, async () => {
logger.info({ port }, "Journal server listening");
// Seed first-party OAuth2 clients
const { seedOAuthClient } = await import("./app/lib/oauth.server.ts");
await seedOAuthClient("trails-cool-mobile", "trailscool://auth/callback", true);
});