workers-gate 0.1.0 → 0.2.1
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 +18 -6
- package/dist/cookie.d.ts +2 -2
- package/dist/cookie.js +7 -3
- package/dist/gate.d.ts +6 -0
- package/dist/gate.js +38 -12
- package/dist/oauth.js +8 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# workers-gate
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/workers-gate)
|
|
4
|
+
[](https://github.com/sunya9/workers-gate/actions/workflows/ci.yml)
|
|
5
|
+
[](./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.
|
|
@@ -85,9 +89,7 @@ const myProvider: GateProvider<MyData> = {
|
|
|
85
89
|
},
|
|
86
90
|
// Procure the data about the visitor. null = could not identify.
|
|
87
91
|
async identify({ code, redirectUri }) {
|
|
88
|
-
return {
|
|
89
|
-
/* whatever your filter needs */
|
|
90
|
-
};
|
|
92
|
+
return {/* whatever your filter needs */};
|
|
91
93
|
},
|
|
92
94
|
};
|
|
93
95
|
```
|
|
@@ -138,7 +140,7 @@ OIDC — implement `GateProvider` with openid-client, return the ID-token claims
|
|
|
138
140
|
|
|
139
141
|
| Field | Default | Description |
|
|
140
142
|
| ------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
141
|
-
| `cookieSecret` | required | HMAC key for session/state cookies
|
|
143
|
+
| `cookieSecret` | required | HMAC key for session/state cookies, 32+ characters |
|
|
142
144
|
| `provider` | required | Data procurement: `authorizeUrl` + `identify` |
|
|
143
145
|
| `filter` | admit all identified | The authorization decision |
|
|
144
146
|
| `signin` | redirect straight to the IdP | Custom sign-in screen for unauthenticated document requests; receives `loginUrl` |
|
|
@@ -146,7 +148,17 @@ OIDC — implement `GateProvider` with openid-client, return the ID-token claims
|
|
|
146
148
|
| `unauthorized` | plain-text 401 | Custom Response for sessionless fetch/XHR; receives `loginUrl` |
|
|
147
149
|
| `sessionTtlSeconds` | `86400` | Session cookie lifetime |
|
|
148
150
|
| `loginPath` / `callbackPath` / `logoutPath` | `/auth/login` `/auth/callback` `/auth/logout` | Routes the gate claims |
|
|
149
|
-
| `cookieName` / `stateCookieName` | `
|
|
151
|
+
| `cookieName` / `stateCookieName` | `__Host-gate` `__Host-gate_state` | Cookie names |
|
|
152
|
+
| `sessionVersion` | unset | Bump to send every outstanding session back through re-authorization |
|
|
153
|
+
|
|
154
|
+
## Security notes
|
|
155
|
+
|
|
156
|
+
- 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.
|
|
157
|
+
- `createGate` rejects a `cookieSecret` shorter than 32 characters.
|
|
158
|
+
- `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`.
|
|
159
|
+
- 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`).
|
|
160
|
+
- 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.
|
|
161
|
+
- 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
162
|
|
|
151
163
|
## Screens are yours
|
|
152
164
|
|
|
@@ -196,6 +208,6 @@ If the silent pass fails (signed out of the IdP), the gate falls back to an inte
|
|
|
196
208
|
|
|
197
209
|
## Limitations
|
|
198
210
|
|
|
199
|
-
- Stateless means no
|
|
211
|
+
- 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
212
|
- Scope is redirect-and-callback flows (OAuth/OIDC-style). Basic auth or IP allowlists are a different animal.
|
|
201
213
|
- 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
|
-
|
|
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 = "
|
|
34
|
-
if (
|
|
35
|
-
throw new TypeError("createGate: cookieSecret
|
|
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=${
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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=${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
@@ -50,10 +50,10 @@
|
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^26.1.0",
|
|
53
|
-
"oxfmt": "^0.
|
|
53
|
+
"oxfmt": "^0.67.0",
|
|
54
54
|
"oxlint": "^1.72.0",
|
|
55
|
-
"typescript": "^
|
|
56
|
-
"vitest": "^
|
|
55
|
+
"typescript": "^7.0.2",
|
|
56
|
+
"vitest": "^5.0.0"
|
|
57
57
|
},
|
|
58
58
|
"packageManager": "pnpm@11.9.0"
|
|
59
59
|
}
|