diff --git a/.env.example b/.env.example index 6d07ee7..9099a4d 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,9 @@ DEV_AUTH_ROLE=admin # Cognito (required when DEV_AUTH_BYPASS=false) # COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/ # COGNITO_AUDIENCE= +# COGNITO_DOMAIN= +# APP_ORIGIN=http://127.0.0.1:3000 +# ORIGIN_VERIFY_SECRET= # Aurora Data API driver (DATABASE_DRIVER=data-api) # AWS_REGION=us-east-1 diff --git a/README.md b/README.md index c5df9a4..480e36c 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ curl -s http://127.0.0.1:8787/api/health curl -s http://127.0.0.1:8787/api/me ``` -`DEV_AUTH_BYPASS=true` is local-only and only allowed when `NODE_ENV` is `development` or `test` (rejected for production, staging, preview, and any other value). +`DEV_AUTH_BYPASS=true` is local-only and only allowed when `NODE_ENV` is `development` or `test` (rejected for production, staging, preview, and any other value). Cookie session names are `ap_*` locally and `__Host-ap_*` outside local. API roles (source of truth): `admin`, `ap_processor`, `approver`, `viewer`. Frontend mocks still use `ap_operator` until AP-15 remaps them. diff --git a/packages/api/docs/auth.md b/packages/api/docs/auth.md index 9d3e01c..a52b634 100644 --- a/packages/api/docs/auth.md +++ b/packages/api/docs/auth.md @@ -1,9 +1,32 @@ # Authentication -## Cognito bearer tokens +## Cookie session (live path) -Production and seahaven-dev expect a Bearer Cognito token verified against the -user pool JWKS and issuer. +The API is a same-origin BFF. Cognito hosted UI issues tokens. The API stores +them in host-only cookies: + +- `__Host-ap_at` access token (HttpOnly) +- `__Host-ap_it` ID token (HttpOnly) +- `__Host-ap_rt` refresh token (HttpOnly, path `/` when host-prefixed) +- `__Host-ap_sess` session hint (not HttpOnly; display name and email) +- `__Host-ap_oauth` PKCE state during login + +Local `NODE_ENV` `development` or `test` drops the `__Host-` prefix and the +Secure flag so `http://127.0.0.1:8787` works (`ap_at`, `ap_it`, `ap_rt`). + +Routes: + +- `GET /api/auth/login` +- `GET /api/auth/callback` +- `POST /api/auth/refresh` +- `POST /api/auth/logout` +- `GET /api/me` reads the ID cookie, verifies it, and upserts `users` by `sub` + +CloudFront sends `X-Origin-Verify` on `/api/*`. `GET /api/health` skips that +check so the ALB probe succeeds. Mutating `/api/*` requests also require a +matching `Origin`. + +## Cognito tokens Audience check: @@ -11,9 +34,7 @@ Audience check: - Access tokens (`token_use=access`): `client_id` must equal `COGNITO_AUDIENCE`. Identity claims (`sub`, `email`, and `name` or `cognito:username`) are required. -Prefer a Cognito **ID token**, which carries email/name by default. An access -token is accepted only when it includes an `email` claim (for example via a -pre-token-generation enrichment). +`GET /api/me` uses the ID cookie. Optional role claim mapping: diff --git a/packages/api/openapi/components/security.yaml b/packages/api/openapi/components/security.yaml index 6b16271..ed66f35 100644 --- a/packages/api/openapi/components/security.yaml +++ b/packages/api/openapi/components/security.yaml @@ -1,5 +1,5 @@ -bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: Cognito ID token preferred. Access tokens require an email claim. Local DEV_AUTH_BYPASS skips verification. +cookieAuth: + type: apiKey + in: cookie + name: __Host-ap_it + description: HttpOnly session id-token cookie set by GET /api/auth/callback. Local stage uses ap_it without the __Host- prefix. diff --git a/packages/api/openapi/openapi.yaml b/packages/api/openapi/openapi.yaml index ad65cc4..c3fab19 100644 --- a/packages/api/openapi/openapi.yaml +++ b/packages/api/openapi/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Sea Haven AP API version: 1.0.0 - description: "Accounts payable HTTP API for Sea Haven Industries. Liveness, readiness, and authenticated session smoke under local and AWS runtimes." + description: "Accounts payable HTTP API for Sea Haven Industries. Liveness, cookie session, and authenticated session smoke under local and AWS runtimes." license: name: Proprietary servers: @@ -12,18 +12,26 @@ tags: - name: Health description: Liveness and readiness checks for the API process. - name: Session - description: Authenticated caller identity after JWT or local dev auth. + description: Cookie session via Cognito hosted UI, plus caller identity after upsert. paths: /api/health: $ref: ./paths/health.yaml /api/ready: $ref: ./paths/ready.yaml + /api/auth/login: + $ref: ./paths/auth-login.yaml + /api/auth/callback: + $ref: ./paths/auth-callback.yaml + /api/auth/refresh: + $ref: ./paths/auth-refresh.yaml + /api/auth/logout: + $ref: ./paths/auth-logout.yaml /api/me: $ref: ./paths/me.yaml components: securitySchemes: - bearerAuth: - $ref: ./components/security.yaml#/bearerAuth + cookieAuth: + $ref: ./components/security.yaml#/cookieAuth schemas: Error: $ref: ./components/schemas.yaml#/Error @@ -33,3 +41,5 @@ components: $ref: ./components/schemas.yaml#/ReadyResponse MeResponse: $ref: ./components/schemas.yaml#/MeResponse +security: + - cookieAuth: [] diff --git a/packages/api/openapi/paths/auth-callback.yaml b/packages/api/openapi/paths/auth-callback.yaml new file mode 100644 index 0000000..b86106f --- /dev/null +++ b/packages/api/openapi/paths/auth-callback.yaml @@ -0,0 +1,43 @@ +get: + tags: + - Session + summary: Complete hosted UI sign-in + description: Exchanges the authorization code, sets HttpOnly session cookies, and redirects to returnTo. + operationId: get-api-auth-callback + security: [] + parameters: + - name: code + in: query + required: false + description: Authorization code from Cognito. + schema: + type: string + example: abcdef + - name: state + in: query + required: false + description: PKCE state echoed from login. + schema: + type: string + example: state-token + - name: error + in: query + required: false + description: Cognito error code when sign-in failed. + schema: + type: string + example: access_denied + responses: + "302": + description: Redirect to the SPA or the login error page. + "400": + description: Bad request. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: VALIDATION_ERROR + message: Bad request. + correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/auth-login.yaml b/packages/api/openapi/paths/auth-login.yaml new file mode 100644 index 0000000..c782154 --- /dev/null +++ b/packages/api/openapi/paths/auth-login.yaml @@ -0,0 +1,41 @@ +get: + tags: + - Session + summary: Start hosted UI sign-in + description: Redirects the browser to Cognito hosted UI with PKCE S256. Sets the oauth cookie. + operationId: get-api-auth-login + security: [] + parameters: + - name: returnTo + in: query + required: false + description: Relative path to return to after sign-in. + schema: + type: string + default: / + example: /invoices + responses: + "302": + description: Redirect to Cognito hosted UI. + "400": + description: Bad request. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: VALIDATION_ERROR + message: Bad request. + correlationId: 11111111-1111-4111-8111-111111111111 + "500": + description: Cognito is not configured. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: INTERNAL_ERROR + message: Cognito is not configured. + correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/auth-logout.yaml b/packages/api/openapi/paths/auth-logout.yaml new file mode 100644 index 0000000..ce5162b --- /dev/null +++ b/packages/api/openapi/paths/auth-logout.yaml @@ -0,0 +1,32 @@ +post: + tags: + - Session + summary: End the API session + description: Clears session cookies. Idempotent when already signed out. + operationId: post-api-auth-logout + security: [] + responses: + "204": + description: Session cleared. + "403": + description: CSRF origin check failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: FORBIDDEN + message: Origin is not allowed. + correlationId: 11111111-1111-4111-8111-111111111111 + "400": + description: Bad request. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: VALIDATION_ERROR + message: Bad request. + correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/auth-refresh.yaml b/packages/api/openapi/paths/auth-refresh.yaml new file mode 100644 index 0000000..4b8af3a --- /dev/null +++ b/packages/api/openapi/paths/auth-refresh.yaml @@ -0,0 +1,32 @@ +post: + tags: + - Session + summary: Refresh the session cookies + description: Rotates HttpOnly token cookies when the refresh token is still valid. + operationId: post-api-auth-refresh + security: [] + responses: + "204": + description: Session refreshed. + "401": + description: Missing or invalid refresh token. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: UNAUTHENTICATED + message: Missing refresh token. + correlationId: 11111111-1111-4111-8111-111111111111 + "403": + description: CSRF origin check failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: FORBIDDEN + message: Origin is not allowed. + correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/me.yaml b/packages/api/openapi/paths/me.yaml index 2e2be50..c8fe5d5 100644 --- a/packages/api/openapi/paths/me.yaml +++ b/packages/api/openapi/paths/me.yaml @@ -5,7 +5,7 @@ get: description: Upserts the caller into users on first request and returns the stored profile used by the SPA session smoke path. operationId: get-api-me security: - - bearerAuth: [] + - cookieAuth: [] responses: "200": description: Authenticated user profile. @@ -19,7 +19,7 @@ get: name: Dev Admin role: admin "401": - description: Missing or invalid bearer token. + description: Missing or invalid session cookie. content: application/json: schema: @@ -27,7 +27,7 @@ get: example: error: code: UNAUTHENTICATED - message: Missing or invalid Authorization header. + message: Missing id token. correlationId: 11111111-1111-4111-8111-111111111111 "409": description: Email is already linked to a different Cognito subject. diff --git a/packages/api/src/app.test.ts b/packages/api/src/app.test.ts index d221587..8e43314 100644 --- a/packages/api/src/app.test.ts +++ b/packages/api/src/app.test.ts @@ -108,7 +108,7 @@ describe("createApp smoke routes", () => { }); }); - it("GET /api/me rejects missing bearer token when bypass is off", async () => { + it("GET /api/me rejects missing session cookies when bypass is off", async () => { const secureEnv = loadEnv({ NODE_ENV: "test", DEV_AUTH_BYPASS: "false", @@ -118,11 +118,7 @@ describe("createApp smoke routes", () => { const app = createApp(secureEnv, createTestDb()); const response = await app.request("/api/me"); expect(response.status).toBe(401); - expectEnvelope( - await response.json(), - "UNAUTHENTICATED", - "Missing or invalid Authorization header.", - ); + expectEnvelope(await response.json(), "UNAUTHENTICATED", "Missing id token."); }); it("unknown paths return the 404 error envelope", async () => { @@ -144,3 +140,138 @@ describe("createApp smoke routes", () => { errorSpy.mockRestore(); }); }); + +describe("cookie BFF", () => { + function setCookies(response: Response): string[] { + const getSetCookie = response.headers.getSetCookie?.bind(response.headers); + if (getSetCookie) return getSetCookie(); + const single = response.headers.get("set-cookie"); + return single ? [single] : []; + } + + it("GET /api/auth/login sets __Host-ap_oauth in a production-like NODE_ENV", async () => { + const env = loadEnv({ + NODE_ENV: "production", + STAGE: "dev", + COGNITO_ISSUER: "https://cognito-idp.us-east-1.amazonaws.com/test", + COGNITO_AUDIENCE: "test-audience", + COGNITO_DOMAIN: "auth.dev.example", + APP_ORIGIN: "https://d111111abcdef8.cloudfront.net", + }); + const app = createApp(env, createTestDb()); + const response = await app.request("/api/auth/login"); + expect(response.status).toBe(302); + const cookies = setCookies(response); + const oauth = cookies.find((item) => item.startsWith("__Host-ap_oauth=")); + expect(oauth).toBeDefined(); + expect(oauth).toContain("HttpOnly"); + expect(oauth).toMatch(/(?:^|; )Secure(?:;|$)/); + expect(oauth).not.toMatch(/Domain=/i); + expect(response.headers.get("location")).toContain("https://auth.dev.example/oauth2/authorize"); + }); + + it("GET /api/auth/login omits __Host- and Secure on the local bypass path", async () => { + const env = loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "true", + COGNITO_ISSUER: "https://cognito-idp.us-east-1.amazonaws.com/test", + COGNITO_AUDIENCE: "test-audience", + COGNITO_DOMAIN: "auth.dev.example", + }); + const app = createApp(env, createTestDb()); + const response = await app.request("/api/auth/login"); + expect(response.status).toBe(302); + const cookies = setCookies(response); + const oauth = cookies.find((item) => item.startsWith("ap_oauth=")); + expect(oauth).toBeDefined(); + expect(oauth).toContain("HttpOnly"); + expect(oauth).not.toMatch(/(?:^|; )Secure(?:;|$)/); + expect(cookies.some((item) => item.startsWith("__Host-ap_oauth="))).toBe(false); + }); + + it("GET /api/auth/callback sets __Host-ap_* token cookies", async () => { + const env = loadEnv({ + NODE_ENV: "production", + STAGE: "dev", + COGNITO_ISSUER: "https://cognito-idp.us-east-1.amazonaws.com/test", + COGNITO_AUDIENCE: "test-audience", + COGNITO_DOMAIN: "auth.dev.example", + APP_ORIGIN: "https://d111111abcdef8.cloudfront.net", + }); + const login = await createApp(env, createTestDb()).request( + "/api/auth/login?returnTo=/invoices", + ); + const oauthCookie = setCookies(login).find((item) => item.startsWith("__Host-ap_oauth=")); + expect(oauthCookie).toBeDefined(); + const location = new URL(login.headers.get("location") ?? ""); + const state = location.searchParams.get("state") ?? ""; + const app = createApp(env, createTestDb(), { + tokens: { + exchangeCode: async () => ({ + accessToken: "at", + idToken: "it", + refreshToken: "rt", + }), + refresh: async () => ({ accessToken: "at", idToken: "it", refreshToken: "rt" }), + revoke: async () => undefined, + }, + }); + const response = await app.request(`/api/auth/callback?code=abc&state=${state}`, { + headers: { cookie: oauthCookie!.split(";")[0] }, + }); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/invoices"); + const cookies = setCookies(response); + expect( + cookies.some((item) => item.startsWith("__Host-ap_at=") && item.includes("Secure")), + ).toBe(true); + expect(cookies.some((item) => item.startsWith("__Host-ap_it="))).toBe(true); + expect(cookies.some((item) => item.startsWith("__Host-ap_rt="))).toBe(true); + }); + + it("GET /api/me succeeds with an id cookie and fails without", async () => { + const env = loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "false", + COGNITO_ISSUER: "https://cognito-idp.us-east-1.amazonaws.com/test", + COGNITO_AUDIENCE: "test-audience", + }); + const app = createApp(env, createTestDb(), { + verifyToken: async () => ({ + sub: sampleUser.cognitoSub, + email: sampleUser.email, + name: sampleUser.name, + aud: "test-audience", + token_use: "id", + }), + }); + const missing = await app.request("/api/me"); + expect(missing.status).toBe(401); + const ok = await app.request("/api/me", { headers: { cookie: "ap_it=fake-id-token" } }); + expect(ok.status).toBe(200); + await expect(ok.json()).resolves.toEqual({ + id: sampleUser.id, + email: sampleUser.email, + name: sampleUser.name, + role: sampleUser.role, + }); + }); + + it("returns 403 when origin-verify is missing on non-health /api", async () => { + const env = loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "true", + ORIGIN_VERIFY_SECRET: "origin-secret", + }); + const app = createApp(env, createTestDb()); + const health = await app.request("/api/health"); + expect(health.status).toBe(200); + const me = await app.request("/api/me"); + expect(me.status).toBe(403); + expectEnvelope(await me.json(), "FORBIDDEN", "Origin is not allowed."); + const allowed = await app.request("/api/me", { + headers: { "x-origin-verify": "origin-secret" }, + }); + expect(allowed.status).toBe(200); + }); +}); diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index aaafdcb..83f4b8f 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -1,18 +1,44 @@ import { Hono } from "hono"; import type { ApiEnv } from "./env.js"; import type { Db } from "./db/client.js"; -import { createAuthMiddleware, type AppBindings } from "./auth/middleware.js"; +import { createAuthMiddleware, type AppBindings, type AuthDeps } from "./auth/middleware.js"; import { createHealthRoutes } from "./routes/health.js"; import { createMeRoutes } from "./routes/me.js"; +import { createAuthRoutes } from "./routes/auth.js"; import { errorJson } from "./http.js"; +import { cloudFrontOriginAllowed } from "./auth/origin-verify.js"; +import { csrfAllowed, isMutating } from "./auth/oauth.js"; +import type { CognitoTokenClient } from "./auth/cognito.js"; -export function createApp(env: ApiEnv, handle: Db) { +export type AppDeps = AuthDeps & { + tokens?: CognitoTokenClient; +}; + +export function createApp(env: ApiEnv, handle: Db, deps: AppDeps = {}) { const app = new Hono(); - const auth = createAuthMiddleware(env, handle); + const auth = createAuthMiddleware(env, handle, deps); const api = new Hono(); - api.route("/", createHealthRoutes(env, handle)); + api.use("*", async (c, next) => { + const path = new URL(c.req.url).pathname; + if (path === "/api/health") { + await next(); + return; + } + if (!cloudFrontOriginAllowed(c, env.originVerifySecret || undefined)) { + return errorJson(c, 403, "FORBIDDEN", "Origin is not allowed."); + } + await next(); + }); + api.use("*", async (c, next) => { + if (isMutating(c.req.method) && !csrfAllowed(c, env)) { + return errorJson(c, 403, "FORBIDDEN", "Origin is not allowed."); + } + await next(); + }); api.use("*", auth); + api.route("/", createHealthRoutes(env, handle)); + api.route("/", createAuthRoutes(env, deps)); api.route("/", createMeRoutes()); app.route("/api", api); diff --git a/packages/api/src/auth/cognito.ts b/packages/api/src/auth/cognito.ts new file mode 100644 index 0000000..361bece --- /dev/null +++ b/packages/api/src/auth/cognito.ts @@ -0,0 +1,104 @@ +export type TokenSet = { + accessToken: string; + idToken: string; + refreshToken?: string; +}; + +export type CognitoTokenClient = { + exchangeCode(input: { code: string; verifier: string; redirectUri: string }): Promise; + refresh(refreshToken: string): Promise; + revoke(refreshToken: string): Promise; +}; + +export function hostedOrigin(domain: string): string { + const trimmed = domain.trim().replace(/\/$/, ""); + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return `https://${trimmed}`; +} + +export function authorizeUrl(input: { + domain: string; + clientId: string; + redirectUri: string; + state: string; + challenge: string; +}): string { + const url = new URL(`${hostedOrigin(input.domain)}/oauth2/authorize`); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", input.clientId); + url.searchParams.set("redirect_uri", input.redirectUri); + url.searchParams.set("scope", "openid email profile"); + url.searchParams.set("state", input.state); + url.searchParams.set("code_challenge", input.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("identity_provider", "Google"); + return url.toString(); +} + +export function callbackRedirectUri(appOrigin: string): string { + return `${appOrigin.replace(/\/$/, "")}/api/auth/callback`; +} + +export function createCognitoTokenClient( + config: { cognitoAudience: string; cognitoDomain: string }, + fetchFn: typeof fetch = fetch, +): CognitoTokenClient { + return { + exchangeCode(input) { + return tokenRequest(config, fetchFn, { + grant_type: "authorization_code", + code: input.code, + code_verifier: input.verifier, + redirect_uri: input.redirectUri, + }); + }, + async refresh(refreshToken) { + return tokenRequest(config, fetchFn, { + grant_type: "refresh_token", + refresh_token: refreshToken, + }); + }, + async revoke(refreshToken) { + const domain = config.cognitoDomain; + const clientId = config.cognitoAudience; + if (!domain || !clientId) throw new Error("Cognito domain and client id are required"); + const response = await fetchFn(`${hostedOrigin(domain)}/oauth2/revoke`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: refreshToken, client_id: clientId }).toString(), + }); + if (!response.ok && response.status !== 200) { + throw new Error(`revoke failed (${response.status})`); + } + }, + }; +} + +async function tokenRequest( + config: { cognitoAudience: string; cognitoDomain: string }, + fetchFn: typeof fetch, + fields: Record, +): Promise { + const domain = config.cognitoDomain; + const clientId = config.cognitoAudience; + if (!domain || !clientId) throw new Error("Cognito domain and client id are required"); + const response = await fetchFn(`${hostedOrigin(domain)}/oauth2/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ client_id: clientId, ...fields }).toString(), + }); + if (!response.ok) throw new Error(`token request failed (${response.status})`); + const body = (await response.json()) as { + access_token?: unknown; + id_token?: unknown; + refresh_token?: unknown; + }; + if (typeof body.access_token !== "string" || typeof body.id_token !== "string") { + throw new Error("token response missing tokens"); + } + return { + accessToken: body.access_token, + idToken: body.id_token, + refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : undefined, + }; +} diff --git a/packages/api/src/auth/cookies.test.ts b/packages/api/src/auth/cookies.test.ts new file mode 100644 index 0000000..c237ef9 --- /dev/null +++ b/packages/api/src/auth/cookies.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { + ACCESS_MAX_AGE_SEC, + COOKIE_ACCESS, + COOKIE_ID, + COOKIE_OAUTH, + COOKIE_REFRESH, + COOKIE_SESSION_HINT, + REFRESH_MAX_AGE_SEC, + clearCookie, + clearedSessionCookies, + cookieNames, + parseCookies, + serializeCookie, + serializeOauthCookie, + sessionCookieValue, + tokenCookies, +} from "./cookies.js"; + +const host = cookieNames("dev"); +const local = cookieNames("local"); + +describe("auth cookies", () => { + it("prefixes deployed cookies with __Host- and keeps local names unprefixed", () => { + expect(host).toEqual({ + access: `__Host-${COOKIE_ACCESS}`, + id: `__Host-${COOKIE_ID}`, + refresh: `__Host-${COOKIE_REFRESH}`, + oauth: `__Host-${COOKIE_OAUTH}`, + hint: `__Host-${COOKIE_SESSION_HINT}`, + }); + expect(local).toEqual({ + access: COOKIE_ACCESS, + id: COOKIE_ID, + refresh: COOKIE_REFRESH, + oauth: COOKIE_OAUTH, + hint: COOKIE_SESSION_HINT, + }); + }); + + it("sets HttpOnly, SameSite=Lax, Path=/, no Domain, and Secure outside local", () => { + const cookie = serializeCookie(host.access, "tok", { + maxAge: ACCESS_MAX_AGE_SEC, + stage: "dev", + }); + expect(cookie.startsWith(`${host.access}=`)).toBe(true); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Lax"); + expect(cookie).toContain("Path=/"); + expect(cookie).toMatch(/(?:^|; )Secure(?:;|$)/); + expect(cookie).not.toMatch(/Domain=/i); + expect(cookie).toContain(`Max-Age=${ACCESS_MAX_AGE_SEC}`); + }); + + it("omits Secure on local and scopes refresh to /api/auth", () => { + const cookie = serializeCookie(local.access, "tok", { + maxAge: ACCESS_MAX_AGE_SEC, + stage: "local", + }); + expect(cookie).toContain("HttpOnly"); + expect(cookie).not.toMatch(/(?:^|; )Secure(?:;|$)/); + expect(cookie).not.toMatch(/Domain=/i); + + const refresh = tokenCookies( + { accessToken: "a", idToken: "i", refreshToken: "r" }, + "local", + ).find((item) => item.startsWith(`${local.refresh}=`)); + expect(refresh).toContain("Path=/api/auth"); + expect(refresh).not.toMatch(/(?:^|; )Secure(?:;|$)/); + }); + + it("sets a non-HttpOnly session hint for 8h", () => { + const cookies = tokenCookies({ accessToken: "a", idToken: "i", refreshToken: "r" }, "dev"); + const hint = cookies.find((item) => item.startsWith(`${host.hint}=`)); + expect(hint).toContain(`${host.hint}=1`); + expect(hint).toContain(`Max-Age=${REFRESH_MAX_AGE_SEC}`); + expect(hint).not.toContain("HttpOnly"); + expect(hint).toContain("SameSite=Lax"); + expect(hint).toMatch(/(?:^|; )Secure(?:;|$)/); + }); + + it("ignores unprefixed session cookies on deployed stages", () => { + expect( + sessionCookieValue({ headers: { cookie: `${COOKIE_ACCESS}=legacy` } }, "access", "dev"), + ).toBeUndefined(); + expect( + sessionCookieValue( + { headers: { cookie: `${host.access}=host; ${COOKIE_ACCESS}=legacy` } }, + "access", + "dev", + ), + ).toBe("host"); + }); + + it("reads unprefixed session cookies only on local", () => { + expect( + sessionCookieValue({ headers: { cookie: `${COOKIE_ACCESS}=local` } }, "access", "local"), + ).toBe("local"); + }); + + it("parses Cookie headers and keeps the first duplicate", () => { + expect( + parseCookies({ + headers: { cookie: `${host.access}=first; ${host.access}=second` }, + }), + ).toEqual({ [host.access]: "first" }); + }); + + it("clears cookies with Max-Age=0 and the same host-only attributes", () => { + const cookie = clearCookie(host.access, "dev"); + expect(cookie).toContain("Max-Age=0"); + expect(cookie).toContain("HttpOnly"); + expect(cookie).not.toMatch(/Domain=/i); + expect(cookie).toMatch(/(?:^|; )Secure(?:;|$)/); + }); + + it("clears both __Host- and legacy ap_* cookies on deployed stages", () => { + const cookies = clearedSessionCookies("dev"); + expect( + cookies.some((item) => item.startsWith(`${host.refresh}=`) && item.includes("Max-Age=0")), + ).toBe(true); + expect( + cookies.some( + (item) => + item.startsWith(`${COOKIE_REFRESH}=`) && + item.includes("Max-Age=0") && + item.includes("Path=/"), + ), + ).toBe(true); + }); + + it("encodes oauth state without a Domain attribute", () => { + const cookie = serializeOauthCookie({ state: "st", verifier: "ver", returnTo: "/" }, "dev"); + expect(cookie.startsWith(`${host.oauth}=`)).toBe(true); + expect(cookie).toContain("HttpOnly"); + expect(cookie).not.toMatch(/Domain=/i); + }); +}); diff --git a/packages/api/src/auth/cookies.ts b/packages/api/src/auth/cookies.ts new file mode 100644 index 0000000..e04d460 --- /dev/null +++ b/packages/api/src/auth/cookies.ts @@ -0,0 +1,248 @@ +export const COOKIE_ACCESS = "ap_at"; +export const COOKIE_ID = "ap_it"; +export const COOKIE_REFRESH = "ap_rt"; +export const COOKIE_OAUTH = "ap_oauth"; +export const COOKIE_SESSION_HINT = "ap_sess"; + +export const ACCESS_MAX_AGE_SEC = 60 * 60; +export const REFRESH_MAX_AGE_SEC = 8 * 60 * 60; +export const OAUTH_MAX_AGE_SEC = 10 * 60; + +export type CookieEvent = { + cookies?: string[]; + headers?: Record; +}; + +export type OauthCookie = { + state: string; + verifier: string; + returnTo: string; +}; + +export type CookieNames = { + access: string; + id: string; + refresh: string; + oauth: string; + hint: string; +}; + +export type SessionHint = { + displayName: string; + email: string; +}; + +export function cookieNames(stage: string): CookieNames { + const prefix = stage === "local" ? "" : "__Host-"; + return { + access: `${prefix}${COOKIE_ACCESS}`, + id: `${prefix}${COOKIE_ID}`, + refresh: `${prefix}${COOKIE_REFRESH}`, + oauth: `${prefix}${COOKIE_OAUTH}`, + hint: `${prefix}${COOKIE_SESSION_HINT}`, + }; +} + +export function parseCookies(event: CookieEvent): Record { + const parsed: Record = {}; + for (const part of cookieParts(event)) { + const eq = part.indexOf("="); + if (eq <= 0) continue; + const name = part.slice(0, eq).trim(); + const value = part.slice(eq + 1).trim(); + if (!name) continue; + if (Object.prototype.hasOwnProperty.call(parsed, name)) continue; + parsed[name] = decodeCookieValue(value); + } + return parsed; +} + +export function cookieValue(event: CookieEvent, name: string): string | undefined { + const value = parseCookies(event)[name]; + return value ? value : undefined; +} + +export function sessionCookieValue( + event: CookieEvent, + kind: keyof CookieNames, + stage: string, +): string | undefined { + return cookieValue(event, cookieNames(stage)[kind]); +} + +export function serializeCookie( + name: string, + value: string, + options: { maxAge: number; stage: string; path?: string; httpOnly?: boolean }, +): string { + const hostPrefixed = name.startsWith("__Host-"); + const path = hostPrefixed ? "/" : (options.path ?? "/"); + const parts = [ + `${name}=${encodeURIComponent(value)}`, + `Path=${path}`, + "SameSite=Lax", + `Max-Age=${options.maxAge}`, + ]; + if (options.httpOnly !== false) parts.splice(2, 0, "HttpOnly"); + if (hostPrefixed || options.stage !== "local") parts.push("Secure"); + return parts.join("; "); +} + +export function clearCookie( + name: string, + stage: string, + options: { httpOnly?: boolean } = {}, +): string { + return serializeCookie(name, "", { + maxAge: 0, + stage, + path: cookiePath(name), + httpOnly: options.httpOnly, + }); +} + +export function tokenCookies( + tokens: { accessToken: string; idToken: string; refreshToken?: string }, + stage: string, +): string[] { + const names = cookieNames(stage); + const cookies = [ + serializeCookie(names.access, tokens.accessToken, { maxAge: ACCESS_MAX_AGE_SEC, stage }), + serializeCookie(names.id, tokens.idToken, { maxAge: ACCESS_MAX_AGE_SEC, stage }), + ]; + if (tokens.refreshToken) { + cookies.push( + serializeCookie(names.refresh, tokens.refreshToken, { + maxAge: REFRESH_MAX_AGE_SEC, + stage, + path: cookiePath(names.refresh), + }), + ); + } + cookies.push( + serializeCookie(names.hint, sessionHintValue(tokens.idToken), { + maxAge: REFRESH_MAX_AGE_SEC, + stage, + httpOnly: false, + }), + ); + cookies.push(clearCookie(names.oauth, stage)); + return cookies; +} + +export function clearedSessionCookies(stage: string): string[] { + const names = cookieNames(stage); + const cookies = [ + clearCookie(names.access, stage), + clearCookie(names.id, stage), + clearCookie(names.refresh, stage), + clearCookie(names.oauth, stage), + clearCookie(names.hint, stage, { httpOnly: false }), + ]; + if (stage !== "local") { + const legacy = cookieNames("local"); + cookies.push( + serializeCookie(legacy.access, "", { maxAge: 0, stage, path: "/" }), + serializeCookie(legacy.id, "", { maxAge: 0, stage, path: "/" }), + serializeCookie(legacy.refresh, "", { maxAge: 0, stage, path: "/" }), + serializeCookie(legacy.oauth, "", { maxAge: 0, stage, path: "/" }), + serializeCookie(legacy.hint, "", { maxAge: 0, stage, path: "/", httpOnly: false }), + ); + } + return cookies; +} + +export function serializeOauthCookie(payload: OauthCookie, stage: string): string { + const names = cookieNames(stage); + return serializeCookie(names.oauth, encodeOauth(payload), { maxAge: OAUTH_MAX_AGE_SEC, stage }); +} + +export function readOauthCookie(event: CookieEvent, stage: string): OauthCookie | undefined { + const raw = sessionCookieValue(event, "oauth", stage); + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.state !== "string" || + !parsed.state || + typeof parsed.verifier !== "string" || + !parsed.verifier || + typeof parsed.returnTo !== "string" + ) { + return undefined; + } + return { state: parsed.state, verifier: parsed.verifier, returnTo: parsed.returnTo }; + } catch { + return undefined; + } +} + +export function headerFrom(event: CookieEvent, name: string): string | undefined { + const headers = event.headers ?? {}; + const needle = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === needle) return value; + } + return undefined; +} + +export function cookieEventFromHeader(cookieHeader: string | undefined): CookieEvent { + return { headers: { cookie: cookieHeader } }; +} + +export function sessionHintFromIdToken(token: string): SessionHint | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + try { + const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record< + string, + unknown + >; + const email = + typeof claims.email === "string" && claims.email.includes("@") ? claims.email : ""; + if (!email) return null; + const displayName = + (typeof claims.name === "string" && claims.name) || + (typeof claims.given_name === "string" && claims.given_name) || + email; + return { email, displayName }; + } catch { + return null; + } +} + +function cookiePath(name: string): string { + if (name.startsWith("__Host-")) return "/"; + return name.endsWith(COOKIE_REFRESH) ? "/api/auth" : "/"; +} + +function sessionHintValue(idToken: string): string { + const hint = sessionHintFromIdToken(idToken); + return hint ? JSON.stringify(hint) : "1"; +} + +function cookieParts(event: CookieEvent): string[] { + const parts: string[] = []; + for (const cookie of event.cookies ?? []) { + parts.push(cookie); + } + const header = headerFrom(event, "cookie"); + if (header) { + for (const part of header.split(";")) { + if (part.trim()) parts.push(part); + } + } + return parts; +} + +function decodeCookieValue(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function encodeOauth(payload: OauthCookie): string { + return JSON.stringify(payload); +} diff --git a/packages/api/src/auth/middleware.ts b/packages/api/src/auth/middleware.ts index 36e10fa..d6d9426 100644 --- a/packages/api/src/auth/middleware.ts +++ b/packages/api/src/auth/middleware.ts @@ -6,6 +6,8 @@ import type { ApiEnv, UserRole } from "../env.js"; import { isUserRole } from "../env.js"; import type { Db } from "../db/client.js"; import { errorJson } from "../http.js"; +import { cookieEventFromHeader, sessionCookieValue } from "./cookies.js"; +import { cookieStage, isPublicRoute } from "./oauth.js"; export type AppVariables = { user: AuthUser; @@ -15,6 +17,12 @@ export type AppBindings = { Variables: AppVariables; }; +export type TokenVerifier = (token: string) => Promise; + +export type AuthDeps = { + verifyToken?: TokenVerifier; +}; + function roleFromClaims(claims: Record, fallback: UserRole): UserRole { const raw = (typeof claims["custom:role"] === "string" && claims["custom:role"]) || @@ -56,13 +64,31 @@ function identityFromPayload(payload: JWTPayload): { return { sub, email, name }; } -export function createAuthMiddleware(env: ApiEnv, handle: Db) { +export function createAuthMiddleware(env: ApiEnv, handle: Db, deps: AuthDeps = {}) { const jwks = env.cognitoIssuer.length > 0 ? createRemoteJWKSet(new URL(`${env.cognitoIssuer}/.well-known/jwks.json`)) : null; + const verifyToken: TokenVerifier = + deps.verifyToken ?? + (async (token) => { + if (!jwks) { + throw new Error("JWT verification is not configured."); + } + const { payload } = await jwtVerify(token, jwks, { + issuer: env.cognitoIssuer, + }); + return payload; + }); + return createMiddleware(async (c, next) => { + const path = new URL(c.req.url).pathname; + if (isPublicRoute(c.req.method, path)) { + await next(); + return; + } + if (env.devAuthBypass) { try { const user = await upsertUserFromIdentity(handle, { @@ -82,21 +108,15 @@ export function createAuthMiddleware(env: ApiEnv, handle: Db) { return; } - const header = c.req.header("authorization"); - if (!header?.startsWith("Bearer ")) { - return errorJson(c, 401, "UNAUTHENTICATED", "Missing or invalid Authorization header."); + const stage = cookieStage(env); + const idToken = sessionCookieValue(cookieEventFromHeader(c.req.header("cookie")), "id", stage); + if (!idToken) { + return errorJson(c, 401, "UNAUTHENTICATED", "Missing id token."); } - if (!jwks) { - return errorJson(c, 401, "UNAUTHENTICATED", "JWT verification is not configured."); - } - - const token = header.slice("Bearer ".length); let payload: JWTPayload; try { - ({ payload } = await jwtVerify(token, jwks, { - issuer: env.cognitoIssuer, - })); + payload = await verifyToken(idToken); } catch { return errorJson(c, 401, "UNAUTHENTICATED", "Invalid or expired token."); } diff --git a/packages/api/src/auth/oauth.test.ts b/packages/api/src/auth/oauth.test.ts new file mode 100644 index 0000000..3b18fa9 --- /dev/null +++ b/packages/api/src/auth/oauth.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { cookieStage, safeReturnTo, signinErrorLocation } from "./oauth.js"; + +describe("oauth helpers", () => { + it("honors a relative returnTo and rejects open redirects", () => { + expect(safeReturnTo("/invoices")).toBe("/invoices"); + expect(safeReturnTo("//evil.com")).toBe("/"); + expect(safeReturnTo("/login")).toBe("/"); + expect(safeReturnTo("https://evil.com")).toBe("/"); + expect(safeReturnTo("/invoices?tab=2#top")).toBe("/invoices?tab=2#top"); + expect(signinErrorLocation("/invoices")).toBe("/login?error=1&returnTo=%2Finvoices"); + expect(signinErrorLocation("/")).toBe("/login?error=1"); + }); + + it("uses local cookie names for development and test", () => { + expect(cookieStage({ nodeEnv: "test", stage: "dev" })).toBe("local"); + expect(cookieStage({ nodeEnv: "development", stage: "dev" })).toBe("local"); + expect(cookieStage({ nodeEnv: "production", stage: "dev" })).toBe("dev"); + }); +}); diff --git a/packages/api/src/auth/oauth.ts b/packages/api/src/auth/oauth.ts new file mode 100644 index 0000000..09105eb --- /dev/null +++ b/packages/api/src/auth/oauth.ts @@ -0,0 +1,229 @@ +import type { Context } from "hono"; +import type { ApiEnv } from "../env.js"; +import { errorJson } from "../http.js"; +import { + cookieEventFromHeader, + cookieNames, + clearCookie, + clearedSessionCookies, + readOauthCookie, + serializeOauthCookie, + sessionCookieValue, + tokenCookies, + type CookieEvent, +} from "./cookies.js"; +import { + authorizeUrl, + callbackRedirectUri, + createCognitoTokenClient, + type CognitoTokenClient, +} from "./cognito.js"; +import { createPkce, safeEqual } from "./pkce.js"; + +const LOCAL_ORIGINS = [ + "http://127.0.0.1:3000", + "http://localhost:3000", + "http://127.0.0.1:8787", + "http://localhost:8787", +] as const; + +export function cookieStage(env: Pick): string { + if (env.nodeEnv === "development" || env.nodeEnv === "test" || env.stage === "local") { + return "local"; + } + return env.stage; +} + +export function isPublicRoute(method: string, path: string): boolean { + if (method === "GET" && path === "/api/health") return true; + if (method === "GET" && path === "/api/ready") return true; + if (method === "GET" && path === "/api/auth/login") return true; + if (method === "GET" && path === "/api/auth/callback") return true; + if (method === "POST" && path === "/api/auth/refresh") return true; + if (method === "POST" && path === "/api/auth/logout") return true; + return false; +} + +export function isMutating(method: string): boolean { + return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE"; +} + +export function allowedOrigins(env: Pick): Set { + const origins = new Set(); + if (cookieStage(env) === "local") { + for (const origin of LOCAL_ORIGINS) origins.add(origin); + } + const app = env.appOrigin.replace(/\/$/, ""); + if (app) origins.add(app); + return origins; +} + +export function csrfAllowed( + c: Context, + env: Pick, +): boolean { + const origin = c.req.header("origin")?.trim(); + if (!origin) return false; + return allowedOrigins(env).has(origin); +} + +const RETURN_TO_BASE = "https://return-to.invalid"; + +function hasControlCharacter(value: string): boolean { + for (const ch of value) { + const code = ch.codePointAt(0) ?? 0; + if (code < 0x20 || code === 0x7f) return true; + } + return false; +} + +function isPlainAfterDecoding(value: string): boolean { + let current = value; + for (let i = 0; i < 2; i += 1) { + let decoded: string; + try { + decoded = decodeURIComponent(current); + } catch { + return false; + } + if (decoded.includes("\\") || hasControlCharacter(decoded)) return false; + if (decoded === current) break; + current = decoded; + } + return true; +} + +export function safeReturnTo(raw: string | null | undefined): string { + if (!raw) return "/"; + const value = raw.trim(); + if (!value.startsWith("/")) return "/"; + if (value.includes("\\") || hasControlCharacter(value)) return "/"; + if (!isPlainAfterDecoding(value)) return "/"; + let url: URL; + try { + url = new URL(value, RETURN_TO_BASE); + } catch { + return "/"; + } + if (url.origin !== RETURN_TO_BASE) return "/"; + if (url.pathname === "/login") return "/"; + const resolved = `${url.pathname}${url.search}${url.hash}`; + if (!resolved.startsWith("/") || resolved.startsWith("//")) return "/"; + return resolved; +} + +export function signinErrorLocation(returnTo?: string | null): string { + const safe = safeReturnTo(returnTo); + if (safe === "/") return "/login?error=1"; + return `/login?error=1&returnTo=${encodeURIComponent(safe)}`; +} + +export function applyCookies(c: Context, cookies: string[]): void { + for (const cookie of cookies) { + c.header("set-cookie", cookie, { append: true }); + } +} + +function cookieEvent(c: Context): CookieEvent { + return cookieEventFromHeader(c.req.header("cookie")); +} + +export async function handleLogin(c: Context, env: ApiEnv) { + if (!env.cognitoAudience || !env.cognitoDomain) { + return errorJson(c, 500, "INTERNAL_ERROR", "Cognito is not configured."); + } + const returnTo = safeReturnTo(c.req.query("returnTo")); + const pkce = createPkce(); + const location = authorizeUrl({ + domain: env.cognitoDomain, + clientId: env.cognitoAudience, + redirectUri: callbackRedirectUri(env.appOrigin), + state: pkce.state, + challenge: pkce.challenge, + }); + const stage = cookieStage(env); + applyCookies(c, [ + serializeOauthCookie({ state: pkce.state, verifier: pkce.verifier, returnTo }, stage), + ]); + return c.redirect(location, 302); +} + +export async function handleCallback( + c: Context, + env: ApiEnv, + tokens: CognitoTokenClient = createCognitoTokenClient(env), +) { + const stage = cookieStage(env); + const oauth = readOauthCookie(cookieEvent(c), stage); + const fail = () => { + applyCookies(c, [clearCookie(cookieNames(stage).oauth, stage)]); + return c.redirect(signinErrorLocation(oauth?.returnTo), 302); + }; + + if (c.req.query("error")) return fail(); + const code = c.req.query("code")?.trim(); + const state = c.req.query("state")?.trim(); + if (!code || !state || !oauth || !safeEqual(state, oauth.state)) return fail(); + + try { + const exchanged = await tokens.exchangeCode({ + code, + verifier: oauth.verifier, + redirectUri: callbackRedirectUri(env.appOrigin), + }); + if (!exchanged.refreshToken) return fail(); + applyCookies(c, tokenCookies(exchanged, stage)); + return c.redirect(safeReturnTo(oauth.returnTo), 302); + } catch { + return fail(); + } +} + +export async function handleRefresh( + c: Context, + env: ApiEnv, + tokens: CognitoTokenClient = createCognitoTokenClient(env), +) { + const stage = cookieStage(env); + const refreshToken = sessionCookieValue(cookieEvent(c), "refresh", stage); + if (!refreshToken) { + applyCookies(c, clearedSessionCookies(stage)); + return errorJson(c, 401, "UNAUTHENTICATED", "Missing refresh token."); + } + try { + const rotated = await tokens.refresh(refreshToken); + applyCookies( + c, + tokenCookies( + { + accessToken: rotated.accessToken, + idToken: rotated.idToken, + refreshToken: rotated.refreshToken ?? refreshToken, + }, + stage, + ), + ); + return c.body(null, 204); + } catch { + applyCookies(c, clearedSessionCookies(stage)); + return errorJson(c, 401, "UNAUTHENTICATED", "Refresh failed."); + } +} + +export async function handleLogout( + c: Context, + env: ApiEnv, + tokens: CognitoTokenClient = createCognitoTokenClient(env), +) { + const stage = cookieStage(env); + const refreshToken = sessionCookieValue(cookieEvent(c), "refresh", stage); + if (refreshToken && env.cognitoDomain && env.cognitoAudience) { + try { + await tokens.revoke(refreshToken); + } catch { + // Still clear cookies so the browser session ends. + } + } + applyCookies(c, clearedSessionCookies(stage)); + return c.body(null, 204); +} diff --git a/packages/api/src/auth/origin-verify.ts b/packages/api/src/auth/origin-verify.ts new file mode 100644 index 0000000..dfa5516 --- /dev/null +++ b/packages/api/src/auth/origin-verify.ts @@ -0,0 +1,18 @@ +import { timingSafeEqual } from "node:crypto"; +import type { Context } from "hono"; + +export const ORIGIN_VERIFY_HEADER = "x-origin-verify"; + +export function originVerifyHeader(c: Context): string { + return c.req.header(ORIGIN_VERIFY_HEADER)?.trim() ?? ""; +} + +/** When a secret is configured, only CloudFront's origin header is accepted. */ +export function cloudFrontOriginAllowed(c: Context, secret: string | undefined): boolean { + if (!secret) return true; + const provided = originVerifyHeader(c); + const a = Buffer.from(provided); + const b = Buffer.from(secret); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} diff --git a/packages/api/src/auth/pkce.ts b/packages/api/src/auth/pkce.ts new file mode 100644 index 0000000..a63056d --- /dev/null +++ b/packages/api/src/auth/pkce.ts @@ -0,0 +1,23 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +const STATE_BYTES = 32; +const VERIFIER_BYTES = 32; + +export function randomToken(bytes = STATE_BYTES): string { + return randomBytes(bytes).toString("base64url"); +} + +export function createPkce(): { verifier: string; challenge: string; state: string } { + const verifier = randomToken(VERIFIER_BYTES); + return { verifier, challenge: pkceChallenge(verifier), state: randomToken() }; +} + +export function pkceChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +export function safeEqual(left: string, right: string): boolean { + const hashedLeft = createHash("sha256").update(left).digest(); + const hashedRight = createHash("sha256").update(right).digest(); + return timingSafeEqual(hashedLeft, hashedRight) && left.length === right.length; +} diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index c3f4dd6..790b799 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -23,6 +23,9 @@ export type ApiEnv = { rdsDatabase: string; cognitoIssuer: string; cognitoAudience: string; + cognitoDomain: string; + appOrigin: string; + originVerifySecret: string; devAuthBypass: boolean; devAuthSub: string; devAuthEmail: string; @@ -73,6 +76,9 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv { rdsDatabase: env.RDS_DATABASE ?? "seahaven_ap", cognitoIssuer: env.COGNITO_ISSUER ?? "", cognitoAudience: env.COGNITO_AUDIENCE ?? "", + cognitoDomain: env.COGNITO_DOMAIN?.trim() ?? "", + appOrigin: env.APP_ORIGIN?.trim() || (local ? "http://127.0.0.1:3000" : ""), + originVerifySecret: env.ORIGIN_VERIFY_SECRET?.trim() ?? "", devAuthBypass, devAuthSub: env.DEV_AUTH_SUB ?? "seed-sub-admin", devAuthEmail: env.DEV_AUTH_EMAIL ?? "admin@seahavenind.com", diff --git a/packages/api/src/routes/auth.ts b/packages/api/src/routes/auth.ts new file mode 100644 index 0000000..67196be --- /dev/null +++ b/packages/api/src/routes/auth.ts @@ -0,0 +1,16 @@ +import { Hono } from "hono"; +import type { ApiEnv } from "../env.js"; +import type { CognitoTokenClient } from "../auth/cognito.js"; +import { handleCallback, handleLogin, handleLogout, handleRefresh } from "../auth/oauth.js"; +import type { AppBindings } from "../auth/middleware.js"; + +export function createAuthRoutes(env: ApiEnv, deps: { tokens?: CognitoTokenClient } = {}) { + const routes = new Hono(); + + routes.get("/auth/login", (c) => handleLogin(c, env)); + routes.get("/auth/callback", (c) => handleCallback(c, env, deps.tokens)); + routes.post("/auth/refresh", (c) => handleRefresh(c, env, deps.tokens)); + routes.post("/auth/logout", (c) => handleLogout(c, env, deps.tokens)); + + return routes; +} diff --git a/redocly.yaml b/redocly.yaml index fdee367..6b39d1c 100644 --- a/redocly.yaml +++ b/redocly.yaml @@ -67,6 +67,11 @@ rules: - ready - me - api + - auth + - login + - callback + - refresh + - logout paths-kebab-case: error no-invalid-schema-examples: error # No schema-properties casing rule: property names mirror the DynamoDB