feat(api): switch live auth to host cookie BFF

Replace Bearer as the documented session path with Cognito hosted UI plus __Host-ap_* cookies so the SPA can call /api with credentials include.
This commit is contained in:
Adam Moussa 2026-09-22 12:39:00 -04:00
parent cc637e3732
commit bbfa6e4a3c
No known key found for this signature in database
23 changed files with 1207 additions and 41 deletions

View file

@ -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/<pool-id>
# COGNITO_AUDIENCE=<app-client-id>
# COGNITO_DOMAIN=<hosted-ui-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

View file

@ -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.

View file

@ -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:

View file

@ -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.

View file

@ -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: []

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

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

View file

@ -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<AppBindings>();
const auth = createAuthMiddleware(env, handle);
const auth = createAuthMiddleware(env, handle, deps);
const api = new Hono<AppBindings>();
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);

View file

@ -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<TokenSet>;
refresh(refreshToken: string): Promise<TokenSet>;
revoke(refreshToken: string): Promise<void>;
};
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<string, string>,
): Promise<TokenSet> {
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,
};
}

View file

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

View file

@ -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<string, string | undefined>;
};
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<string, string> {
const parsed: Record<string, string> = {};
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<OauthCookie>;
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);
}

View file

@ -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<JWTPayload>;
export type AuthDeps = {
verifyToken?: TokenVerifier;
};
function roleFromClaims(claims: Record<string, unknown>, 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<AppBindings>(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.");
}

View file

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

View file

@ -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<ApiEnv, "nodeEnv" | "stage">): 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<ApiEnv, "appOrigin" | "nodeEnv" | "stage">): Set<string> {
const origins = new Set<string>();
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<ApiEnv, "appOrigin" | "nodeEnv" | "stage">,
): 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);
}

View file

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

View file

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

View file

@ -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",

View file

@ -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<AppBindings>();
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;
}

View file

@ -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