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);
});

View file

@ -17,6 +17,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
ios: {
supportsTablet: true,
bundleIdentifier: "cool.trails.app",
infoPlist: {
NSAppTransportSecurity: {
NSAllowsLocalNetworking: true,
},
},
},
android: {
adaptiveIcon: {
@ -28,5 +33,5 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
web: {
favicon: "./assets/favicon.png",
},
plugins: ["expo-router"],
plugins: ["expo-router", "expo-secure-store", "expo-web-browser"],
});

View file

@ -1,17 +1,70 @@
import { View, Text, StyleSheet } from "react-native";
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, TouchableOpacity, Alert } from "react-native";
import { router } from "expo-router";
import { API_VERSION } from "@trails-cool/api";
import { logout } from "../../lib/auth";
import { getServerUrl, clearServerUrl } from "../../lib/server-config";
export default function ProfileScreen() {
const [serverUrl, setServerUrl] = useState("");
useEffect(() => {
getServerUrl().then(setServerUrl);
}, []);
const handleSwitchServer = () => {
Alert.alert(
"Switch Server",
"This will log you out and clear local data. Continue?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Switch",
style: "destructive",
onPress: async () => {
await logout();
await clearServerUrl();
router.replace("/login");
},
},
],
);
};
const handleLogout = async () => {
await logout();
router.replace("/login");
};
return (
<View style={styles.container}>
<Text style={styles.text}>Profile</Text>
<Text style={styles.server}>{serverUrl}</Text>
<Text style={styles.version}>API v{API_VERSION}</Text>
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<Text style={styles.logoutText}>Log Out</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleSwitchServer}>
<Text style={styles.link}>Switch Server</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: "center", alignItems: "center" },
container: { flex: 1, justifyContent: "center", alignItems: "center", padding: 24 },
text: { fontSize: 18, color: "#666" },
version: { fontSize: 12, color: "#999", marginTop: 8 },
server: { fontSize: 14, color: "#999", marginTop: 4 },
version: { fontSize: 12, color: "#999", marginTop: 4 },
logoutButton: {
marginTop: 32,
backgroundColor: "#e5e5e5",
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 32,
},
logoutText: { fontSize: 16, color: "#333" },
link: { color: "#4A6B40", fontSize: 14, marginTop: 16 },
});

View file

@ -1,5 +1,23 @@
import { Stack } from "expo-router";
import { useEffect, useState } from "react";
import { Stack, router } from "expo-router";
import { isAuthenticated } from "../lib/auth";
import { startVersionCheck } from "../lib/version-check";
export default function RootLayout() {
const [checked, setChecked] = useState(false);
useEffect(() => {
isAuthenticated().then((authed) => {
if (!authed) {
router.replace("/login");
} else {
startVersionCheck();
}
setChecked(true);
});
}, []);
if (!checked) return null;
return <Stack screenOptions={{ headerShown: false }} />;
}

164
apps/mobile/app/login.tsx Normal file
View file

@ -0,0 +1,164 @@
import { useState } from "react";
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from "react-native";
import { router } from "expo-router";
import {
setServerUrl,
fetchDiscovery,
isApiVersionCompatible,
ServerConfigError,
} from "../lib/server-config";
import { login } from "../lib/auth";
export default function LoginScreen() {
const [serverUrl, setServerUrlState] = useState(
__DEV__ ? "http://localhost:3000" : "https://trails.cool",
);
const [showCustomServer, setShowCustomServer] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogin = async () => {
setError(null);
setLoading(true);
try {
const url = serverUrl.replace(/\/+$/, "");
// Validate server via discovery endpoint
const discovery = await fetchDiscovery(url);
if (!isApiVersionCompatible(discovery.apiVersion)) {
setError(
`This app requires API v${discovery.apiVersion.split(".")[0]}.x but the server runs v${discovery.apiVersion}. Please update the app.`,
);
setLoading(false);
return;
}
// Store the validated server URL
await setServerUrl(url);
// Start OAuth2 PKCE login
const success = await login();
if (success) {
router.replace("/(tabs)");
} else {
setError("Login was cancelled or failed. Please try again.");
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("[Login] Error:", message, err);
if (err instanceof ServerConfigError) {
setError(`Could not connect to server: ${err.message}`);
} else {
setError(`Connection failed: ${message}`);
}
} finally {
setLoading(false);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>trails.cool</Text>
<Text style={styles.subtitle}>Sign in to your Journal</Text>
{showCustomServer && (
<View style={styles.serverInput}>
<Text style={styles.label}>Server URL</Text>
<TextInput
style={styles.input}
value={serverUrl}
onChangeText={setServerUrlState}
placeholder="https://trails.cool"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
)}
<TouchableOpacity
style={styles.button}
onPress={handleLogin}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
{!showCustomServer && (
<TouchableOpacity onPress={() => setShowCustomServer(true)}>
<Text style={styles.link}>Connect to a different server</Text>
</TouchableOpacity>
)}
{error && <Text style={styles.error}>{error}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
backgroundColor: "#fff",
},
title: {
fontSize: 32,
fontWeight: "700",
color: "#4A6B40",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#666",
marginBottom: 32,
},
serverInput: {
width: "100%",
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: "500",
color: "#333",
marginBottom: 4,
},
input: {
borderWidth: 1,
borderColor: "#ccc",
borderRadius: 8,
padding: 12,
fontSize: 16,
},
button: {
backgroundColor: "#4A6B40",
borderRadius: 8,
paddingVertical: 14,
paddingHorizontal: 48,
marginBottom: 16,
width: "100%",
alignItems: "center",
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
link: {
color: "#4A6B40",
fontSize: 14,
},
error: {
color: "#c00",
fontSize: 14,
marginTop: 16,
textAlign: "center",
},
});

View file

@ -0,0 +1,24 @@
import { ApiError, NetworkError } from "../api-client";
describe("ApiError", () => {
it("has status, code, and message", () => {
const err = new ApiError(404, "NOT_FOUND", "Route not found");
expect(err.status).toBe(404);
expect(err.code).toBe("NOT_FOUND");
expect(err.message).toBe("Route not found");
expect(err).toBeInstanceOf(Error);
});
});
describe("NetworkError", () => {
it("has default message", () => {
const err = new NetworkError();
expect(err.message).toBe("Network request failed");
expect(err).toBeInstanceOf(Error);
});
it("accepts custom message", () => {
const err = new NetworkError("Timeout");
expect(err.message).toBe("Timeout");
});
});

View file

@ -0,0 +1,19 @@
import { isApiVersionCompatible } from "../server-config";
describe("isApiVersionCompatible", () => {
it("compatible when major versions match", () => {
expect(isApiVersionCompatible("1.0.0")).toBe(true);
expect(isApiVersionCompatible("1.2.3")).toBe(true);
expect(isApiVersionCompatible("1.99.0")).toBe(true);
});
it("incompatible when major versions differ", () => {
expect(isApiVersionCompatible("2.0.0")).toBe(false);
expect(isApiVersionCompatible("0.9.0")).toBe(false);
});
it("handles malformed versions gracefully", () => {
expect(isApiVersionCompatible("")).toBe(false);
expect(isApiVersionCompatible("abc")).toBe(false);
});
});

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),
});
}

142
apps/mobile/lib/auth.ts Normal file
View file

@ -0,0 +1,142 @@
import * as SecureStore from "expo-secure-store";
import * as WebBrowser from "expo-web-browser";
import * as Crypto from "expo-crypto";
import { getServerUrl } from "./server-config";
const STORE_KEY_ACCESS_TOKEN = "access_token";
const STORE_KEY_REFRESH_TOKEN = "refresh_token";
const CLIENT_ID = "trails-cool-mobile";
const REDIRECT_URI = "trailscool://auth/callback";
// --- PKCE helpers ---
function generateCodeVerifier(): string {
const bytes = Crypto.getRandomBytes(32);
return uint8ToBase64Url(bytes);
}
async function generateCodeChallenge(verifier: string): Promise<string> {
const hash = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
verifier,
{ encoding: Crypto.CryptoEncoding.BASE64 },
);
// Convert standard base64 to base64url
return hash.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function uint8ToBase64Url(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// --- Token storage ---
export async function getAccessToken(): Promise<string | null> {
return SecureStore.getItemAsync(STORE_KEY_ACCESS_TOKEN);
}
export async function getRefreshToken(): Promise<string | null> {
return SecureStore.getItemAsync(STORE_KEY_REFRESH_TOKEN);
}
async function storeTokens(accessToken: string, refreshToken: string): Promise<void> {
await SecureStore.setItemAsync(STORE_KEY_ACCESS_TOKEN, accessToken);
await SecureStore.setItemAsync(STORE_KEY_REFRESH_TOKEN, refreshToken);
}
export async function clearTokens(): Promise<void> {
await SecureStore.deleteItemAsync(STORE_KEY_ACCESS_TOKEN);
await SecureStore.deleteItemAsync(STORE_KEY_REFRESH_TOKEN);
}
export async function isAuthenticated(): Promise<boolean> {
const token = await getAccessToken();
return token !== null;
}
// --- Login flow ---
export async function login(): Promise<boolean> {
const serverUrl = await getServerUrl();
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const authorizeUrl = new URL(`${serverUrl}/oauth/authorize`);
authorizeUrl.searchParams.set("client_id", CLIENT_ID);
authorizeUrl.searchParams.set("redirect_uri", REDIRECT_URI);
authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("code_challenge", codeChallenge);
authorizeUrl.searchParams.set("code_challenge_method", "S256");
const result = await WebBrowser.openAuthSessionAsync(
authorizeUrl.toString(),
REDIRECT_URI,
);
if (result.type !== "success") return false;
const callbackUrl = new URL(result.url);
const code = callbackUrl.searchParams.get("code");
if (!code) return false;
// Exchange code for tokens
const tokenUrl = `${serverUrl}/oauth/token`;
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
code_verifier: codeVerifier,
});
const resp = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!resp.ok) return false;
const tokens = await resp.json();
await storeTokens(tokens.access_token, tokens.refresh_token);
return true;
}
// --- Token refresh ---
export async function refreshTokens(): Promise<boolean> {
const serverUrl = await getServerUrl();
const refreshToken = await getRefreshToken();
if (!refreshToken) return false;
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
});
const resp = await fetch(`${serverUrl}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!resp.ok) {
await clearTokens();
return false;
}
const tokens = await resp.json();
await storeTokens(tokens.access_token, tokens.refresh_token);
return true;
}
// --- Logout ---
export async function logout(): Promise<void> {
await clearTokens();
}

View file

@ -0,0 +1,100 @@
import * as SecureStore from "expo-secure-store";
import { API_VERSION } from "@trails-cool/api";
const STORE_KEY_SERVER_URL = "server_url";
const DEFAULT_SERVER_URL = __DEV__ ? "http://localhost:3000" : "https://trails.cool";
export interface DiscoveryResponse {
apiVersion: string;
instanceName: string;
apiBaseUrl: string;
tileUrl?: string;
}
/**
* Get the stored server URL, or the default.
*/
export async function getServerUrl(): Promise<string> {
const stored = await SecureStore.getItemAsync(STORE_KEY_SERVER_URL);
return stored ?? DEFAULT_SERVER_URL;
}
/**
* Store a new server URL.
*/
export async function setServerUrl(url: string): Promise<void> {
await SecureStore.setItemAsync(STORE_KEY_SERVER_URL, url);
}
/**
* Clear the stored server URL (resets to default).
*/
export async function clearServerUrl(): Promise<void> {
await SecureStore.deleteItemAsync(STORE_KEY_SERVER_URL);
}
/**
* Fetch and validate the discovery endpoint for a server URL.
* Returns the parsed discovery response or throws.
*/
export async function fetchDiscovery(serverUrl: string): Promise<DiscoveryResponse> {
const url = `${serverUrl.replace(/\/+$/, "")}/.well-known/trails-cool`;
console.log("[Discovery] Fetching:", url);
let resp: Response;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
resp = await fetch(url, {
headers: { Accept: "application/json" },
signal: controller.signal,
});
clearTimeout(timer);
} catch (err) {
console.error("[Discovery] Fetch failed:", err);
throw new ServerConfigError("network_error", (err as Error).message);
}
console.log("[Discovery] Response status:", resp.status);
if (!resp.ok) {
throw new ServerConfigError(
"discovery_failed",
`Server returned ${resp.status}`,
);
}
const data = await resp.json();
if (!data.apiVersion || !data.instanceName || !data.apiBaseUrl) {
throw new ServerConfigError(
"invalid_discovery",
"Server returned an invalid discovery response",
);
}
return data as DiscoveryResponse;
}
/**
* Parse the major version number from a semver string.
*/
function majorVersion(semver: string): number {
const match = semver.match(/^(\d+)/);
return match ? Number(match[1]) : 0;
}
/**
* Check if the server's API version is compatible with this app.
* Compares major versions the server must match the app's major version.
*/
export function isApiVersionCompatible(serverVersion: string): boolean {
return majorVersion(serverVersion) === majorVersion(API_VERSION);
}
export class ServerConfigError extends Error {
constructor(
public code: string,
message: string,
) {
super(message);
}
}

View file

@ -0,0 +1,31 @@
import { Alert, AppState } from "react-native";
import { getServerUrl, fetchDiscovery, isApiVersionCompatible } from "./server-config";
let listening = false;
/**
* Start listening for app foreground events to re-check API version.
* Shows a non-blocking alert if the server version has drifted.
*/
export function startVersionCheck() {
if (listening) return;
listening = true;
AppState.addEventListener("change", async (state) => {
if (state !== "active") return;
try {
const serverUrl = await getServerUrl();
const discovery = await fetchDiscovery(serverUrl);
if (!isApiVersionCompatible(discovery.apiVersion)) {
Alert.alert(
"Update Available",
"The server has been updated. Please update the app for the best experience.",
[{ text: "OK" }],
);
}
} catch {
// Silently ignore — network may be unavailable
}
});
}

View file

@ -25,8 +25,11 @@
"@trails-cool/map-core": "workspace:*",
"@trails-cool/types": "workspace:*",
"expo": "~55.0.14",
"expo-crypto": "~55.0.14",
"expo-router": "~55.0.12",
"expo-secure-store": "~55.0.13",
"expo-status-bar": "~55.0.5",
"expo-web-browser": "~55.0.14",
"react": "19.2.0",
"react-native": "0.83.4",
"react-native-safe-area-context": "~5.6.2",
@ -34,7 +37,7 @@
},
"devDependencies": {
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^30.0.0",
"@types/jest": "^29.5.14",
"@types/react": "~19.2.14",
"jest-expo": "^55.0.15",
"typescript": "~5.9.2"