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:
parent
998db213d6
commit
1ae406a8aa
25 changed files with 1402 additions and 134 deletions
127
apps/mobile/lib/api-client.ts
Normal file
127
apps/mobile/lib/api-client.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue