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
106
apps/journal/app/lib/oauth.server.test.ts
Normal file
106
apps/journal/app/lib/oauth.server.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
270
apps/journal/app/lib/oauth.server.ts
Normal file
270
apps/journal/app/lib/oauth.server.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
33
apps/journal/app/routes/api.well-known.trails-cool.test.ts
Normal file
33
apps/journal/app/routes/api.well-known.trails-cool.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
18
apps/journal/app/routes/api.well-known.trails-cool.ts
Normal file
18
apps/journal/app/routes/api.well-known.trails-cool.ts
Normal 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`,
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
80
apps/journal/app/routes/oauth.authorize.tsx
Normal file
80
apps/journal/app/routes/oauth.authorize.tsx
Normal 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());
|
||||
}
|
||||
70
apps/journal/app/routes/oauth.token.ts
Normal file
70
apps/journal/app/routes/oauth.token.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue