@auth-craft/aws-cf-stack 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +16 -0
- package/README.md +35 -3
- package/assets/gateway/worker.js +1 -1
- package/lib/common.sh +36 -11
- package/lib/deploy-gateway.sh +209 -92
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -46,6 +46,13 @@ LAMBDA_APP_BASE_PATH= # recommended — set a stable, non-guessable valu
|
|
|
46
46
|
# changes between deploys, so clients/workers don't break.
|
|
47
47
|
|
|
48
48
|
# ─── Cloudflare gateway workers ──────────────────────────────────────────────
|
|
49
|
+
# Deploy model: 'multi' (default — 3 workers, one scope each; AUTH_SCOPE fixed per worker,
|
|
50
|
+
# so scope is ENFORCED) or 'shared' (1 worker that derives scope from the path's first
|
|
51
|
+
# segment). Shared is simpler (1 deploy/binding) but does NOT enforce scope on its own —
|
|
52
|
+
# pair it with a scope-locked Pages proxy (@auth-craft/pages-auth-proxy `scope` option) if
|
|
53
|
+
# customer apps must never reach system/tenant. In shared mode CORS unions all scopes and
|
|
54
|
+
# the CORS_KV uses CF_CORS_KV_ID_SHARED (or falls back to CF_CORS_KV_ID_CUSTOMER).
|
|
55
|
+
CF_GATEWAY_MODE=multi # multi | shared
|
|
49
56
|
CLOUDFLARE_API_TOKEN= # required for gateway (flag --cf-api-token)
|
|
50
57
|
CLOUDFLARE_ACCOUNT_ID= # required for gateway (flag --cf-account-id)
|
|
51
58
|
CF_EDGE_GATE_SECRET= # required for gateway (flag --edge-gate-secret) — Client → Worker gate
|
|
@@ -84,6 +91,15 @@ CF_WORKER_DISABLE_PUBLIC_URL= # shared default (e.g. true to disable al
|
|
|
84
91
|
CF_WORKER_DISABLE_PUBLIC_URL_SYSTEM= # e.g. true — admin app via Pages proxy
|
|
85
92
|
CF_WORKER_DISABLE_PUBLIC_URL_TENANT=
|
|
86
93
|
CF_WORKER_DISABLE_PUBLIC_URL_CUSTOMER=
|
|
94
|
+
# Skip the X-Edge-Gate (Client → Worker) check. Redundant when the worker has NO public
|
|
95
|
+
# URL and is reached only via a Service Binding (Pages proxy) — so it's ONLY honored when
|
|
96
|
+
# CF_WORKER_DISABLE_PUBLIC_URL is also set for that scope (else ignored with a warning).
|
|
97
|
+
# Does NOT affect the Worker → Backend secret (X-Gateway-Secret stays required). Truthy:
|
|
98
|
+
# 1/true/yes. When skipped, EDGE_GATE_SECRET is not needed.
|
|
99
|
+
CF_WORKER_SKIP_EDGE_GATE= # shared default
|
|
100
|
+
CF_WORKER_SKIP_EDGE_GATE_SYSTEM= # e.g. true (pairs with DISABLE_PUBLIC_URL_SYSTEM)
|
|
101
|
+
CF_WORKER_SKIP_EDGE_GATE_TENANT=
|
|
102
|
+
CF_WORKER_SKIP_EDGE_GATE_CUSTOMER=
|
|
87
103
|
|
|
88
104
|
# ─── Internal service JWT (optional — enables /internal/* + service worker verify) ─
|
|
89
105
|
LAMBDA_INTERNAL_JWT_PUBLIC_KEY= # optional — also fed to workers as SERVICE_JWT_PUBLIC_KEY
|
package/README.md
CHANGED
|
@@ -17,7 +17,9 @@ or building the auth-craft repo** — just pass env and run.
|
|
|
17
17
|
|
|
18
18
|
1. **DynamoDB + Lambda** (CDK) — the auth backend. Emits a Function URL + a base path.
|
|
19
19
|
2. **Super-admin** (idempotent) — bootstraps the `__system__` tenant owner.
|
|
20
|
-
3. **
|
|
20
|
+
3. **Gateway worker(s)** on Cloudflare, via wrangler. Default `multi` = **3 workers**
|
|
21
|
+
(system / tenant / customer); `CF_GATEWAY_MODE=shared` = **1 worker**. See
|
|
22
|
+
[Deploy model](#deploy-model-multi-vs-shared).
|
|
21
23
|
|
|
22
24
|
The gateway's `BACKEND_URL` is computed as `FunctionURL + ApiBasePath` read back from the
|
|
23
25
|
**live CDK outputs** of the same deploy — so the worker config can never drift from the
|
|
@@ -33,18 +35,38 @@ Everything is named from `--stage` (`dev|staging|prod`) and `--project` (default
|
|
|
33
35
|
|----------|------|
|
|
34
36
|
| CDK stacks | `<app>-<stage>-data`, `<app>-<stage>-api` |
|
|
35
37
|
| Lambda fn + DynamoDB table | `<app>-<stage>` |
|
|
36
|
-
| Gateway workers | `<app>-<stage>-system` / `-tenant` / `-customer` |
|
|
38
|
+
| Gateway workers (`multi`) | `<app>-<stage>-system` / `-tenant` / `-customer` |
|
|
39
|
+
| Gateway worker (`shared`) | `<app>-<stage>` |
|
|
37
40
|
|
|
38
41
|
So `outputs`/`gateway` look up the api stack `<app>-<stage>-api`. The CDK output keys are
|
|
39
42
|
`LambdaFunctionUrl`, `ApiBasePath`, `AuthSystemName`, `DynamoDBTableName`.
|
|
40
43
|
|
|
44
|
+
### Deploy model (multi vs shared)
|
|
45
|
+
|
|
46
|
+
`CF_GATEWAY_MODE` picks how the gateway is deployed:
|
|
47
|
+
|
|
48
|
+
- **`multi`** (default) — 3 workers, one scope each. Each worker has a fixed `AUTH_SCOPE`,
|
|
49
|
+
so the scope is **enforced server-side**: a customer worker can never act as system,
|
|
50
|
+
whatever path the client sends. Strongest isolation; 3 deploys + 3 Service Bindings.
|
|
51
|
+
- **`shared`** — 1 worker. Scope is derived from the path's first segment
|
|
52
|
+
(`/system/...` → `system`), so the worker by itself does **not** enforce scope (a client
|
|
53
|
+
could send `/system/...`). Simpler: 1 deploy, 1 binding, unioned CORS. **Pair it with a
|
|
54
|
+
scope-locked Pages proxy** (`createPagesAuthProxy({ scope: 'customer' })` from
|
|
55
|
+
[@auth-craft/pages-auth-proxy](../pages-auth-proxy/README.md)) so the *proxy* prepends
|
|
56
|
+
the scope and the client never picks it — that restores the "customer can't reach
|
|
57
|
+
system" guarantee at the edge.
|
|
58
|
+
|
|
59
|
+
In `shared` mode CORS unions all per-scope origins/suffixes, and the dynamic-CORS KV uses
|
|
60
|
+
`CF_CORS_KV_ID_SHARED` (falling back to `CF_CORS_KV_ID_CUSTOMER`). `outputs.workerNames.*`
|
|
61
|
+
all point at the single worker.
|
|
62
|
+
|
|
41
63
|
## Commands
|
|
42
64
|
|
|
43
65
|
| Command | Does |
|
|
44
66
|
|---------|------|
|
|
45
67
|
| `lambda` | Deploy the DynamoDB + Lambda CDK stacks. `--outputs-file <p>` writes the raw CDK outputs JSON. Runs a best-effort `/health` check. |
|
|
46
68
|
| `admin` | Create the super-admin (idempotent; **skips** if `LAMBDA_SUPER_ADMIN_*` / `LAMBDA_AUTH_SETUP_TOKEN` unset; "already exists" = success). |
|
|
47
|
-
| `gateway` | Deploy the 3
|
|
69
|
+
| `gateway` | Deploy the gateway worker(s) — 3 (`multi`, default) or 1 (`CF_GATEWAY_MODE=shared`). Re-reads the Lambda outputs from CloudFormation if run as a separate invocation. |
|
|
48
70
|
| `all` | `lambda` → `admin` → `gateway`, one run. |
|
|
49
71
|
| `outputs` | Print the live api-stack outputs as JSON (no deploy): `backendUrl`, `apiBasePath`, `dynamodbTable`, and the auth keys read **from the live Lambda config** (`jwtPublicKey`, `jwtIssuer`, `jwtAlgorithm`, `serviceJwtPublicKey`) + `gatewayUrls`/`routePrefixes`. Self-contained — works standalone (no env), so an orchestrator can auto-fill its `*_AUTH_*` vars. |
|
|
50
72
|
|
|
@@ -230,6 +252,16 @@ domain). This applies only when no `CF_WORKER_CUSTOM_DOMAIN_<SCOPE>` is set for
|
|
|
230
252
|
if you're using `AUTH_GATEWAY_URL` (no binding) leave it off, since that path needs the
|
|
231
253
|
public URL.
|
|
232
254
|
|
|
255
|
+
**Skip the edge gate (optional).** The `X-Edge-Gate` secret exists to stop the public
|
|
256
|
+
internet from hitting the worker. Once the public URL is disabled and the worker is
|
|
257
|
+
reached only via a Service Binding, that gate is redundant — set
|
|
258
|
+
`CF_WORKER_SKIP_EDGE_GATE=true` (or per scope) to drop it; the Pages proxy then needs no
|
|
259
|
+
`EDGE_GATE_SECRET`. For safety it's **only honored when the public URL is also disabled**
|
|
260
|
+
for that scope (otherwise ignored with a warning). It does **not** touch the
|
|
261
|
+
Worker → Backend secret: `X-Gateway-Secret` stays required because the backend (Lambda
|
|
262
|
+
Function URL) is still on the public internet — without it, anyone could call the Lambda
|
|
263
|
+
directly.
|
|
264
|
+
|
|
233
265
|
### Per-scope token transport
|
|
234
266
|
|
|
235
267
|
`REFRESH_TOKEN_STRATEGY` sets the transport for every scope. To mix, use the per-scope
|
package/assets/gateway/worker.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var Et=typeof process<"u"&&!1;function _(t){return Et?Object.freeze(t):t}function h(t,e){if(arguments.length===0)return _({ok:!0,data:void 0});let n={ok:!0,data:t};return e!==void 0&&(n.meta=e),_(n)}function a(t,e){let r={ok:!1,errors:_(Array.isArray(t)?[...t]:[t])};return e!==void 0&&(r.meta=e),_(r)}function d(t,e){return n=>{let r={code:t,message:n?.message??e};return n?.field&&(r.field=n.field),r}}function N(t){return t.ok?t.data===void 0?t.meta?{ok:!0,meta:t.meta}:{ok:!0}:t.meta?{ok:!0,data:t.data,meta:t.meta}:{ok:!0,data:t.data}:t.meta?{ok:!1,errors:t.errors,meta:t.meta}:{ok:!1,errors:t.errors}}function M(t){let n=["BACKEND_URL","BACKEND_GATEWAY_SECRET","EDGE_GATE_SECRET","JWT_PUBLIC_KEY","SERVICE_JWT_PUBLIC_KEY"].filter(r=>!t[r]);if(n.length>0)throw new Error(`Missing required environment variables: ${n.join(", ")}`)}function pt(t){return t.ENVIRONMENT||"development"}function S(t){return pt(t)==="development"}function U(t){return t.ALLOWED_ORIGINS?t.ALLOWED_ORIGINS.split(",").map(e=>e.trim()).filter(Boolean):["*"]}function gt(t){return t.ALLOWED_ORIGIN_SUFFIXES?t.ALLOWED_ORIGIN_SUFFIXES.split(",").map(e=>e.trim().toLowerCase()).filter(Boolean):[]}function P(t){return`origin:${t}`}async function v(t,e){if(!t)return!1;if(U(e).includes(t))return!0;let r=gt(e);if(r.length>0){let o;try{o=new URL(t).host.toLowerCase()}catch{return!1}if(r.some(i=>o===i.replace(/^\./,"")||o.endsWith(i)))return!0}return!!(e.CORS_KV&&await e.CORS_KV.get(P(t))!==null)}function I(t){let e=t.ROUTE_PREFIX||"";if(!e)return"";let n=e.startsWith("/")?e:`/${e}`;return n.endsWith("/")?n.slice(0,-1):n}function C(t,e){let n=I(e);return n?t===n?"/":t.startsWith(`${n}/`)?t.slice(n.length):t:t}var At=new Set(["system","tenant","customer"]);function L(t){return t&&At.has(t)?t:void 0}function H(t,e){let n=L(t.AUTH_SCOPE);if(n)return{scope:n,forwardPath:e};let r=L(I(t).replace(/^\//,""));if(r)return{scope:r,forwardPath:e};let o=e.split("/").filter(Boolean),i=L(o[0]);if(i){let s=e.slice(e.indexOf(o[0])+o[0].length);return{scope:i,forwardPath:s||"/"}}return{scope:void 0,forwardPath:e}}function F(t,e){let n=I(e);return n?t===n||t.startsWith(`${n}/`):!0}function X(t){if(!t)return new Set(["EdDSA"]);let e=t.split(",").map(n=>n.trim()).filter(Boolean);return e.length>0?new Set(e):new Set(["EdDSA"])}function $(t){return X(t.JWT_ALLOWED_ALGORITHMS)}function Y(t){return X(t.SERVICE_JWT_ALLOWED_ALGORITHMS)}function ht(t){let e=t.SERVICE_ROUTE_PREFIX||"/internal",n=e.startsWith("/")?e:`/${e}`;return n.endsWith("/")?n.slice(0,-1):n}function j(t){return t.SERVICE_JWT_AUDIENCE||"auth-craft:internal"}function z(t){return t.JWT_ISSUER||void 0}function Q(t){return t.SERVICE_JWT_ISSUER||void 0}function Z(t,e){let n=t.split("?")[0].replace(/\/$/,"")||"/",r=ht(e);return n===r||n.startsWith(`${r}/`)}var mt=new Set(["authenticate","login","register","refresh","logout","complete-challenge","complete-mfa-challenge","issue-mfa-challenge","forgot-password","reset-password","verify-email","resend-verification"]),B=new Set(["/verification/resend","/verification/verify","/password/forgot","/password/reset"]);function q(t){let e=t.split("?")[0].replace(/\/$/,"")||"/";if(e==="/"||e.startsWith("/oauth/")||e==="/oauth"||B.has(e))return!0;let n=e.match(/^\/[^/]+(\/(?:verification|password)\/[^/]+)$/);if(n?.[1]&&B.has(n[1]))return!0;let r=e.match(/^(?:\/[^/]+)?\/auth\/([^/]+)/);if(r){let o=r[1];if(o&&mt.has(o)||o?.startsWith("oauth"))return!0}return!1}function tt(t){return t.GATEWAY_JWT_PRIVATE_KEY||void 0}function et(t){return t.TID_VALUE||void 0}function rt(t){let e=t.split("?")[0].replace(/\/$/,"");return e.endsWith("/auth/login")||e.endsWith("/auth/authenticate")}function Rt(){let t=Date.now(),e=new Uint8Array(16);e[0]=t/1099511627776&255,e[1]=t/4294967296&255,e[2]=t/16777216&255,e[3]=t/65536&255,e[4]=t/256&255,e[5]=t&255;let n=new Uint8Array(10);crypto.getRandomValues(n),e.set(n,6),e[6]=e[6]&15|112,e[8]=e[8]&63|128;let r="0123456789ABCDEFGHJKMNPQRSTVWXYZ",o="",i=0,s=0;for(let c=0;c<e.length;c++)for(s=s<<8|e[c],i+=8;i>=5;)o+=r[s>>>i-5&31],i-=5;return i>0&&(o+=r[s<<5-i&31]),o}var w={BAD_REQUEST:d("BAD_REQUEST","Bad request"),UNAUTHORIZED:d("UNAUTHORIZED","Authentication required"),FORBIDDEN:d("FORBIDDEN","Access denied"),NOT_FOUND:d("NOT_FOUND","Resource not found"),RATE_LIMIT_EXCEEDED:d("RATE_LIMIT_EXCEEDED","Too many requests"),INTERNAL_ERROR:d("INTERNAL_ERROR","Internal server error"),BAD_GATEWAY:d("BAD_GATEWAY","Gateway error"),SERVICE_UNAVAILABLE:d("SERVICE_UNAVAILABLE","Service temporarily unavailable"),GATEWAY_TIMEOUT:d("GATEWAY_TIMEOUT","Gateway timeout")},f={INVALID_FORMAT:d("JWT_INVALID_FORMAT","Invalid JWT format"),UNSUPPORTED_ALGORITHM:d("JWT_UNSUPPORTED_ALGORITHM","Unsupported JWT algorithm"),INVALID_TOKEN_TYPE:d("JWT_INVALID_TOKEN_TYPE","Invalid token type: expected access token"),TOKEN_EXPIRED:d("JWT_TOKEN_EXPIRED","JWT token expired"),SIGNATURE_INVALID:d("JWT_SIGNATURE_INVALID","JWT signature verification failed"),MISSING_TOKEN:d("JWT_MISSING_TOKEN","Missing access token"),INVALID_ISSUER:d("JWT_INVALID_ISSUER","Invalid JWT issuer")},nt={BAD_REQUEST:400,UNAUTHORIZED:401,FORBIDDEN:403,NOT_FOUND:404,RATE_LIMIT_EXCEEDED:429,INTERNAL_ERROR:500,BAD_GATEWAY:502,SERVICE_UNAVAILABLE:503,GATEWAY_TIMEOUT:504,JWT_INVALID_FORMAT:401,JWT_UNSUPPORTED_ALGORITHM:401,JWT_INVALID_TOKEN_TYPE:401,JWT_TOKEN_EXPIRED:401,JWT_SIGNATURE_INVALID:401,JWT_MISSING_TOKEN:401,JWT_INVALID_ISSUER:401};function St(t){if(t.ok)return t.data===void 0?204:200;let e=t.errors?.[0];return e?.code&&e.code in nt?nt[e.code]:500}function l(t,e){let n=new Date().toISOString(),r=e||Rt(),o={...t.meta,timestamp:n,traceId:r},i=t.ok?h(t.data,o):a(t.errors,o),s=St(i),c=N(i);return new Response(JSON.stringify(c),{status:s,headers:{"Content-Type":"application/json"}})}var wt=new Set(["EdDSA","ES256","RS256","PS256"]);function yt(t){return wt.has(t)}function _t(t){switch(t){case"EdDSA":return{name:"Ed25519"};case"ES256":return{name:"ECDSA",namedCurve:"P-256"};case"RS256":return{name:"RSASSA-PKCS1-v1_5",hash:"SHA-256"};case"PS256":return{name:"RSA-PSS",hash:"SHA-256"}}}function It(t){switch(t){case"EdDSA":return"Ed25519";case"ES256":return{name:"ECDSA",hash:"SHA-256"};case"RS256":return{name:"RSASSA-PKCS1-v1_5"};case"PS256":return{name:"RSA-PSS",saltLength:32}}}function x(t){let e=t.replace(/-/g,"+").replace(/_/g,"/"),n=e+"==".slice(0,(4-e.length%4)%4),r=atob(n),o=new Uint8Array(r.length);for(let i=0;i<r.length;i++)o[i]=r.charCodeAt(i);return o}function ot(t){return atob(t)}var Ot=/^[0-9a-fA-F]{64}$/;async function Dt(t,e){if(Ot.test(t)){if(e!=="EdDSA")throw new Error(`Hex key format is only supported for EdDSA, got ${e}`);let r=new Uint8Array(32);for(let o=0;o<32;o++)r[o]=parseInt(t.slice(o*2,o*2+2),16);return crypto.subtle.importKey("raw",r,{name:"Ed25519"},!1,["verify"])}let n;try{n=JSON.parse(ot(t))}catch{throw new Error(`Invalid JWK base64 for algorithm ${e}`)}return await crypto.subtle.importKey("jwk",n,_t(e),!1,["verify"])}function it(t){let e=t.split(".");if(e.length!==3)return null;let n=e[1];if(!n)return null;try{let r=x(n);return JSON.parse(new TextDecoder().decode(r))}catch{return null}}function Pt(t){let n=t.split(".")[0];if(!n)return a(f.INVALID_FORMAT());try{let r=x(n);return h(JSON.parse(new TextDecoder().decode(r)))}catch{return a(f.INVALID_FORMAT())}}function Ct(t){let n=t.split(".")[1];if(!n)return a(f.INVALID_FORMAT());try{let r=x(n);return h(JSON.parse(new TextDecoder().decode(r)))}catch{return a(f.INVALID_FORMAT())}}async function xt(t,e,n){let r=t.split("."),o=r[0],i=r[1],s=r[2];if(!o||!i||!s)return!1;let c=new TextEncoder().encode(`${o}.${i}`),u=x(s);try{return await crypto.subtle.verify(It(n),e,u,c)}catch{return!1}}async function b(t,e,n=1,r=new Set(["EdDSA"]),o){let i=Pt(t);if(!i.ok)return a(i.errors,i.meta);let s=i.data.alg;if(!r.has(s))return a(f.UNSUPPORTED_ALGORITHM());if(!yt(s))return a(f.UNSUPPORTED_ALGORITHM());let c=Ct(t);if(!c.ok)return a(c.errors,c.meta);let u=c.data;if(u.typ!==n)return a(f.INVALID_TOKEN_TYPE());if(o!==void 0&&u.iss!==o)return a(f.INVALID_ISSUER());if(u.exp&&Math.floor(Date.now()/1e3)>=u.exp)return a(f.TOKEN_EXPIRED());let E;try{E=await Dt(e,s)}catch{return a(f.INVALID_FORMAT())}return await xt(t,E,s)?h(u):a(f.SIGNATURE_INVALID())}var W=null;async function Nt(t){let e;try{e=JSON.parse(ot(t))}catch{throw new Error("Invalid Gateway JWT private key: expected base64-encoded JWK")}return await crypto.subtle.importKey("jwk",e,{name:"Ed25519"},!1,["sign"])}function J(t){let e="";for(let n=0;n<t.length;n++)e+=String.fromCharCode(t[n]);return btoa(e).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}async function st(t,e,n,r,o=30){W||(W=Nt(t));let i=await W,s=Math.floor(Date.now()/1e3),c={alg:"EdDSA",typ:"JWT"},u={iss:e,aud:n,sub:r,iat:s,exp:s+o,jti:crypto.randomUUID()},E=J(new TextEncoder().encode(JSON.stringify(c))),g=J(new TextEncoder().encode(JSON.stringify(u))),m=new TextEncoder().encode(`${E}.${g}`),R=await crypto.subtle.sign("Ed25519",i,m),T=J(new Uint8Array(R));return`${E}.${g}.${T}`}var Lt=["content-type","accept","accept-encoding","accept-language","user-agent","cookie"];function Ut(t){return t.getSetCookie?.()??[]}async function Wt(t,e){if(!(t.headers.get("content-type")||"").includes("application/json"))return null;let r;try{r=await t.clone().json()}catch{return null}return delete r.tid,r.tid=e,{body:JSON.stringify(r),contentType:"application/json"}}function Jt(t,e,n,r,o,i){let s=new Headers;for(let m of Lt){let R=t.headers.get(m);R&&s.set(m,R)}o&&s.set("content-type",o);let c=t.headers.get("Authorization");c&&s.set("Authorization",c),r&&s.set("X-Auth-Context",r),s.set("X-Gateway-Secret",e),i&&s.set("x-gateway-auth",`Bearer ${i}`),s.set("X-Request-Id",n);let u=t.headers.get("cf-connecting-ip");u&&(s.set("X-Forwarded-For",u),s.set("X-Real-IP",u)),s.set("X-Forwarded-Proto","https");let E=new URL(t.url);s.set("X-Forwarded-Host",E.hostname);let g=t.headers.get("cf-ipcountry");return g&&s.set("X-Cloudflare-Country",g),s}function bt(t,e,n){let r=new URL(t.url),o=e.BACKEND_URL.endsWith("/")?e.BACKEND_URL.slice(0,-1):e.BACKEND_URL;S(e)&&console.log("[Gateway] Path transform:",JSON.stringify({original:r.pathname,forwarded:n}));let i=new URL(`${o}${n}`);return i.search=r.search,i.toString()}function kt(t,e,n){let r=new Headers(t.headers);r.set("X-Request-Id",e);let o=Ut(t.headers);if(o.length>0){r.delete("Set-Cookie");for(let i of o)r.append("Set-Cookie",i);S(n)&&console.log("[Gateway] Forwarding Set-Cookie:",o.length,"cookie(s)")}return new Response(t.body,{status:t.status,statusText:t.statusText,headers:r})}async function k(t,e){let n=crypto.randomUUID();try{let r=new URL(t.url),o=C(r.pathname,e),{scope:i,forwardPath:s}=H(e,o),c=bt(t,e,s),u,E,g=et(e);if(g&&rt(s)&&t.method==="POST"){let y=await Wt(t,g);y&&(u=y.body,E=y.contentType,S(e)&&console.log("[Gateway] Injected tid:",g,"into",s))}let m,R=tt(e);R&&(m=await st(R,"bff-worker","auth-craft:backend","cloudflare-auth-gateway"));let T=Jt(t,e.BACKEND_GATEWAY_SECRET,n,i,E,m),D=u!==void 0?u:t.body?await t.clone().arrayBuffer():void 0,K=await fetch(c,{method:t.method,headers:T,body:D});return kt(K,n,e)}catch(r){console.error("[Gateway] Proxy error:",r);let o=r instanceof Error?r.message:"Unknown error";return l(a(d("BAD_GATEWAY",`Failed to reach backend: ${o}`)()))}}var O=new Map,at=0,Gt=[{match:t=>t==="/auth/authenticate",limit:15,windowMs:6e4,prefix:"login"},{match:t=>t==="/auth/register",limit:8,windowMs:6e4,prefix:"register"},{match:t=>t==="/password/forgot",limit:8,windowMs:3e5,prefix:"forgot"},{match:t=>t.startsWith("/oauth/"),limit:30,windowMs:6e4,prefix:"oauth"},{match:t=>t.startsWith("/auth/complete-"),limit:30,windowMs:6e4,prefix:"complete"},{match:t=>t==="/auth/issue-mfa-challenge",limit:15,windowMs:6e4,prefix:"issue-mfa"}];function Kt(t){let e=Date.now();if(e-at<3e4)return;at=e;let n=e-t;for(let[r,o]of O){let i=o.hits.filter(s=>s>n);i.length===0?O.delete(r):o.hits=i}}function ct(t,e,n){if(n!=="POST")return{allowed:!0,retryAfterSec:0};let r=Gt.find(E=>E.match(e));if(!r)return{allowed:!0,retryAfterSec:0};Kt(3e5);let o=`${r.prefix}:${t}`,i=Date.now(),s=i-r.windowMs,c=O.get(o)??{hits:[]},u=c.hits.filter(E=>E>s);if(u.length>=r.limit){let E=u[0]??i;return c.hits=u,O.set(o,c),{allowed:!1,retryAfterSec:Math.max(1,Math.ceil((E+r.windowMs-i)/1e3))}}return u.push(i),c.hits=u,O.set(o,c),{allowed:!0,retryAfterSec:0}}function ut(t,e){if(t.length!==e.length)return!1;let n=0;for(let r=0;r<t.length;r++)n|=t.charCodeAt(r)^e.charCodeAt(r);return n===0}async function lt(t,e){return t&&await v(t,e)?{"Access-Control-Allow-Origin":t,"Access-Control-Allow-Credentials":"true",Vary:"Origin"}:U(e).includes("*")?{"Access-Control-Allow-Origin":"*"}:{Vary:"Origin"}}async function Vt(t,e){let n=t.headers.get("Origin")??"*",r=t.headers.get("Access-Control-Request-Headers")??"";return new Response(null,{status:204,headers:{...await lt(n,e),"Access-Control-Allow-Methods":"GET, POST, PUT, DELETE, OPTIONS, PATCH","Access-Control-Allow-Headers":r||"Content-Type, Authorization, X-Requested-With, X-Edge-Gate","Access-Control-Max-Age":"86400"}})}async function p(t,e,n){let r=e.headers.get("Origin")??"*",o=new Response(t.body,t),i=t.headers.getSetCookie?.()??[];if(i.length>0){o.headers.delete("Set-Cookie");for(let c of i)o.headers.append("Set-Cookie",c)}let s=await lt(r,n);for(let[c,u]of Object.entries(s))o.headers.set(c,u);return o.headers.set("Access-Control-Expose-Headers","Content-Length, X-Request-Id, Set-Cookie"),o}var Bt="/internal/cors-origins";function Mt(t){return t.split("?")[0].replace(/\/$/,"")===Bt}async function vt(t,e){if(!e.CORS_KV)return G();if(t.method==="GET"){let r=(await e.CORS_KV.list({prefix:"origin:"})).keys.map(o=>o.name.slice(7));return l(h({origins:r}))}if(t.method==="PUT"||t.method==="DELETE"){let n;try{n=(await t.json()).origin}catch{return l(a(w.BAD_REQUEST()))}if(typeof n!="string"||!n)return l(a(w.BAD_REQUEST()));let r;try{if(r=new URL(n).origin,r==="null"||r!==n.replace(/\/$/,""))return l(a(w.BAD_REQUEST()))}catch{return l(a(w.BAD_REQUEST()))}return t.method==="PUT"?await e.CORS_KV.put(P(r),"1"):await e.CORS_KV.delete(P(r)),l(h({origin:r}))}return l(a(w.BAD_REQUEST()))}function G(){return new Response(null,{status:404})}var Pe={async fetch(t,e,n){try{M(e);let r=new URL(t.url);if(!F(r.pathname,e))return G();if(t.method==="OPTIONS")return await Vt(t,e);let o=t.headers.get("X-Edge-Gate");if(!o||!e.EDGE_GATE_SECRET||!ut(o,e.EDGE_GATE_SECRET))return S(e)&&console.warn("[Gateway] X-Edge-Gate missing or invalid"),G();let i=I(e),s=i?`${i}/health`:"/health";if(r.pathname===s)return await p(l(h({status:"healthy",timestamp:new Date().toISOString()})),t,e);let c=C(r.pathname,e);S(e)&&console.log("[Gateway] Request:",t.method,r.pathname,"\u2192",c);let u=t.headers.get("cf-connecting-ip")??"unknown",E=ct(u,c,t.method);if(!E.allowed){let A=l(a(w.RATE_LIMIT_EXCEEDED()));return A.headers.set("Retry-After",String(E.retryAfterSec)),await p(A,t,e)}let g=Z(c,e);if(!g&&q(c))return await p(await k(t,e),t,e);let m=t.headers.get("Authorization");if(!m)return await p(l(a(f.MISSING_TOKEN())),t,e);let R=m.match(/^Bearer\s+(.+)$/i);if(!R)return await p(l(a(f.INVALID_FORMAT())),t,e);let T=R[1];if(T.length>4096)return await p(l(a(f.INVALID_FORMAT())),t,e);let D=T.split(".");if(D.length!==3)return await p(l(a(f.INVALID_FORMAT())),t,e);if(D[1].length>2048)return await p(l(a(f.INVALID_FORMAT())),t,e);let y=it(T);if(!y)return await p(l(a(f.INVALID_FORMAT())),t,e);let ft=y.typ,dt=g?3:1;if(ft!==dt)return await p(l(a(f.INVALID_TOKEN_TYPE())),t,e);if(g&&y.aud!==j(e))return await p(l(a(f.INVALID_TOKEN_TYPE())),t,e);let V=y.exp;if(typeof V=="number"&&Math.floor(Date.now()/1e3)>=V)return await p(l(a(f.TOKEN_EXPIRED())),t,e);if(g){if(!e.SERVICE_JWT_PUBLIC_KEY)return await p(l(a(w.INTERNAL_ERROR())),t,e);let A=await b(T,e.SERVICE_JWT_PUBLIC_KEY,3,Y(e),Q(e));if(!A.ok)return S(e)&&console.error("[Gateway] Service JWT failed:",A.errors),await p(l(a(A.errors,A.meta)),t,e)}else{let A=await b(T,e.JWT_PUBLIC_KEY,1,$(e),z(e));if(!A.ok)return S(e)&&console.error("[Gateway] User JWT failed:",A.errors),await p(l(a(A.errors,A.meta)),t,e)}return g&&Mt(c)?await p(await vt(t,e),t,e):await p(await k(t,e),t,e)}catch(r){return S(e)&&console.error("[Gateway] Unhandled error:",r),l(a(w.INTERNAL_ERROR()))}}};export{Pe as default};
|
|
1
|
+
var Et=typeof process<"u"&&!1;function y(t){return Et?Object.freeze(t):t}function m(t,e){if(arguments.length===0)return y({ok:!0,data:void 0});let n={ok:!0,data:t};return e!==void 0&&(n.meta=e),y(n)}function a(t,e){let r={ok:!1,errors:y(Array.isArray(t)?[...t]:[t])};return e!==void 0&&(r.meta=e),y(r)}function E(t,e){return n=>{let r={code:t,message:n?.message??e};return n?.field&&(r.field=n.field),r}}function N(t){return t.ok?t.data===void 0?t.meta?{ok:!0,meta:t.meta}:{ok:!0}:t.meta?{ok:!0,data:t.data,meta:t.meta}:{ok:!0,data:t.data}:t.meta?{ok:!1,errors:t.errors,meta:t.meta}:{ok:!1,errors:t.errors}}function v(t){let e=["BACKEND_URL","BACKEND_GATEWAY_SECRET","JWT_PUBLIC_KEY","SERVICE_JWT_PUBLIC_KEY"];U(t)||e.push("EDGE_GATE_SECRET");let n=e.filter(r=>!t[r]);if(n.length>0)throw new Error(`Missing required environment variables: ${n.join(", ")}`)}function U(t){return t.SKIP_EDGE_GATE==="true"}function pt(t){return t.ENVIRONMENT||"development"}function S(t){return pt(t)==="development"}function W(t){return t.ALLOWED_ORIGINS?t.ALLOWED_ORIGINS.split(",").map(e=>e.trim()).filter(Boolean):["*"]}function gt(t){return t.ALLOWED_ORIGIN_SUFFIXES?t.ALLOWED_ORIGIN_SUFFIXES.split(",").map(e=>e.trim().toLowerCase()).filter(Boolean):[]}function P(t){return`origin:${t}`}async function H(t,e){if(!t)return!1;if(W(e).includes(t))return!0;let r=gt(e);if(r.length>0){let o;try{o=new URL(t).host.toLowerCase()}catch{return!1}if(r.some(i=>o===i.replace(/^\./,"")||o.endsWith(i)))return!0}return!!(e.CORS_KV&&await e.CORS_KV.get(P(t))!==null)}function O(t){let e=t.ROUTE_PREFIX||"";if(!e)return"";let n=e.startsWith("/")?e:`/${e}`;return n.endsWith("/")?n.slice(0,-1):n}function C(t,e){let n=O(e);return n?t===n?"/":t.startsWith(`${n}/`)?t.slice(n.length):t:t}var At=new Set(["system","tenant","customer"]);function L(t){return t&&At.has(t)?t:void 0}function F(t,e){let n=L(t.AUTH_SCOPE);if(n)return{scope:n,forwardPath:e};let r=L(O(t).replace(/^\//,""));if(r)return{scope:r,forwardPath:e};let o=e.split("/").filter(Boolean),i=L(o[0]);if(i){let s=e.slice(e.indexOf(o[0])+o[0].length);return{scope:i,forwardPath:s||"/"}}return{scope:void 0,forwardPath:e}}function X(t,e){let n=O(e);return n?t===n||t.startsWith(`${n}/`):!0}function $(t){if(!t)return new Set(["EdDSA"]);let e=t.split(",").map(n=>n.trim()).filter(Boolean);return e.length>0?new Set(e):new Set(["EdDSA"])}function Y(t){return $(t.JWT_ALLOWED_ALGORITHMS)}function j(t){return $(t.SERVICE_JWT_ALLOWED_ALGORITHMS)}function ht(t){let e=t.SERVICE_ROUTE_PREFIX||"/internal",n=e.startsWith("/")?e:`/${e}`;return n.endsWith("/")?n.slice(0,-1):n}function z(t){return t.SERVICE_JWT_AUDIENCE||"auth-craft:internal"}function Q(t){return t.JWT_ISSUER||void 0}function Z(t){return t.SERVICE_JWT_ISSUER||void 0}function q(t,e){let n=t.split("?")[0].replace(/\/$/,"")||"/",r=ht(e);return n===r||n.startsWith(`${r}/`)}var mt=new Set(["authenticate","login","register","refresh","logout","complete-challenge","complete-mfa-challenge","issue-mfa-challenge","forgot-password","reset-password","verify-email","resend-verification"]),M=new Set(["/verification/resend","/verification/verify","/password/forgot","/password/reset"]);function tt(t){let e=t.split("?")[0].replace(/\/$/,"")||"/";if(e==="/"||e.startsWith("/oauth/")||e==="/oauth"||M.has(e))return!0;let n=e.match(/^\/[^/]+(\/(?:verification|password)\/[^/]+)$/);if(n?.[1]&&M.has(n[1]))return!0;let r=e.match(/^(?:\/[^/]+)?\/auth\/([^/]+)/);if(r){let o=r[1];if(o&&mt.has(o)||o?.startsWith("oauth"))return!0}return!1}function et(t){return t.GATEWAY_JWT_PRIVATE_KEY||void 0}function rt(t){return t.TID_VALUE||void 0}function nt(t){let e=t.split("?")[0].replace(/\/$/,"");return e.endsWith("/auth/login")||e.endsWith("/auth/authenticate")}function Rt(){let t=Date.now(),e=new Uint8Array(16);e[0]=t/1099511627776&255,e[1]=t/4294967296&255,e[2]=t/16777216&255,e[3]=t/65536&255,e[4]=t/256&255,e[5]=t&255;let n=new Uint8Array(10);crypto.getRandomValues(n),e.set(n,6),e[6]=e[6]&15|112,e[8]=e[8]&63|128;let r="0123456789ABCDEFGHJKMNPQRSTVWXYZ",o="",i=0,s=0;for(let c=0;c<e.length;c++)for(s=s<<8|e[c],i+=8;i>=5;)o+=r[s>>>i-5&31],i-=5;return i>0&&(o+=r[s<<5-i&31]),o}var T={BAD_REQUEST:E("BAD_REQUEST","Bad request"),UNAUTHORIZED:E("UNAUTHORIZED","Authentication required"),FORBIDDEN:E("FORBIDDEN","Access denied"),NOT_FOUND:E("NOT_FOUND","Resource not found"),RATE_LIMIT_EXCEEDED:E("RATE_LIMIT_EXCEEDED","Too many requests"),INTERNAL_ERROR:E("INTERNAL_ERROR","Internal server error"),BAD_GATEWAY:E("BAD_GATEWAY","Gateway error"),SERVICE_UNAVAILABLE:E("SERVICE_UNAVAILABLE","Service temporarily unavailable"),GATEWAY_TIMEOUT:E("GATEWAY_TIMEOUT","Gateway timeout")},d={INVALID_FORMAT:E("JWT_INVALID_FORMAT","Invalid JWT format"),UNSUPPORTED_ALGORITHM:E("JWT_UNSUPPORTED_ALGORITHM","Unsupported JWT algorithm"),INVALID_TOKEN_TYPE:E("JWT_INVALID_TOKEN_TYPE","Invalid token type: expected access token"),TOKEN_EXPIRED:E("JWT_TOKEN_EXPIRED","JWT token expired"),SIGNATURE_INVALID:E("JWT_SIGNATURE_INVALID","JWT signature verification failed"),MISSING_TOKEN:E("JWT_MISSING_TOKEN","Missing access token"),INVALID_ISSUER:E("JWT_INVALID_ISSUER","Invalid JWT issuer")},ot={BAD_REQUEST:400,UNAUTHORIZED:401,FORBIDDEN:403,NOT_FOUND:404,RATE_LIMIT_EXCEEDED:429,INTERNAL_ERROR:500,BAD_GATEWAY:502,SERVICE_UNAVAILABLE:503,GATEWAY_TIMEOUT:504,JWT_INVALID_FORMAT:401,JWT_UNSUPPORTED_ALGORITHM:401,JWT_INVALID_TOKEN_TYPE:401,JWT_TOKEN_EXPIRED:401,JWT_SIGNATURE_INVALID:401,JWT_MISSING_TOKEN:401,JWT_INVALID_ISSUER:401};function St(t){if(t.ok)return t.data===void 0?204:200;let e=t.errors?.[0];return e?.code&&e.code in ot?ot[e.code]:500}function f(t,e){let n=new Date().toISOString(),r=e||Rt(),o={...t.meta,timestamp:n,traceId:r},i=t.ok?m(t.data,o):a(t.errors,o),s=St(i),c=N(i);return new Response(JSON.stringify(c),{status:s,headers:{"Content-Type":"application/json"}})}var wt=new Set(["EdDSA","ES256","RS256","PS256"]);function _t(t){return wt.has(t)}function yt(t){switch(t){case"EdDSA":return{name:"Ed25519"};case"ES256":return{name:"ECDSA",namedCurve:"P-256"};case"RS256":return{name:"RSASSA-PKCS1-v1_5",hash:"SHA-256"};case"PS256":return{name:"RSA-PSS",hash:"SHA-256"}}}function It(t){switch(t){case"EdDSA":return"Ed25519";case"ES256":return{name:"ECDSA",hash:"SHA-256"};case"RS256":return{name:"RSASSA-PKCS1-v1_5"};case"PS256":return{name:"RSA-PSS",saltLength:32}}}function x(t){let e=t.replace(/-/g,"+").replace(/_/g,"/"),n=e+"==".slice(0,(4-e.length%4)%4),r=atob(n),o=new Uint8Array(r.length);for(let i=0;i<r.length;i++)o[i]=r.charCodeAt(i);return o}function it(t){return atob(t)}var Ot=/^[0-9a-fA-F]{64}$/;async function Dt(t,e){if(Ot.test(t)){if(e!=="EdDSA")throw new Error(`Hex key format is only supported for EdDSA, got ${e}`);let r=new Uint8Array(32);for(let o=0;o<32;o++)r[o]=parseInt(t.slice(o*2,o*2+2),16);return crypto.subtle.importKey("raw",r,{name:"Ed25519"},!1,["verify"])}let n;try{n=JSON.parse(it(t))}catch{throw new Error(`Invalid JWK base64 for algorithm ${e}`)}return await crypto.subtle.importKey("jwk",n,yt(e),!1,["verify"])}function st(t){let e=t.split(".");if(e.length!==3)return null;let n=e[1];if(!n)return null;try{let r=x(n);return JSON.parse(new TextDecoder().decode(r))}catch{return null}}function Pt(t){let n=t.split(".")[0];if(!n)return a(d.INVALID_FORMAT());try{let r=x(n);return m(JSON.parse(new TextDecoder().decode(r)))}catch{return a(d.INVALID_FORMAT())}}function Ct(t){let n=t.split(".")[1];if(!n)return a(d.INVALID_FORMAT());try{let r=x(n);return m(JSON.parse(new TextDecoder().decode(r)))}catch{return a(d.INVALID_FORMAT())}}async function xt(t,e,n){let r=t.split("."),o=r[0],i=r[1],s=r[2];if(!o||!i||!s)return!1;let c=new TextEncoder().encode(`${o}.${i}`),u=x(s);try{return await crypto.subtle.verify(It(n),e,u,c)}catch{return!1}}async function b(t,e,n=1,r=new Set(["EdDSA"]),o){let i=Pt(t);if(!i.ok)return a(i.errors,i.meta);let s=i.data.alg;if(!r.has(s))return a(d.UNSUPPORTED_ALGORITHM());if(!_t(s))return a(d.UNSUPPORTED_ALGORITHM());let c=Ct(t);if(!c.ok)return a(c.errors,c.meta);let u=c.data;if(u.typ!==n)return a(d.INVALID_TOKEN_TYPE());if(o!==void 0&&u.iss!==o)return a(d.INVALID_ISSUER());if(u.exp&&Math.floor(Date.now()/1e3)>=u.exp)return a(d.TOKEN_EXPIRED());let l;try{l=await Dt(e,s)}catch{return a(d.INVALID_FORMAT())}return await xt(t,l,s)?m(u):a(d.SIGNATURE_INVALID())}var J=null;async function Nt(t){let e;try{e=JSON.parse(it(t))}catch{throw new Error("Invalid Gateway JWT private key: expected base64-encoded JWK")}return await crypto.subtle.importKey("jwk",e,{name:"Ed25519"},!1,["sign"])}function k(t){let e="";for(let n=0;n<t.length;n++)e+=String.fromCharCode(t[n]);return btoa(e).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}async function at(t,e,n,r,o=30){J||(J=Nt(t));let i=await J,s=Math.floor(Date.now()/1e3),c={alg:"EdDSA",typ:"JWT"},u={iss:e,aud:n,sub:r,iat:s,exp:s+o,jti:crypto.randomUUID()},l=k(new TextEncoder().encode(JSON.stringify(c))),A=k(new TextEncoder().encode(JSON.stringify(u))),R=new TextEncoder().encode(`${l}.${A}`),h=await crypto.subtle.sign("Ed25519",i,R),w=k(new Uint8Array(h));return`${l}.${A}.${w}`}var Lt=["content-type","accept","accept-encoding","accept-language","user-agent","cookie"];function Ut(t){return t.getSetCookie?.()??[]}async function Wt(t,e){if(!(t.headers.get("content-type")||"").includes("application/json"))return null;let r;try{r=await t.clone().json()}catch{return null}return delete r.tid,r.tid=e,{body:JSON.stringify(r),contentType:"application/json"}}function Jt(t,e,n,r,o,i){let s=new Headers;for(let R of Lt){let h=t.headers.get(R);h&&s.set(R,h)}o&&s.set("content-type",o);let c=t.headers.get("Authorization");c&&s.set("Authorization",c),r&&s.set("X-Auth-Context",r),s.set("X-Gateway-Secret",e),i&&s.set("x-gateway-auth",`Bearer ${i}`),s.set("X-Request-Id",n);let u=t.headers.get("cf-connecting-ip");u&&(s.set("X-Forwarded-For",u),s.set("X-Real-IP",u)),s.set("X-Forwarded-Proto","https");let l=new URL(t.url);s.set("X-Forwarded-Host",l.hostname);let A=t.headers.get("cf-ipcountry");return A&&s.set("X-Cloudflare-Country",A),s}function kt(t,e,n){let r=new URL(t.url),o=e.BACKEND_URL.endsWith("/")?e.BACKEND_URL.slice(0,-1):e.BACKEND_URL;S(e)&&console.log("[Gateway] Path transform:",JSON.stringify({original:r.pathname,forwarded:n}));let i=new URL(`${o}${n}`);return i.search=r.search,i.toString()}function bt(t,e,n){let r=new Headers(t.headers);r.set("X-Request-Id",e);let o=Ut(t.headers);if(o.length>0){r.delete("Set-Cookie");for(let i of o)r.append("Set-Cookie",i);S(n)&&console.log("[Gateway] Forwarding Set-Cookie:",o.length,"cookie(s)")}return new Response(t.body,{status:t.status,statusText:t.statusText,headers:r})}async function G(t,e){let n=crypto.randomUUID();try{let r=new URL(t.url),o=C(r.pathname,e),{scope:i,forwardPath:s}=F(e,o),c=kt(t,e,s),u,l,A=rt(e);if(A&&nt(s)&&t.method==="POST"){let I=await Wt(t,A);I&&(u=I.body,l=I.contentType,S(e)&&console.log("[Gateway] Injected tid:",A,"into",s))}let R,h=et(e);h&&(R=await at(h,"bff-worker","auth-craft:backend","cloudflare-auth-gateway"));let w=Jt(t,e.BACKEND_GATEWAY_SECRET,n,i,l,R),V=u!==void 0?u:t.body?await t.clone().arrayBuffer():void 0,_=await fetch(c,{method:t.method,headers:w,body:V});return bt(_,n,e)}catch(r){console.error("[Gateway] Proxy error:",r);let o=r instanceof Error?r.message:"Unknown error";return f(a(E("BAD_GATEWAY",`Failed to reach backend: ${o}`)()))}}var D=new Map,ct=0,Gt=[{match:t=>t==="/auth/authenticate",limit:15,windowMs:6e4,prefix:"login"},{match:t=>t==="/auth/register",limit:8,windowMs:6e4,prefix:"register"},{match:t=>t==="/password/forgot",limit:8,windowMs:3e5,prefix:"forgot"},{match:t=>t.startsWith("/oauth/"),limit:30,windowMs:6e4,prefix:"oauth"},{match:t=>t.startsWith("/auth/complete-"),limit:30,windowMs:6e4,prefix:"complete"},{match:t=>t==="/auth/issue-mfa-challenge",limit:15,windowMs:6e4,prefix:"issue-mfa"}];function Kt(t){let e=Date.now();if(e-ct<3e4)return;ct=e;let n=e-t;for(let[r,o]of D){let i=o.hits.filter(s=>s>n);i.length===0?D.delete(r):o.hits=i}}function ut(t,e,n){if(n!=="POST")return{allowed:!0,retryAfterSec:0};let r=Gt.find(l=>l.match(e));if(!r)return{allowed:!0,retryAfterSec:0};Kt(3e5);let o=`${r.prefix}:${t}`,i=Date.now(),s=i-r.windowMs,c=D.get(o)??{hits:[]},u=c.hits.filter(l=>l>s);if(u.length>=r.limit){let l=u[0]??i;return c.hits=u,D.set(o,c),{allowed:!1,retryAfterSec:Math.max(1,Math.ceil((l+r.windowMs-i)/1e3))}}return u.push(i),c.hits=u,D.set(o,c),{allowed:!0,retryAfterSec:0}}function ft(t,e){if(t.length!==e.length)return!1;let n=0;for(let r=0;r<t.length;r++)n|=t.charCodeAt(r)^e.charCodeAt(r);return n===0}async function lt(t,e){return t&&await H(t,e)?{"Access-Control-Allow-Origin":t,"Access-Control-Allow-Credentials":"true",Vary:"Origin"}:W(e).includes("*")?{"Access-Control-Allow-Origin":"*"}:{Vary:"Origin"}}async function Vt(t,e){let n=t.headers.get("Origin")??"*",r=t.headers.get("Access-Control-Request-Headers")??"";return new Response(null,{status:204,headers:{...await lt(n,e),"Access-Control-Allow-Methods":"GET, POST, PUT, DELETE, OPTIONS, PATCH","Access-Control-Allow-Headers":r||"Content-Type, Authorization, X-Requested-With, X-Edge-Gate","Access-Control-Max-Age":"86400"}})}async function p(t,e,n){let r=e.headers.get("Origin")??"*",o=new Response(t.body,t),i=t.headers.getSetCookie?.()??[];if(i.length>0){o.headers.delete("Set-Cookie");for(let c of i)o.headers.append("Set-Cookie",c)}let s=await lt(r,n);for(let[c,u]of Object.entries(s))o.headers.set(c,u);return o.headers.set("Access-Control-Expose-Headers","Content-Length, X-Request-Id, Set-Cookie"),o}var Bt="/internal/cors-origins";function Mt(t){return t.split("?")[0].replace(/\/$/,"")===Bt}async function vt(t,e){if(!e.CORS_KV)return K();if(t.method==="GET"){let r=(await e.CORS_KV.list({prefix:"origin:"})).keys.map(o=>o.name.slice(7));return f(m({origins:r}))}if(t.method==="PUT"||t.method==="DELETE"){let n;try{n=(await t.json()).origin}catch{return f(a(T.BAD_REQUEST()))}if(typeof n!="string"||!n)return f(a(T.BAD_REQUEST()));let r;try{if(r=new URL(n).origin,r==="null"||r!==n.replace(/\/$/,""))return f(a(T.BAD_REQUEST()))}catch{return f(a(T.BAD_REQUEST()))}return t.method==="PUT"?await e.CORS_KV.put(P(r),"1"):await e.CORS_KV.delete(P(r)),f(m({origin:r}))}return f(a(T.BAD_REQUEST()))}function K(){return new Response(null,{status:404})}var Pe={async fetch(t,e,n){try{v(e);let r=new URL(t.url);if(!X(r.pathname,e))return K();if(t.method==="OPTIONS")return await Vt(t,e);if(!U(e)){let g=t.headers.get("X-Edge-Gate");if(!g||!e.EDGE_GATE_SECRET||!ft(g,e.EDGE_GATE_SECRET))return S(e)&&console.warn("[Gateway] X-Edge-Gate missing or invalid"),K()}let o=O(e),i=o?`${o}/health`:"/health";if(r.pathname===i)return await p(f(m({status:"healthy",timestamp:new Date().toISOString()})),t,e);let s=C(r.pathname,e);S(e)&&console.log("[Gateway] Request:",t.method,r.pathname,"\u2192",s);let c=t.headers.get("cf-connecting-ip")??"unknown",u=ut(c,s,t.method);if(!u.allowed){let g=f(a(T.RATE_LIMIT_EXCEEDED()));return g.headers.set("Retry-After",String(u.retryAfterSec)),await p(g,t,e)}let l=q(s,e);if(!l&&tt(s))return await p(await G(t,e),t,e);let A=t.headers.get("Authorization");if(!A)return await p(f(a(d.MISSING_TOKEN())),t,e);let R=A.match(/^Bearer\s+(.+)$/i);if(!R)return await p(f(a(d.INVALID_FORMAT())),t,e);let h=R[1];if(h.length>4096)return await p(f(a(d.INVALID_FORMAT())),t,e);let w=h.split(".");if(w.length!==3)return await p(f(a(d.INVALID_FORMAT())),t,e);if(w[1].length>2048)return await p(f(a(d.INVALID_FORMAT())),t,e);let _=st(h);if(!_)return await p(f(a(d.INVALID_FORMAT())),t,e);let I=_.typ,dt=l?3:1;if(I!==dt)return await p(f(a(d.INVALID_TOKEN_TYPE())),t,e);if(l&&_.aud!==z(e))return await p(f(a(d.INVALID_TOKEN_TYPE())),t,e);let B=_.exp;if(typeof B=="number"&&Math.floor(Date.now()/1e3)>=B)return await p(f(a(d.TOKEN_EXPIRED())),t,e);if(l){if(!e.SERVICE_JWT_PUBLIC_KEY)return await p(f(a(T.INTERNAL_ERROR())),t,e);let g=await b(h,e.SERVICE_JWT_PUBLIC_KEY,3,j(e),Z(e));if(!g.ok)return S(e)&&console.error("[Gateway] Service JWT failed:",g.errors),await p(f(a(g.errors,g.meta)),t,e)}else{let g=await b(h,e.JWT_PUBLIC_KEY,1,Y(e),Q(e));if(!g.ok)return S(e)&&console.error("[Gateway] User JWT failed:",g.errors),await p(f(a(g.errors,g.meta)),t,e)}return l&&Mt(s)?await p(await vt(t,e),t,e):await p(await G(t,e),t,e)}catch(r){return S(e)&&console.error("[Gateway] Unhandled error:",r),f(a(T.INTERNAL_ERROR()))}}};export{Pe as default};
|
package/lib/common.sh
CHANGED
|
@@ -177,16 +177,32 @@ lambda_env() {
|
|
|
177
177
|
--query "Environment.Variables.${key}" --output text 2>/dev/null | sed 's/^None$//' || true
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
# Resolve the gateway deploy mode: 'multi' (3 workers, one scope each — default) or
|
|
181
|
+
# 'shared' (1 worker that derives scope from the path's first segment). Accepts
|
|
182
|
+
# CF_GATEWAY_MODE = multi | shared (case-insensitive); anything else → multi.
|
|
183
|
+
gateway_mode() {
|
|
184
|
+
case "$(echo "${CF_GATEWAY_MODE:-multi}" | tr '[:upper:]' '[:lower:]')" in
|
|
185
|
+
shared | single | one) echo shared ;;
|
|
186
|
+
*) echo multi ;;
|
|
187
|
+
esac
|
|
188
|
+
}
|
|
189
|
+
|
|
180
190
|
# Derive a gateway worker URL for a scope (custom domain > workers.dev > empty).
|
|
191
|
+
# In shared mode all scopes resolve to the single worker ${APP_NAME}-${STAGE}.
|
|
181
192
|
gateway_url_for() {
|
|
182
|
-
local scope="$1" upper dom_var custom
|
|
193
|
+
local scope="$1" upper dom_var custom worker_name
|
|
183
194
|
upper="$(echo "$scope" | tr '[:lower:]' '[:upper:]')"
|
|
184
195
|
dom_var="CF_WORKER_CUSTOM_DOMAIN_${upper}"
|
|
185
196
|
custom="${!dom_var:-}"
|
|
197
|
+
if [[ "$(gateway_mode)" == "shared" ]]; then
|
|
198
|
+
worker_name="${APP_NAME}-${STAGE}"
|
|
199
|
+
else
|
|
200
|
+
worker_name="${APP_NAME}-${STAGE}-${scope}"
|
|
201
|
+
fi
|
|
186
202
|
if [[ -n "$custom" ]]; then
|
|
187
203
|
echo "https://$custom"
|
|
188
204
|
elif [[ -n "${CF_WORKERS_SUBDOMAIN:-}" ]]; then
|
|
189
|
-
echo "https://${
|
|
205
|
+
echo "https://${worker_name}.${CF_WORKERS_SUBDOMAIN}.workers.dev"
|
|
190
206
|
else
|
|
191
207
|
echo ""
|
|
192
208
|
fi
|
|
@@ -227,16 +243,25 @@ print_outputs_json() {
|
|
|
227
243
|
jwtAlg="$(lambda_env "$fn" JWT_ALGORITHM)"; jwtAlg="${jwtAlg:-${LAMBDA_JWT_ALGORITHM:-EdDSA}}"
|
|
228
244
|
svcPub="$(lambda_env "$fn" INTERNAL_JWT_PUBLIC_KEY)"; svcPub="${svcPub:-${LAMBDA_INTERNAL_JWT_PUBLIC_KEY:-}}"
|
|
229
245
|
|
|
230
|
-
# Worker names per scope (
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
|
|
236
|
-
local wnTenant
|
|
237
|
-
|
|
246
|
+
# Worker names per scope. In multi mode (default) each scope is its own worker
|
|
247
|
+
# (${APP_NAME}-${STAGE}-${scope}); in shared mode all scopes are the single worker
|
|
248
|
+
# ${APP_NAME}-${STAGE}. These are what a consumer's Cloudflare Pages project references
|
|
249
|
+
# in a Service Binding (`[[services]] service = "<worker name>"`) to front auth
|
|
250
|
+
# same-origin via @auth-craft/pages-auth-proxy. The binding lives in the consumer's
|
|
251
|
+
# Pages project, not here — `serviceBindings` is a ready-to-paste snippet.
|
|
252
|
+
local mode wnSystem wnTenant wnCustomer bindingService
|
|
253
|
+
mode="$(gateway_mode)"
|
|
254
|
+
if [[ "$mode" == "shared" ]]; then
|
|
255
|
+
wnSystem="${APP_NAME}-${STAGE}"; wnTenant="$wnSystem"; wnCustomer="$wnSystem"
|
|
256
|
+
bindingService="$wnSystem"
|
|
257
|
+
else
|
|
258
|
+
wnSystem="${APP_NAME}-${STAGE}-system"
|
|
259
|
+
wnTenant="${APP_NAME}-${STAGE}-tenant"
|
|
260
|
+
wnCustomer="${APP_NAME}-${STAGE}-customer"
|
|
261
|
+
bindingService="$wnCustomer"
|
|
262
|
+
fi
|
|
238
263
|
local bindingSnippet
|
|
239
|
-
bindingSnippet=$(printf '[[services]]\nbinding = "AUTH_GATEWAY"\nservice = "%s"\n' "$
|
|
264
|
+
bindingSnippet=$(printf '[[services]]\nbinding = "AUTH_GATEWAY"\nservice = "%s"\n' "$bindingService")
|
|
240
265
|
|
|
241
266
|
jq -n \
|
|
242
267
|
--arg url "$url" --arg base "$base" --arg name "$name" \
|
package/lib/deploy-gateway.sh
CHANGED
|
@@ -45,6 +45,22 @@ disable_public_url_for() {
|
|
|
45
45
|
esac
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
# Resolve whether to skip the X-Edge-Gate (Client → Worker) check for a scope. Safe ONLY
|
|
49
|
+
# when the worker has no public URL and is reached solely via a Service Binding — so this
|
|
50
|
+
# defaults to following disable_public_url and the caller additionally guards on it.
|
|
51
|
+
# Precedence: CF_WORKER_SKIP_EDGE_GATE_<SCOPE> > CF_WORKER_SKIP_EDGE_GATE > '' (off).
|
|
52
|
+
# Truthy: 1 / true / yes. Does NOT affect the Worker → Backend secret (X-Gateway-Secret).
|
|
53
|
+
skip_edge_gate_for() {
|
|
54
|
+
local scope="$1" upper var val
|
|
55
|
+
upper="$(echo "$scope" | tr '[:lower:]' '[:upper:]')"
|
|
56
|
+
var="CF_WORKER_SKIP_EDGE_GATE_${upper}"
|
|
57
|
+
val="${!var:-${CF_WORKER_SKIP_EDGE_GATE:-}}"
|
|
58
|
+
case "$(echo "$val" | tr '[:upper:]' '[:lower:]')" in
|
|
59
|
+
1 | true | yes) echo 1 ;;
|
|
60
|
+
*) echo '' ;;
|
|
61
|
+
esac
|
|
62
|
+
}
|
|
63
|
+
|
|
48
64
|
# Resolve per-scope static CORS origins. Precedence:
|
|
49
65
|
# CF_ALLOWED_ORIGINS_<SCOPE> > CF_ALLOWED_ORIGINS > LAMBDA_CORS_ORIGINS > '*'.
|
|
50
66
|
allowed_origins_for() {
|
|
@@ -90,8 +106,161 @@ enable_workers_dev() {
|
|
|
90
106
|
fi
|
|
91
107
|
}
|
|
92
108
|
|
|
109
|
+
# Merge per-scope CORS values for the shared worker (one worker serves all scopes).
|
|
110
|
+
# Unions the comma lists across system/tenant/customer, de-duped, '*' wins (collapses
|
|
111
|
+
# to '*' since it already allows everything).
|
|
112
|
+
merge_cors_for_shared() {
|
|
113
|
+
local resolver="$1" scope merged piece
|
|
114
|
+
merged=""
|
|
115
|
+
for scope in system tenant customer; do
|
|
116
|
+
piece="$("$resolver" "$scope")"
|
|
117
|
+
[[ -z "$piece" ]] && continue
|
|
118
|
+
merged="${merged:+$merged,}$piece"
|
|
119
|
+
done
|
|
120
|
+
# Normalize: split on comma, trim, drop blanks/dups; if any '*' present → just '*'.
|
|
121
|
+
echo "$merged" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | awk '
|
|
122
|
+
/^\*$/ { star = 1 }
|
|
123
|
+
NF && !seen[$0]++ { order[++n] = $0 }
|
|
124
|
+
END {
|
|
125
|
+
if (star) { print "*"; exit }
|
|
126
|
+
for (i = 1; i <= n; i++) printf "%s%s", (i > 1 ? "," : ""), order[i]
|
|
127
|
+
print ""
|
|
128
|
+
}'
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Deploy a single gateway worker. All inputs are passed explicitly so the same code path
|
|
132
|
+
# serves both the multi-worker loop and the shared-worker mode. The shared config
|
|
133
|
+
# (backend_url, environment, jwt/svc values) is read from deploy_gateway's locals via the
|
|
134
|
+
# caller's scope — so this stays a nested helper invoked only from deploy_gateway.
|
|
135
|
+
#
|
|
136
|
+
# Args: 1=worker_name 2=label 3=auth_scope('' = path-derived) 4=route_prefix
|
|
137
|
+
# 5=custom_domain 6=allowed_origins 7=origin_suffixes 8=cors_kv_id
|
|
138
|
+
# 9=disable_public_url 10=skip_edge_gate
|
|
139
|
+
deploy_one_worker() {
|
|
140
|
+
local worker_name="$1" label="$2" auth_scope="$3" route_prefix="$4" custom_domain="$5"
|
|
141
|
+
local allowed_origins="$6" origin_suffixes="$7" cors_kv_id="$8"
|
|
142
|
+
local disable_public_url="$9" skip_edge_gate="${10}"
|
|
143
|
+
local config_file url
|
|
144
|
+
|
|
145
|
+
info "Deploying $worker_name (${label}, route_prefix='${route_prefix}')…"
|
|
146
|
+
|
|
147
|
+
# A temp config is needed when either (a) a CORS KV id is set (a binding can't be
|
|
148
|
+
# set via --var) or (b) the public URL must be disabled (workers_dev/preview_urls are
|
|
149
|
+
# top-level config keys, not vars, and CF won't disable preview URLs just because
|
|
150
|
+
# workers.dev is off — both must be set explicitly). Otherwise use the shipped config.
|
|
151
|
+
config_file="$WRANGLER_DIR/wrangler.toml"
|
|
152
|
+
if [[ -n "$cors_kv_id" || -n "$disable_public_url" ]]; then
|
|
153
|
+
config_file="$(mktemp "${TMPDIR:-/tmp}/wrangler-${label}.XXXXXX.toml")"
|
|
154
|
+
if [[ -n "$disable_public_url" ]]; then
|
|
155
|
+
# Turn off the workers.dev URL *and* the per-version preview URLs in a single
|
|
156
|
+
# deploy (no REST API round-trip; CF won't drop preview URLs just for workers_dev
|
|
157
|
+
# being off, so both are set explicitly). These are ROOT keys: they must precede
|
|
158
|
+
# any [table] header or TOML scopes them under it (e.g. [build]). We drop the
|
|
159
|
+
# shipped `workers_dev = true` (TOML forbids duplicate keys) and inject both flags
|
|
160
|
+
# immediately after the first root key (`name = …`), before [build].
|
|
161
|
+
awk '
|
|
162
|
+
/^[[:space:]]*workers_dev[[:space:]]*=/ { next } # drop original (anywhere)
|
|
163
|
+
{ print }
|
|
164
|
+
!injected && /^[[:space:]]*name[[:space:]]*=/ {
|
|
165
|
+
print "workers_dev = false"; print "preview_urls = false"; injected = 1
|
|
166
|
+
}
|
|
167
|
+
' "$WRANGLER_DIR/wrangler.toml" >"$config_file"
|
|
168
|
+
info " public URL disabled: workers.dev + preview URLs off (Pages-proxy / Service Binding only)"
|
|
169
|
+
else
|
|
170
|
+
cat "$WRANGLER_DIR/wrangler.toml" >"$config_file"
|
|
171
|
+
fi
|
|
172
|
+
if [[ -n "$cors_kv_id" ]]; then
|
|
173
|
+
{
|
|
174
|
+
echo ''
|
|
175
|
+
echo '[[kv_namespaces]]'
|
|
176
|
+
echo 'binding = "CORS_KV"'
|
|
177
|
+
echo "id = \"$cors_kv_id\""
|
|
178
|
+
} >>"$config_file"
|
|
179
|
+
info " dynamic CORS: KV binding CORS_KV → $cors_kv_id"
|
|
180
|
+
fi
|
|
181
|
+
fi
|
|
182
|
+
|
|
183
|
+
# Per-worker vars (flags override the toml). AUTH_SCOPE is set only in multi mode; in
|
|
184
|
+
# shared mode it's omitted so the worker derives scope from the path's first segment.
|
|
185
|
+
local -a deploy_vars=(
|
|
186
|
+
--var "ENVIRONMENT:$environment"
|
|
187
|
+
--var "ROUTE_PREFIX:$route_prefix"
|
|
188
|
+
--var "ALLOWED_ORIGINS:$allowed_origins"
|
|
189
|
+
--var "ALLOWED_ORIGIN_SUFFIXES:$origin_suffixes"
|
|
190
|
+
--var "JWT_ISSUER:$jwt_issuer"
|
|
191
|
+
--var "JWT_ALLOWED_ALGORITHMS:$jwt_alg"
|
|
192
|
+
--var "SERVICE_ROUTE_PREFIX:$svc_route"
|
|
193
|
+
--var "SERVICE_JWT_AUDIENCE:$svc_aud"
|
|
194
|
+
--var "SERVICE_JWT_ISSUER:$svc_iss"
|
|
195
|
+
--var "SERVICE_JWT_ALLOWED_ALGORITHMS:$svc_alg"
|
|
196
|
+
)
|
|
197
|
+
[[ -n "$auth_scope" ]] && deploy_vars+=(--var "AUTH_SCOPE:$auth_scope")
|
|
198
|
+
if [[ -n "$skip_edge_gate" ]]; then
|
|
199
|
+
deploy_vars+=(--var "SKIP_EDGE_GATE:true")
|
|
200
|
+
info " edge gate SKIPPED (binding-only worker; X-Edge-Gate not required)"
|
|
201
|
+
fi
|
|
202
|
+
|
|
203
|
+
# Deploy the prebuilt bundle.
|
|
204
|
+
wrangler deploy \
|
|
205
|
+
--name "$worker_name" \
|
|
206
|
+
--config "$config_file" \
|
|
207
|
+
--no-bundle \
|
|
208
|
+
"${deploy_vars[@]}" >/dev/null
|
|
209
|
+
|
|
210
|
+
# Clean up the temp config (only created when KV was bound or public URL disabled).
|
|
211
|
+
[[ "$config_file" != "$WRANGLER_DIR/wrangler.toml" ]] && rm -f "$config_file"
|
|
212
|
+
|
|
213
|
+
# Secrets (piped via stdin).
|
|
214
|
+
put_secret "$worker_name" BACKEND_URL "$backend_url"
|
|
215
|
+
put_secret "$worker_name" BACKEND_GATEWAY_SECRET "${LAMBDA_GATEWAY_SECRET:-}"
|
|
216
|
+
# Edge-gate secret only when the gate is enforced (skipped workers don't need it).
|
|
217
|
+
[[ -z "$skip_edge_gate" ]] && put_secret "$worker_name" EDGE_GATE_SECRET "${CF_EDGE_GATE_SECRET:-}"
|
|
218
|
+
put_secret "$worker_name" JWT_PUBLIC_KEY "${LAMBDA_JWT_PUBLIC_KEY:-}"
|
|
219
|
+
put_secret "$worker_name" SERVICE_JWT_PUBLIC_KEY "${LAMBDA_INTERNAL_JWT_PUBLIC_KEY:-}"
|
|
220
|
+
put_secret "$worker_name" GATEWAY_JWT_PRIVATE_KEY "${CF_GATEWAY_JWT_PRIVATE_KEY:-}"
|
|
221
|
+
|
|
222
|
+
if [[ -n "$custom_domain" ]]; then
|
|
223
|
+
url="https://$custom_domain"
|
|
224
|
+
info " custom domain: $custom_domain (configure DNS/route separately)"
|
|
225
|
+
elif [[ -n "$disable_public_url" ]]; then
|
|
226
|
+
# workers.dev + preview URLs were turned off in the deploy config above; the worker
|
|
227
|
+
# is reachable only via its Service Binding (Pages proxy) — no public URL to print.
|
|
228
|
+
url="(no public URL — reached via Pages proxy / Service Binding)"
|
|
229
|
+
else
|
|
230
|
+
enable_workers_dev "$worker_name"
|
|
231
|
+
if [[ -n "${CF_WORKERS_SUBDOMAIN:-}" ]]; then
|
|
232
|
+
url="https://${worker_name}.${CF_WORKERS_SUBDOMAIN}.workers.dev"
|
|
233
|
+
else
|
|
234
|
+
url="(workers.dev — set CF_WORKERS_SUBDOMAIN to print the URL)"
|
|
235
|
+
fi
|
|
236
|
+
fi
|
|
237
|
+
success " $label gateway: $url"
|
|
238
|
+
DEPLOYED_WORKER_URL="$url"
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
# Resolve the disable-public-url / skip-edge-gate flags for a worker, applying the safety
|
|
242
|
+
# rule that skip is only honored when the public URL is disabled. Sets the globals
|
|
243
|
+
# RESOLVED_DISABLE_PUBLIC_URL and RESOLVED_SKIP_EDGE_GATE. `key` is the resolver scope key
|
|
244
|
+
# ('system'|'tenant'|'customer', or 'shared' for the merged worker); `has_custom_domain`
|
|
245
|
+
# is non-empty when a custom domain forces the worker to stay reachable.
|
|
246
|
+
resolve_url_flags() {
|
|
247
|
+
local key="$1" has_custom_domain="$2"
|
|
248
|
+
RESOLVED_DISABLE_PUBLIC_URL=""
|
|
249
|
+
[[ -z "$has_custom_domain" ]] && RESOLVED_DISABLE_PUBLIC_URL="$(disable_public_url_for "$key")"
|
|
250
|
+
RESOLVED_SKIP_EDGE_GATE=""
|
|
251
|
+
if [[ -n "$(skip_edge_gate_for "$key")" ]]; then
|
|
252
|
+
if [[ -n "$RESOLVED_DISABLE_PUBLIC_URL" ]]; then
|
|
253
|
+
RESOLVED_SKIP_EDGE_GATE=1
|
|
254
|
+
else
|
|
255
|
+
warn " $key: SKIP_EDGE_GATE ignored — only allowed when the public URL is disabled"
|
|
256
|
+
fi
|
|
257
|
+
fi
|
|
258
|
+
}
|
|
259
|
+
|
|
93
260
|
deploy_gateway() {
|
|
94
|
-
|
|
261
|
+
local mode
|
|
262
|
+
mode="$(gateway_mode)"
|
|
263
|
+
header "Deploy Gateway Worker(s) (stage=$STAGE, mode=$mode)"
|
|
95
264
|
|
|
96
265
|
require_cmd npx
|
|
97
266
|
require_env CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID
|
|
@@ -100,13 +269,11 @@ deploy_gateway() {
|
|
|
100
269
|
|| die "Prebuilt gateway bundle missing: $GATEWAY_ASSET_DIR/worker.js (was the package built?)"
|
|
101
270
|
|
|
102
271
|
# Anti-drift: BACKEND_URL the worker forwards onto = Function URL + API base path.
|
|
272
|
+
# These shared values are read by deploy_one_worker (nested helper).
|
|
103
273
|
local backend_url="${LAMBDA_FUNCTION_URL%/}${API_BASE_PATH}"
|
|
104
|
-
|
|
105
|
-
# Shared config across the 3 workers (CORS is now resolved PER SCOPE below).
|
|
106
274
|
local environment jwt_issuer
|
|
107
275
|
environment="$RUNTIME_NODE_ENV"
|
|
108
276
|
jwt_issuer="${LAMBDA_JWT_ISSUER:-${AUTH_SYSTEM_NAME:-$RESOURCE_NAME}}"
|
|
109
|
-
|
|
110
277
|
local jwt_alg="${LAMBDA_JWT_ALGORITHM:-EdDSA}"
|
|
111
278
|
local svc_route="${LAMBDA_SERVICE_ROUTE_PATH:-/internal}"
|
|
112
279
|
local svc_aud="${LAMBDA_INTERNAL_JWT_AUDIENCE:-auth-craft:internal}"
|
|
@@ -114,8 +281,39 @@ deploy_gateway() {
|
|
|
114
281
|
local svc_alg="${LAMBDA_INTERNAL_JWT_ALG:-EdDSA}"
|
|
115
282
|
|
|
116
283
|
GATEWAY_URLS=()
|
|
117
|
-
|
|
118
|
-
|
|
284
|
+
|
|
285
|
+
if [[ "$mode" == "shared" ]]; then
|
|
286
|
+
# ── Shared worker: one worker serves all scopes; scope comes from the path's first
|
|
287
|
+
# segment (worker AUTH_SCOPE unset). CORS unions all scopes; KV uses the customer id.
|
|
288
|
+
local worker_name custom_domain allowed_origins origin_suffixes cors_kv_id
|
|
289
|
+
worker_name="${APP_NAME}-${STAGE}"
|
|
290
|
+
custom_domain="$(custom_domain_for shared)"
|
|
291
|
+
allowed_origins="$(merge_cors_for_shared allowed_origins_for)"
|
|
292
|
+
origin_suffixes="$(merge_cors_for_shared origin_suffixes_for)"
|
|
293
|
+
cors_kv_id="$(cors_kv_id_for shared)"
|
|
294
|
+
[[ -z "$cors_kv_id" ]] && cors_kv_id="$(cors_kv_id_for customer)"
|
|
295
|
+
|
|
296
|
+
resolve_url_flags shared "$custom_domain"
|
|
297
|
+
|
|
298
|
+
# ROUTE_PREFIX='' and AUTH_SCOPE='' → worker derives scope from /{scope}/... segment.
|
|
299
|
+
# NOTE: a shared worker does NOT enforce scope by itself (any client may send
|
|
300
|
+
# /system/...). Pair it with a scope-locked Pages proxy (@auth-craft/pages-auth-proxy
|
|
301
|
+
# `scope` option) if customer apps must never reach system/tenant context.
|
|
302
|
+
deploy_one_worker "$worker_name" "shared" "" "" "$custom_domain" \
|
|
303
|
+
"$allowed_origins" "$origin_suffixes" "$cors_kv_id" \
|
|
304
|
+
"$RESOLVED_DISABLE_PUBLIC_URL" "$RESOLVED_SKIP_EDGE_GATE"
|
|
305
|
+
GATEWAY_URLS+=("shared=$DEPLOYED_WORKER_URL")
|
|
306
|
+
|
|
307
|
+
export GATEWAY_URLS
|
|
308
|
+
success "Shared gateway worker deployed"
|
|
309
|
+
info " BACKEND_URL wired to: $backend_url"
|
|
310
|
+
warn " Shared mode does NOT enforce scope — use a scope-locked Pages proxy if needed."
|
|
311
|
+
return 0
|
|
312
|
+
fi
|
|
313
|
+
|
|
314
|
+
# ── Multi-worker: 3 workers, one scope each (AUTH_SCOPE fixed per worker = enforced).
|
|
315
|
+
local scope worker_name route_prefix custom_domain
|
|
316
|
+
local allowed_origins origin_suffixes cors_kv_id
|
|
119
317
|
for scope in system tenant customer; do
|
|
120
318
|
worker_name="${APP_NAME}-${STAGE}-${scope}"
|
|
121
319
|
route_prefix="$(route_prefix_for "$scope")"
|
|
@@ -123,93 +321,12 @@ deploy_gateway() {
|
|
|
123
321
|
allowed_origins="$(allowed_origins_for "$scope")"
|
|
124
322
|
origin_suffixes="$(origin_suffixes_for "$scope")"
|
|
125
323
|
cors_kv_id="$(cors_kv_id_for "$scope")"
|
|
126
|
-
|
|
127
|
-
disable_public_url=""
|
|
128
|
-
[[ -z "$custom_domain" ]] && disable_public_url="$(disable_public_url_for "$scope")"
|
|
129
|
-
|
|
130
|
-
info "Deploying $worker_name (scope=$scope route_prefix=$route_prefix)…"
|
|
131
|
-
|
|
132
|
-
# A temp config is needed when either (a) a CORS KV id is set (a binding can't be
|
|
133
|
-
# set via --var) or (b) the public URL must be disabled (workers_dev/preview_urls are
|
|
134
|
-
# top-level config keys, not vars, and CF won't disable preview URLs just because
|
|
135
|
-
# workers.dev is off — both must be set explicitly). Otherwise use the shipped config.
|
|
136
|
-
config_file="$WRANGLER_DIR/wrangler.toml"
|
|
137
|
-
if [[ -n "$cors_kv_id" || -n "$disable_public_url" ]]; then
|
|
138
|
-
config_file="$(mktemp "${TMPDIR:-/tmp}/wrangler-${scope}.XXXXXX.toml")"
|
|
139
|
-
if [[ -n "$disable_public_url" ]]; then
|
|
140
|
-
# Turn off the workers.dev URL *and* the per-version preview URLs in a single
|
|
141
|
-
# deploy (no REST API round-trip; CF won't drop preview URLs just for workers_dev
|
|
142
|
-
# being off, so both are set explicitly). These are ROOT keys: they must precede
|
|
143
|
-
# any [table] header or TOML scopes them under it (e.g. [build]). We drop the
|
|
144
|
-
# shipped `workers_dev = true` (TOML forbids duplicate keys) and inject both flags
|
|
145
|
-
# immediately after the first root key (`name = …`), before [build].
|
|
146
|
-
awk '
|
|
147
|
-
/^[[:space:]]*workers_dev[[:space:]]*=/ { next } # drop original (anywhere)
|
|
148
|
-
{ print }
|
|
149
|
-
!injected && /^[[:space:]]*name[[:space:]]*=/ {
|
|
150
|
-
print "workers_dev = false"; print "preview_urls = false"; injected = 1
|
|
151
|
-
}
|
|
152
|
-
' "$WRANGLER_DIR/wrangler.toml" >"$config_file"
|
|
153
|
-
info " public URL disabled: workers.dev + preview URLs off (Pages-proxy / Service Binding only)"
|
|
154
|
-
else
|
|
155
|
-
cat "$WRANGLER_DIR/wrangler.toml" >"$config_file"
|
|
156
|
-
fi
|
|
157
|
-
if [[ -n "$cors_kv_id" ]]; then
|
|
158
|
-
{
|
|
159
|
-
echo ''
|
|
160
|
-
echo '[[kv_namespaces]]'
|
|
161
|
-
echo 'binding = "CORS_KV"'
|
|
162
|
-
echo "id = \"$cors_kv_id\""
|
|
163
|
-
} >>"$config_file"
|
|
164
|
-
info " dynamic CORS: KV binding CORS_KV → $cors_kv_id"
|
|
165
|
-
fi
|
|
166
|
-
fi
|
|
324
|
+
resolve_url_flags "$scope" "$custom_domain"
|
|
167
325
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
--no-bundle \
|
|
173
|
-
--var "ENVIRONMENT:$environment" \
|
|
174
|
-
--var "AUTH_SCOPE:$scope" \
|
|
175
|
-
--var "ROUTE_PREFIX:$route_prefix" \
|
|
176
|
-
--var "ALLOWED_ORIGINS:$allowed_origins" \
|
|
177
|
-
--var "ALLOWED_ORIGIN_SUFFIXES:$origin_suffixes" \
|
|
178
|
-
--var "JWT_ISSUER:$jwt_issuer" \
|
|
179
|
-
--var "JWT_ALLOWED_ALGORITHMS:$jwt_alg" \
|
|
180
|
-
--var "SERVICE_ROUTE_PREFIX:$svc_route" \
|
|
181
|
-
--var "SERVICE_JWT_AUDIENCE:$svc_aud" \
|
|
182
|
-
--var "SERVICE_JWT_ISSUER:$svc_iss" \
|
|
183
|
-
--var "SERVICE_JWT_ALLOWED_ALGORITHMS:$svc_alg" >/dev/null
|
|
184
|
-
|
|
185
|
-
# Clean up the temp config (only created when KV was bound or public URL disabled).
|
|
186
|
-
[[ "$config_file" != "$WRANGLER_DIR/wrangler.toml" ]] && rm -f "$config_file"
|
|
187
|
-
|
|
188
|
-
# Secrets (piped via stdin).
|
|
189
|
-
put_secret "$worker_name" BACKEND_URL "$backend_url"
|
|
190
|
-
put_secret "$worker_name" BACKEND_GATEWAY_SECRET "${LAMBDA_GATEWAY_SECRET:-}"
|
|
191
|
-
put_secret "$worker_name" EDGE_GATE_SECRET "${CF_EDGE_GATE_SECRET:-}"
|
|
192
|
-
put_secret "$worker_name" JWT_PUBLIC_KEY "${LAMBDA_JWT_PUBLIC_KEY:-}"
|
|
193
|
-
put_secret "$worker_name" SERVICE_JWT_PUBLIC_KEY "${LAMBDA_INTERNAL_JWT_PUBLIC_KEY:-}"
|
|
194
|
-
put_secret "$worker_name" GATEWAY_JWT_PRIVATE_KEY "${CF_GATEWAY_JWT_PRIVATE_KEY:-}"
|
|
195
|
-
|
|
196
|
-
if [[ -n "$custom_domain" ]]; then
|
|
197
|
-
url="https://$custom_domain"
|
|
198
|
-
info " custom domain: $custom_domain (configure DNS/route separately)"
|
|
199
|
-
elif [[ -n "$disable_public_url" ]]; then
|
|
200
|
-
# workers.dev + preview URLs were turned off in the deploy config above; the worker
|
|
201
|
-
# is reachable only via its Service Binding (Pages proxy) — no public URL to print.
|
|
202
|
-
url="(no public URL — reached via Pages proxy / Service Binding)"
|
|
203
|
-
else
|
|
204
|
-
enable_workers_dev "$worker_name"
|
|
205
|
-
if [[ -n "${CF_WORKERS_SUBDOMAIN:-}" ]]; then
|
|
206
|
-
url="https://${worker_name}.${CF_WORKERS_SUBDOMAIN}.workers.dev"
|
|
207
|
-
else
|
|
208
|
-
url="(workers.dev — set CF_WORKERS_SUBDOMAIN to print the URL)"
|
|
209
|
-
fi
|
|
210
|
-
fi
|
|
211
|
-
success " $scope gateway: $url"
|
|
212
|
-
GATEWAY_URLS+=("$scope=$url")
|
|
326
|
+
deploy_one_worker "$worker_name" "$scope" "$scope" "$route_prefix" "$custom_domain" \
|
|
327
|
+
"$allowed_origins" "$origin_suffixes" "$cors_kv_id" \
|
|
328
|
+
"$RESOLVED_DISABLE_PUBLIC_URL" "$RESOLVED_SKIP_EDGE_GATE"
|
|
329
|
+
GATEWAY_URLS+=("$scope=$DEPLOYED_WORKER_URL")
|
|
213
330
|
done
|
|
214
331
|
export GATEWAY_URLS
|
|
215
332
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@auth-craft/aws-cf-stack",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Self-contained, versioned distribution of the Auth Craft AWS (DynamoDB + Lambda) + Cloudflare gateway stack. Bundles prebuilt Lambda/worker artifacts + CDK app so consumers deploy without cloning auth-craft.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|