mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-10-04 21:52:00 +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 (required when DEV_AUTH_BYPASS=false)
|
||||||
# COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/<pool-id>
|
# COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/<pool-id>
|
||||||
# COGNITO_AUDIENCE=<app-client-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)
|
# Aurora Data API driver (DATABASE_DRIVER=data-api)
|
||||||
# AWS_REGION=us-east-1
|
# 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
|
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.
|
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
|
# Authentication
|
||||||
|
|
||||||
## Cognito bearer tokens
|
## Cookie session (live path)
|
||||||
|
|
||||||
Production and seahaven-dev expect a Bearer Cognito token verified against the
|
The API is a same-origin BFF. Cognito hosted UI issues tokens. The API stores
|
||||||
user pool JWKS and issuer.
|
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:
|
Audience check:
|
||||||
|
|
||||||
|
|
@ -11,9 +34,7 @@ Audience check:
|
||||||
- Access tokens (`token_use=access`): `client_id` must equal `COGNITO_AUDIENCE`.
|
- Access tokens (`token_use=access`): `client_id` must equal `COGNITO_AUDIENCE`.
|
||||||
|
|
||||||
Identity claims (`sub`, `email`, and `name` or `cognito:username`) are required.
|
Identity claims (`sub`, `email`, and `name` or `cognito:username`) are required.
|
||||||
Prefer a Cognito **ID token**, which carries email/name by default. An access
|
`GET /api/me` uses the ID cookie.
|
||||||
token is accepted only when it includes an `email` claim (for example via a
|
|
||||||
pre-token-generation enrichment).
|
|
||||||
|
|
||||||
Optional role claim mapping:
|
Optional role claim mapping:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
bearerAuth:
|
cookieAuth:
|
||||||
type: http
|
type: apiKey
|
||||||
scheme: bearer
|
in: cookie
|
||||||
bearerFormat: JWT
|
name: __Host-ap_it
|
||||||
description: Cognito ID token preferred. Access tokens require an email claim. Local DEV_AUTH_BYPASS skips verification.
|
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:
|
info:
|
||||||
title: Sea Haven AP API
|
title: Sea Haven AP API
|
||||||
version: 1.0.0
|
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:
|
license:
|
||||||
name: Proprietary
|
name: Proprietary
|
||||||
servers:
|
servers:
|
||||||
|
|
@ -12,18 +12,26 @@ tags:
|
||||||
- name: Health
|
- name: Health
|
||||||
description: Liveness and readiness checks for the API process.
|
description: Liveness and readiness checks for the API process.
|
||||||
- name: Session
|
- 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:
|
paths:
|
||||||
/api/health:
|
/api/health:
|
||||||
$ref: ./paths/health.yaml
|
$ref: ./paths/health.yaml
|
||||||
/api/ready:
|
/api/ready:
|
||||||
$ref: ./paths/ready.yaml
|
$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:
|
/api/me:
|
||||||
$ref: ./paths/me.yaml
|
$ref: ./paths/me.yaml
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
bearerAuth:
|
cookieAuth:
|
||||||
$ref: ./components/security.yaml#/bearerAuth
|
$ref: ./components/security.yaml#/cookieAuth
|
||||||
schemas:
|
schemas:
|
||||||
Error:
|
Error:
|
||||||
$ref: ./components/schemas.yaml#/Error
|
$ref: ./components/schemas.yaml#/Error
|
||||||
|
|
@ -33,3 +41,5 @@ components:
|
||||||
$ref: ./components/schemas.yaml#/ReadyResponse
|
$ref: ./components/schemas.yaml#/ReadyResponse
|
||||||
MeResponse:
|
MeResponse:
|
||||||
$ref: ./components/schemas.yaml#/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.
|
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
|
operationId: get-api-me
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- cookieAuth: []
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Authenticated user profile.
|
description: Authenticated user profile.
|
||||||
|
|
@ -19,7 +19,7 @@ get:
|
||||||
name: Dev Admin
|
name: Dev Admin
|
||||||
role: admin
|
role: admin
|
||||||
"401":
|
"401":
|
||||||
description: Missing or invalid bearer token.
|
description: Missing or invalid session cookie.
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
|
|
@ -27,7 +27,7 @@ get:
|
||||||
example:
|
example:
|
||||||
error:
|
error:
|
||||||
code: UNAUTHENTICATED
|
code: UNAUTHENTICATED
|
||||||
message: Missing or invalid Authorization header.
|
message: Missing id token.
|
||||||
correlationId: 11111111-1111-4111-8111-111111111111
|
correlationId: 11111111-1111-4111-8111-111111111111
|
||||||
"409":
|
"409":
|
||||||
description: Email is already linked to a different Cognito subject.
|
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({
|
const secureEnv = loadEnv({
|
||||||
NODE_ENV: "test",
|
NODE_ENV: "test",
|
||||||
DEV_AUTH_BYPASS: "false",
|
DEV_AUTH_BYPASS: "false",
|
||||||
|
|
@ -118,11 +118,7 @@ describe("createApp smoke routes", () => {
|
||||||
const app = createApp(secureEnv, createTestDb());
|
const app = createApp(secureEnv, createTestDb());
|
||||||
const response = await app.request("/api/me");
|
const response = await app.request("/api/me");
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
expectEnvelope(
|
expectEnvelope(await response.json(), "UNAUTHENTICATED", "Missing id token.");
|
||||||
await response.json(),
|
|
||||||
"UNAUTHENTICATED",
|
|
||||||
"Missing or invalid Authorization header.",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unknown paths return the 404 error envelope", async () => {
|
it("unknown paths return the 404 error envelope", async () => {
|
||||||
|
|
@ -144,3 +140,138 @@ describe("createApp smoke routes", () => {
|
||||||
errorSpy.mockRestore();
|
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 { Hono } from "hono";
|
||||||
import type { ApiEnv } from "./env.js";
|
import type { ApiEnv } from "./env.js";
|
||||||
import type { Db } from "./db/client.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 { createHealthRoutes } from "./routes/health.js";
|
||||||
import { createMeRoutes } from "./routes/me.js";
|
import { createMeRoutes } from "./routes/me.js";
|
||||||
|
import { createAuthRoutes } from "./routes/auth.js";
|
||||||
import { errorJson } from "./http.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 app = new Hono<AppBindings>();
|
||||||
const auth = createAuthMiddleware(env, handle);
|
const auth = createAuthMiddleware(env, handle, deps);
|
||||||
|
|
||||||
const api = new Hono<AppBindings>();
|
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.use("*", auth);
|
||||||
|
api.route("/", createHealthRoutes(env, handle));
|
||||||
|
api.route("/", createAuthRoutes(env, deps));
|
||||||
api.route("/", createMeRoutes());
|
api.route("/", createMeRoutes());
|
||||||
app.route("/api", api);
|
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 { isUserRole } from "../env.js";
|
||||||
import type { Db } from "../db/client.js";
|
import type { Db } from "../db/client.js";
|
||||||
import { errorJson } from "../http.js";
|
import { errorJson } from "../http.js";
|
||||||
|
import { cookieEventFromHeader, sessionCookieValue } from "./cookies.js";
|
||||||
|
import { cookieStage, isPublicRoute } from "./oauth.js";
|
||||||
|
|
||||||
export type AppVariables = {
|
export type AppVariables = {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
|
|
@ -15,6 +17,12 @@ export type AppBindings = {
|
||||||
Variables: AppVariables;
|
Variables: AppVariables;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TokenVerifier = (token: string) => Promise<JWTPayload>;
|
||||||
|
|
||||||
|
export type AuthDeps = {
|
||||||
|
verifyToken?: TokenVerifier;
|
||||||
|
};
|
||||||
|
|
||||||
function roleFromClaims(claims: Record<string, unknown>, fallback: UserRole): UserRole {
|
function roleFromClaims(claims: Record<string, unknown>, fallback: UserRole): UserRole {
|
||||||
const raw =
|
const raw =
|
||||||
(typeof claims["custom:role"] === "string" && claims["custom:role"]) ||
|
(typeof claims["custom:role"] === "string" && claims["custom:role"]) ||
|
||||||
|
|
@ -56,13 +64,31 @@ function identityFromPayload(payload: JWTPayload): {
|
||||||
return { sub, email, name };
|
return { sub, email, name };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAuthMiddleware(env: ApiEnv, handle: Db) {
|
export function createAuthMiddleware(env: ApiEnv, handle: Db, deps: AuthDeps = {}) {
|
||||||
const jwks =
|
const jwks =
|
||||||
env.cognitoIssuer.length > 0
|
env.cognitoIssuer.length > 0
|
||||||
? createRemoteJWKSet(new URL(`${env.cognitoIssuer}/.well-known/jwks.json`))
|
? createRemoteJWKSet(new URL(`${env.cognitoIssuer}/.well-known/jwks.json`))
|
||||||
: null;
|
: 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) => {
|
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) {
|
if (env.devAuthBypass) {
|
||||||
try {
|
try {
|
||||||
const user = await upsertUserFromIdentity(handle, {
|
const user = await upsertUserFromIdentity(handle, {
|
||||||
|
|
@ -82,21 +108,15 @@ export function createAuthMiddleware(env: ApiEnv, handle: Db) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const header = c.req.header("authorization");
|
const stage = cookieStage(env);
|
||||||
if (!header?.startsWith("Bearer ")) {
|
const idToken = sessionCookieValue(cookieEventFromHeader(c.req.header("cookie")), "id", stage);
|
||||||
return errorJson(c, 401, "UNAUTHENTICATED", "Missing or invalid Authorization header.");
|
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;
|
let payload: JWTPayload;
|
||||||
try {
|
try {
|
||||||
({ payload } = await jwtVerify(token, jwks, {
|
payload = await verifyToken(idToken);
|
||||||
issuer: env.cognitoIssuer,
|
|
||||||
}));
|
|
||||||
} catch {
|
} catch {
|
||||||
return errorJson(c, 401, "UNAUTHENTICATED", "Invalid or expired token.");
|
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;
|
rdsDatabase: string;
|
||||||
cognitoIssuer: string;
|
cognitoIssuer: string;
|
||||||
cognitoAudience: string;
|
cognitoAudience: string;
|
||||||
|
cognitoDomain: string;
|
||||||
|
appOrigin: string;
|
||||||
|
originVerifySecret: string;
|
||||||
devAuthBypass: boolean;
|
devAuthBypass: boolean;
|
||||||
devAuthSub: string;
|
devAuthSub: string;
|
||||||
devAuthEmail: string;
|
devAuthEmail: string;
|
||||||
|
|
@ -73,6 +76,9 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
|
||||||
rdsDatabase: env.RDS_DATABASE ?? "seahaven_ap",
|
rdsDatabase: env.RDS_DATABASE ?? "seahaven_ap",
|
||||||
cognitoIssuer: env.COGNITO_ISSUER ?? "",
|
cognitoIssuer: env.COGNITO_ISSUER ?? "",
|
||||||
cognitoAudience: env.COGNITO_AUDIENCE ?? "",
|
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,
|
devAuthBypass,
|
||||||
devAuthSub: env.DEV_AUTH_SUB ?? "seed-sub-admin",
|
devAuthSub: env.DEV_AUTH_SUB ?? "seed-sub-admin",
|
||||||
devAuthEmail: env.DEV_AUTH_EMAIL ?? "admin@seahavenind.com",
|
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
|
- ready
|
||||||
- me
|
- me
|
||||||
- api
|
- api
|
||||||
|
- auth
|
||||||
|
- login
|
||||||
|
- callback
|
||||||
|
- refresh
|
||||||
|
- logout
|
||||||
paths-kebab-case: error
|
paths-kebab-case: error
|
||||||
no-invalid-schema-examples: error
|
no-invalid-schema-examples: error
|
||||||
# No schema-properties casing rule: property names mirror the DynamoDB
|
# No schema-properties casing rule: property names mirror the DynamoDB
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue