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,127 @@
import { getServerUrl } from "./server-config";
import { getAccessToken, refreshTokens, clearTokens } from "./auth";
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
) {
super(message);
}
}
export class NetworkError extends Error {
constructor(message = "Network request failed") {
super(message);
}
}
/**
* Base API client with bearer token injection and automatic 401 refresh.
*/
async function request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const serverUrl = await getServerUrl();
const baseUrl = `${serverUrl}/api/v1`;
const url = `${baseUrl}${path}`;
const token = await getAccessToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string> ?? {}),
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
let resp: Response;
try {
resp = await fetch(url, { ...options, headers });
} catch (err) {
throw new NetworkError((err as Error).message);
}
// Auto-refresh on 401
if (resp.status === 401 && token) {
const refreshed = await refreshTokens();
if (refreshed) {
const newToken = await getAccessToken();
headers["Authorization"] = `Bearer ${newToken}`;
try {
resp = await fetch(url, { ...options, headers });
} catch (err) {
throw new NetworkError((err as Error).message);
}
} else {
await clearTokens();
throw new ApiError(401, "UNAUTHORIZED", "Session expired. Please log in again.");
}
}
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new ApiError(
resp.status,
body.code ?? "UNKNOWN",
body.error ?? `Request failed with status ${resp.status}`,
);
}
return resp.json();
}
// --- Routes ---
export function listRoutes(cursor?: string, limit = 20) {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set("cursor", cursor);
return request<unknown>(`/routes?${params}`);
}
export function getRoute(id: string) {
return request<unknown>(`/routes/${id}`);
}
export function createRoute(data: { name: string; description?: string; gpx?: string }) {
return request<unknown>("/routes", {
method: "POST",
body: JSON.stringify(data),
});
}
export function updateRoute(id: string, data: { name?: string; description?: string; gpx?: string }) {
return request<unknown>(`/routes/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
// --- Activities ---
export function listActivities(cursor?: string, limit = 20) {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set("cursor", cursor);
return request<unknown>(`/activities?${params}`);
}
export function getActivity(id: string) {
return request<unknown>(`/activities/${id}`);
}
export function createActivity(data: {
name: string;
description?: string;
gpx?: string;
routeId?: string;
startedAt?: string;
duration?: number;
distance?: number;
}) {
return request<unknown>("/activities", {
method: "POST",
body: JSON.stringify(data),
});
}