mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 18:33:19 +00:00
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:
parent
cc637e3732
commit
bbfa6e4a3c
23 changed files with 1207 additions and 41 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
|
|
|
|||
43
packages/api/openapi/paths/auth-callback.yaml
Normal file
43
packages/api/openapi/paths/auth-callback.yaml
Normal 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
|
||||
41
packages/api/openapi/paths/auth-login.yaml
Normal file
41
packages/api/openapi/paths/auth-login.yaml
Normal 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
|
||||
32
packages/api/openapi/paths/auth-logout.yaml
Normal file
32
packages/api/openapi/paths/auth-logout.yaml
Normal 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
|
||||
32
packages/api/openapi/paths/auth-refresh.yaml
Normal file
32
packages/api/openapi/paths/auth-refresh.yaml
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
104
packages/api/src/auth/cognito.ts
Normal file
104
packages/api/src/auth/cognito.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
138
packages/api/src/auth/cookies.test.ts
Normal file
138
packages/api/src/auth/cookies.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
248
packages/api/src/auth/cookies.ts
Normal file
248
packages/api/src/auth/cookies.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
20
packages/api/src/auth/oauth.test.ts
Normal file
20
packages/api/src/auth/oauth.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
229
packages/api/src/auth/oauth.ts
Normal file
229
packages/api/src/auth/oauth.ts
Normal 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);
|
||||
}
|
||||
18
packages/api/src/auth/origin-verify.ts
Normal file
18
packages/api/src/auth/origin-verify.ts
Normal 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);
|
||||
}
|
||||
23
packages/api/src/auth/pkce.ts
Normal file
23
packages/api/src/auth/pkce.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
16
packages/api/src/routes/auth.ts
Normal file
16
packages/api/src/routes/auth.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue