workers-gate 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # workers-gate
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/workers-gate)](https://www.npmjs.com/package/workers-gate)
4
+ [![CI](https://github.com/sunya9/workers-gate/actions/workflows/ci.yml/badge.svg)](https://github.com/sunya9/workers-gate/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/workers-gate)](./LICENSE)
6
+
3
7
  A stateless authorization gate for apps on Cloudflare Workers. Bring your own provider; call the gate where you want it.
4
8
 
5
9
  Authorization, not authentication: it never asks or stores who the user is. No database, no KV, and a single dependency — cookie signing and verification are delegated to [jose](https://github.com/panva/jose). All that travels is a signed, expiring JWT cookie. Your filter says yes? You're in. Otherwise 403.
@@ -138,7 +142,7 @@ OIDC — implement `GateProvider` with openid-client, return the ID-token claims
138
142
 
139
143
  | Field | Default | Description |
140
144
  | ------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------- |
141
- | `cookieSecret` | required | HMAC key for session/state cookies |
145
+ | `cookieSecret` | required | HMAC key for session/state cookies, 32+ characters |
142
146
  | `provider` | required | Data procurement: `authorizeUrl` + `identify` |
143
147
  | `filter` | admit all identified | The authorization decision |
144
148
  | `signin` | redirect straight to the IdP | Custom sign-in screen for unauthenticated document requests; receives `loginUrl` |
@@ -146,7 +150,17 @@ OIDC — implement `GateProvider` with openid-client, return the ID-token claims
146
150
  | `unauthorized` | plain-text 401 | Custom Response for sessionless fetch/XHR; receives `loginUrl` |
147
151
  | `sessionTtlSeconds` | `86400` | Session cookie lifetime |
148
152
  | `loginPath` / `callbackPath` / `logoutPath` | `/auth/login` `/auth/callback` `/auth/logout` | Routes the gate claims |
149
- | `cookieName` / `stateCookieName` | `__gate` `__gate_state` | Cookie names |
153
+ | `cookieName` / `stateCookieName` | `__Host-gate` `__Host-gate_state` | Cookie names |
154
+ | `sessionVersion` | unset | Bump to send every outstanding session back through re-authorization |
155
+
156
+ ## Security notes
157
+
158
+ - Sessions are bound to the request origin via the JWT `aud` claim: a cookie minted on one hostname does not pass on another, and reusing a `cookieSecret` across apps no longer lets their sessions cross.
159
+ - `createGate` rejects a `cookieSecret` shorter than 32 characters.
160
+ - `sessionVersion` is the stateless revocation lever: bumping it invalidates every session at once, and holders are silently re-authorized — which re-runs `identify` and your `filter`.
161
+ - Exceptions from `identify`/`filter` and malformed token-endpoint responses fail closed to the denied response; the cause is logged via `console.error` (visible in `wrangler tail`).
162
+ - Cookies default to `__Host-` prefixed names for cookie-tossing resistance: no parent or sibling (sub)domain can plant or shadow them. Override with plain names only if you must; a non-prefixed `stateCookieName` also narrows the state cookie back to the callback path.
163
+ - Local dev note: all gate cookies carry `Secure`, which Safari drops on plain-HTTP localhost — use Chrome or Firefox with `wrangler dev` (both treat localhost as trustworthy).
150
164
 
151
165
  ## Screens are yours
152
166
 
@@ -196,6 +210,6 @@ If the silent pass fails (signed out of the IdP), the gate falls back to an inte
196
210
 
197
211
  ## Limitations
198
212
 
199
- - Stateless means no early revocation: a visitor who no longer satisfies your policy keeps access until their cookie expires (default 24h). Shorten `sessionTtlSeconds` if that matters.
213
+ - Stateless means no per-visitor revocation: someone who no longer satisfies your policy keeps access until their cookie expires (default 24h). Shorten `sessionTtlSeconds`, or bump `sessionVersion` to cut off everyone at once.
200
214
  - Scope is redirect-and-callback flows (OAuth/OIDC-style). Basic auth or IP allowlists are a different animal.
201
215
  - A `POST` issued with an expired session is redirected and loses its body — the follow-up navigation re-authorizes.
package/dist/cookie.d.ts CHANGED
@@ -4,5 +4,5 @@ export interface VerifiedToken {
4
4
  /** signature was valid but the exp claim has passed */
5
5
  expired: boolean;
6
6
  }
7
- export declare function sign(payload: JWTPayload, secret: string): Promise<string>;
8
- export declare function verify(token: string, secret: string): Promise<VerifiedToken | null>;
7
+ export declare function sign(payload: JWTPayload, secret: string, audience?: string): Promise<string>;
8
+ export declare function verify(token: string, secret: string, audience?: string): Promise<VerifiedToken | null>;
package/dist/cookie.js CHANGED
@@ -1,12 +1,16 @@
1
1
  import { errors, jwtVerify, SignJWT } from "jose";
2
2
  const encoder = new TextEncoder();
3
- export function sign(payload, secret) {
4
- return new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).sign(encoder.encode(secret));
3
+ export function sign(payload, secret, audience) {
4
+ const jwt = new SignJWT(payload).setProtectedHeader({ alg: "HS256" });
5
+ if (audience)
6
+ jwt.setAudience(audience);
7
+ return jwt.sign(encoder.encode(secret));
5
8
  }
6
- export async function verify(token, secret) {
9
+ export async function verify(token, secret, audience) {
7
10
  try {
8
11
  const { payload } = await jwtVerify(token, encoder.encode(secret), {
9
12
  algorithms: ["HS256"],
13
+ ...(audience ? { audience } : {}),
10
14
  });
11
15
  return { payload, expired: false };
12
16
  }
package/dist/gate.d.ts CHANGED
@@ -64,6 +64,12 @@ export interface GateConfig<Data extends {} = {}> {
64
64
  request: Request;
65
65
  loginUrl: string;
66
66
  }) => Response | Promise<Response>;
67
+ /**
68
+ * Stamped into every session; sessions carrying a different value are sent
69
+ * back through (silent) re-authorization. Bump it to invalidate all
70
+ * outstanding sessions at once — the stateless substitute for revocation.
71
+ */
72
+ sessionVersion?: string | number;
67
73
  loginPath?: string;
68
74
  callbackPath?: string;
69
75
  logoutPath?: string;
package/dist/gate.js CHANGED
@@ -30,10 +30,13 @@ function isStatePayload(value) {
30
30
  typeof value.exp === "number");
31
31
  }
32
32
  export function createGate(config) {
33
- const { cookieSecret, provider, filter, denied, unauthorized, signin, loginPath = "/auth/login", callbackPath = "/auth/callback", logoutPath = "/auth/logout", sessionTtlSeconds = 60 * 60 * 24, cookieName = "__gate", stateCookieName = "__gate_state", } = config;
34
- if (!cookieSecret) {
35
- throw new TypeError("createGate: cookieSecret is empty");
33
+ const { cookieSecret, provider, filter, denied, unauthorized, signin, sessionVersion, loginPath = "/auth/login", callbackPath = "/auth/callback", logoutPath = "/auth/logout", sessionTtlSeconds = 60 * 60 * 24, cookieName = "__Host-gate", stateCookieName = "__Host-gate_state", } = config;
34
+ if (cookieSecret.length < 32) {
35
+ throw new TypeError("createGate: cookieSecret must be at least 32 characters (e.g. openssl rand -hex 32)");
36
36
  }
37
+ // __Host- prefixed cookies require Path=/; otherwise keep the state
38
+ // cookie scoped to the callback route only
39
+ const stateCookiePath = stateCookieName.startsWith("__Host-") ? "/" : callbackPath;
37
40
  return async function gate(request) {
38
41
  const url = new URL(request.url);
39
42
  const redirectUri = `${url.origin}${callbackPath}`;
@@ -50,14 +53,14 @@ export function createGate(config) {
50
53
  const startLogin = async (silent) => {
51
54
  const state = randomState();
52
55
  const returnTo = safeReturnTo(url.searchParams.get("returnTo"));
53
- const stateToken = await sign({ state, returnTo, exp: nowSec() + 600 }, cookieSecret);
56
+ const stateToken = await sign({ state, returnTo, exp: nowSec() + 600 }, cookieSecret, url.origin);
54
57
  const res = redirect(provider.authorizeUrl({ redirectUri, state, silent }));
55
- res.headers.set("Set-Cookie", `${stateCookieName}=${stateToken}; Path=${callbackPath}; HttpOnly; Secure; SameSite=Lax; Max-Age=600`);
58
+ res.headers.set("Set-Cookie", `${stateCookieName}=${stateToken}; Path=${stateCookiePath}; HttpOnly; Secure; SameSite=Lax; Max-Age=600`);
56
59
  return res;
57
60
  };
58
61
  const handleCallback = async () => {
59
62
  const stateToken = readCookie(stateCookieName);
60
- const verified = stateToken ? await verify(stateToken, cookieSecret) : null;
63
+ const verified = stateToken ? await verify(stateToken, cookieSecret, url.origin) : null;
61
64
  const statePayload = verified && !verified.expired && isStatePayload(verified.payload) ? verified.payload : null;
62
65
  // providers report failed silent re-auth as an error param;
63
66
  // fall back to interactive login instead of surfacing it
@@ -70,9 +73,26 @@ export function createGate(config) {
70
73
  if (!statePayload || !code || !state || state !== statePayload.state) {
71
74
  return new Response("Invalid OAuth state", { status: 400 });
72
75
  }
73
- const data = await provider.identify({ code, redirectUri });
76
+ // exceptions fail closed: an IdP outage or a filter bug must deny,
77
+ // never crash into the platform error page; keep the cause in logs
78
+ let data;
79
+ try {
80
+ data = await provider.identify({ code, redirectUri });
81
+ }
82
+ catch (error) {
83
+ console.error("workers-gate: provider.identify threw", error);
84
+ data = null;
85
+ }
74
86
  // loose != so a sloppy provider resolving undefined fails closed
75
- const allowed = data != null && (filter ? await filter(data, { request }) : true);
87
+ let allowed = false;
88
+ if (data != null) {
89
+ try {
90
+ allowed = filter ? await filter(data, { request }) : true;
91
+ }
92
+ catch (error) {
93
+ console.error("workers-gate: filter threw", error);
94
+ }
95
+ }
76
96
  if (!allowed) {
77
97
  const retryUrl = `${loginPath}?returnTo=${encodeURIComponent(safeReturnTo(statePayload.returnTo))}`;
78
98
  if (denied)
@@ -82,10 +102,13 @@ export function createGate(config) {
82
102
  headers: { "Content-Type": "text/html; charset=utf-8" },
83
103
  });
84
104
  }
85
- const session = await sign({ exp: nowSec() + sessionTtlSeconds }, cookieSecret);
105
+ const session = await sign({
106
+ exp: nowSec() + sessionTtlSeconds,
107
+ ...(sessionVersion !== undefined ? { v: sessionVersion } : {}),
108
+ }, cookieSecret, url.origin);
86
109
  const res = redirect(safeReturnTo(statePayload.returnTo));
87
110
  res.headers.append("Set-Cookie", `${cookieName}=${session}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${sessionTtlSeconds}`);
88
- res.headers.append("Set-Cookie", `${stateCookieName}=; Path=${callbackPath}; HttpOnly; Secure; SameSite=Lax; Max-Age=0`);
111
+ res.headers.append("Set-Cookie", `${stateCookieName}=; Path=${stateCookiePath}; HttpOnly; Secure; SameSite=Lax; Max-Age=0`);
89
112
  return res;
90
113
  };
91
114
  const logout = () => {
@@ -102,9 +125,12 @@ export function createGate(config) {
102
125
  return logout();
103
126
  }
104
127
  const sessionToken = readCookie(cookieName);
105
- const verified = sessionToken ? await verify(sessionToken, cookieSecret) : null;
128
+ const verified = sessionToken ? await verify(sessionToken, cookieSecret, url.origin) : null;
106
129
  const session = verified && isSessionPayload(verified.payload) ? verified.payload : null;
107
- if (session && !verified?.expired) {
130
+ // a stale sessionVersion is treated like expiry: the visitor is real,
131
+ // but the policy changed — send them back through re-authorization
132
+ const versionOk = sessionVersion === undefined || session?.v === sessionVersion;
133
+ if (session && !verified?.expired && versionOk) {
108
134
  return null; // authorized — the request is yours
109
135
  }
110
136
  const returnTo = encodeURIComponent(url.pathname + url.search);
package/dist/oauth.js CHANGED
@@ -41,7 +41,14 @@ export function oauthProvider(config) {
41
41
  });
42
42
  if (!res.ok)
43
43
  return null;
44
- const data = await res.json();
44
+ // a token endpoint answering non-JSON is out of contract: fail closed
45
+ let data;
46
+ try {
47
+ data = await res.json();
48
+ }
49
+ catch {
50
+ return null;
51
+ }
45
52
  if (typeof data !== "object" ||
46
53
  data === null ||
47
54
  !("access_token" in data) ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workers-gate",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Stateless authorization gate for apps on Cloudflare Workers — bring your own provider, no database, cookie signing delegated to jose",
5
5
  "keywords": [
6
6
  "authorization",