workers-gate 0.1.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/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/cookie.d.ts +8 -0
- package/dist/cookie.js +20 -0
- package/dist/gate.d.ts +79 -0
- package/dist/gate.js +127 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/oauth.d.ts +30 -0
- package/dist/oauth.js +58 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sunya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# workers-gate
|
|
2
|
+
|
|
3
|
+
A stateless authorization gate for apps on Cloudflare Workers. Bring your own provider; call the gate where you want it.
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
`createGate(config)` gives you a plain function: `(request) => Promise<Response | null>`.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
gate(request):
|
|
13
|
+
/auth/login → Response: redirect to provider.authorizeUrl(...)
|
|
14
|
+
/auth/callback → provider.identify(...) fetches data → your filter(data) decides
|
|
15
|
+
├─ true → Response: signed session cookie + redirect back
|
|
16
|
+
└─ false → Response: 403
|
|
17
|
+
anything else → valid session cookie? (signature + expiry, no I/O)
|
|
18
|
+
├─ yes → null ← the request is yours
|
|
19
|
+
└─ no → Response: redirect to login (or 401 for non-document requests)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
You call it inside your own `fetch` and return early when it hands you a Response — the standard middleware contract. No wrapping, no hidden routing, no implicit env reads: every value the gate uses is one you passed it.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// src/worker.ts
|
|
28
|
+
import { env } from "cloudflare:workers";
|
|
29
|
+
import { createGate, oauthProvider } from "workers-gate";
|
|
30
|
+
|
|
31
|
+
const gate = createGate({
|
|
32
|
+
cookieSecret: env.COOKIE_SECRET,
|
|
33
|
+
provider: oauthProvider({
|
|
34
|
+
// endpoints, client credentials, scope, identify() —
|
|
35
|
+
// see Providers below; complete configs live in Recipes
|
|
36
|
+
}),
|
|
37
|
+
filter: (data) => data.team === "blue", // your policy, typed by the provider
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export default {
|
|
41
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
42
|
+
return (await gate(request)) ?? env.ASSETS.fetch(request);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- Build the gate once at module scope via the [`cloudflare:workers`](https://developers.cloudflare.com/workers/runtime-apis/bindings/#importing-env-as-a-module) env import (compatibility date 2025-03-10+), or per request inside `fetch` from the handler's `env` — either works the same.
|
|
48
|
+
- `filter` receives whatever data `identify` returned — fully typed, re-evaluated on every (re-)login. Omit it to admit anyone the provider can identify.
|
|
49
|
+
- The layering encodes ownership: `identify` lives in the provider (shareable, policy-free procurement), `filter` lives in your config (your deployment's policy). Folding the judgment into `identify` (return `null` for "no") is equally valid.
|
|
50
|
+
- Public routes are your if-statement: `if (url.pathname === "/healthz") return ok()` before calling `gate(request)`.
|
|
51
|
+
- Works with wrangler-generated `Env` types as-is — the gate never touches `env`; you hand it values.
|
|
52
|
+
|
|
53
|
+
Route every request through the worker so static assets are gated too:
|
|
54
|
+
|
|
55
|
+
```jsonc
|
|
56
|
+
// wrangler.jsonc
|
|
57
|
+
{
|
|
58
|
+
"main": "src/worker.ts",
|
|
59
|
+
"assets": {
|
|
60
|
+
"directory": "./dist",
|
|
61
|
+
"binding": "ASSETS",
|
|
62
|
+
"not_found_handling": "single-page-application", // for SPAs
|
|
63
|
+
"run_worker_first": true, // or ["/*", "!/assets/*"] to skip hashed assets
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The gate itself needs one binding: `COOKIE_SECRET` (`openssl rand -hex 32`, via `wrangler secret put`). Everything else belongs to your provider and filter.
|
|
69
|
+
|
|
70
|
+
Set secrets before the first deploy: `createGate` fails fast on an empty `cookieSecret`, so a worker deployed without its secrets won't pass deploy validation. `wrangler secret put` offers to create a draft Worker when none exists — put the secrets first, then deploy. To keep deployment-specific values out of tracked files, `wrangler deploy --var KEY:value` overrides config vars at deploy time.
|
|
71
|
+
|
|
72
|
+
## Providers
|
|
73
|
+
|
|
74
|
+
A provider procures data about the visitor; it makes no decisions. For any vanilla OAuth2 authorization-code IdP, `oauthProvider()` covers the protocol plumbing — you supply two endpoints, client credentials, and an `identify()` that fetches the data your filter judges (see the Discord recipe below for a complete one).
|
|
75
|
+
|
|
76
|
+
For IdPs with heavier dialects (PKCE-required, custom token-endpoint auth), implement `GateProvider` directly — two functions — on top of whatever client you like ([openid-client](https://github.com/panva/openid-client) for OIDC, [Arctic](https://arcticjs.dev) for 60+ providers; both run on Workers):
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { type GateProvider } from "workers-gate";
|
|
80
|
+
|
|
81
|
+
const myProvider: GateProvider<MyData> = {
|
|
82
|
+
// Where visitors go to prove themselves.
|
|
83
|
+
authorizeUrl({ redirectUri, state, silent }) {
|
|
84
|
+
return "https://idp.example/authorize?...";
|
|
85
|
+
},
|
|
86
|
+
// Procure the data about the visitor. null = could not identify.
|
|
87
|
+
async identify({ code, redirectUri }) {
|
|
88
|
+
return {
|
|
89
|
+
/* whatever your filter needs */
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The contract: `authorizeUrl` must request whatever scope/claims `identify` needs to gather its data — the two are a pair. Keep the scope minimal.
|
|
96
|
+
|
|
97
|
+
`silent` is `true` when a returning visitor's session just expired; OAuth-style providers can translate it to `prompt=none`. If the IdP answers the callback with an `error` param, the gate automatically retries an interactive login. `oauthProvider`'s `identify` also receives the full `tokenResponse` for extras like `id_token`.
|
|
98
|
+
|
|
99
|
+
## Recipes
|
|
100
|
+
|
|
101
|
+
The library ships no IdP presets — endpoints, scope, and policy are yours. Both example projects are deployable as-is (wrangler config, `.dev.vars` template, generated types).
|
|
102
|
+
|
|
103
|
+
### Discord: guild members only — [`examples/discord-guild/`](./examples/discord-guild/)
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
provider: oauthProvider({
|
|
107
|
+
authorizeEndpoint: "https://discord.com/oauth2/authorize",
|
|
108
|
+
tokenEndpoint: "https://discord.com/api/v10/oauth2/token",
|
|
109
|
+
clientId: env.DISCORD_CLIENT_ID,
|
|
110
|
+
clientSecret: env.DISCORD_CLIENT_SECRET,
|
|
111
|
+
scope: "guilds.members.read",
|
|
112
|
+
silentParams: { prompt: "none" },
|
|
113
|
+
async identify({ accessToken }) {
|
|
114
|
+
// membership in this one guild, straight from Discord; 404 = not a member
|
|
115
|
+
const res = await fetch(
|
|
116
|
+
`https://discord.com/api/v10/users/@me/guilds/${env.GUILD_ID}/member`,
|
|
117
|
+
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
|
118
|
+
);
|
|
119
|
+
if (!res.ok) return null;
|
|
120
|
+
const member: { roles: string[] } = await res.json();
|
|
121
|
+
return member;
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
// membership itself is the policy; add a filter for finer rules, e.g. roles
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Prefer the broader `guilds` scope + `GET /users/@me/guilds` if you'd rather not let the app read your nickname/roles in that guild.
|
|
128
|
+
|
|
129
|
+
### GitHub: org members only — [`examples/github-org/`](./examples/github-org/)
|
|
130
|
+
|
|
131
|
+
Scope `read:org`, `identify` fetches `GET /user/memberships/orgs/{org}`, and the filter admits only `state === "active"`. The example also ships a denial page with GitHub-specific recovery guidance (GitHub has no `prompt` support, so switching accounts requires signing out of github.com first). An org-owned private GitHub App is the cleanest registration: installing it on the org doubles as the access approval that OAuth Apps need separately.
|
|
132
|
+
|
|
133
|
+
### Google Workspace: domain members only
|
|
134
|
+
|
|
135
|
+
OIDC — implement `GateProvider` with openid-client, return the ID-token claims from `identify`, and `filter: (claims) => claims.hd === "example.com"`.
|
|
136
|
+
|
|
137
|
+
## GateConfig
|
|
138
|
+
|
|
139
|
+
| Field | Default | Description |
|
|
140
|
+
| ------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
141
|
+
| `cookieSecret` | required | HMAC key for session/state cookies |
|
|
142
|
+
| `provider` | required | Data procurement: `authorizeUrl` + `identify` |
|
|
143
|
+
| `filter` | admit all identified | The authorization decision |
|
|
144
|
+
| `signin` | redirect straight to the IdP | Custom sign-in screen for unauthenticated document requests; receives `loginUrl` |
|
|
145
|
+
| `denied` | built-in minimal 403 page | Custom Response for rejected/unidentified visitors; receives `loginUrl` |
|
|
146
|
+
| `unauthorized` | plain-text 401 | Custom Response for sessionless fetch/XHR; receives `loginUrl` |
|
|
147
|
+
| `sessionTtlSeconds` | `86400` | Session cookie lifetime |
|
|
148
|
+
| `loginPath` / `callbackPath` / `logoutPath` | `/auth/login` `/auth/callback` `/auth/logout` | Routes the gate claims |
|
|
149
|
+
| `cookieName` / `stateCookieName` | `__gate` `__gate_state` | Cookie names |
|
|
150
|
+
|
|
151
|
+
## Screens are yours
|
|
152
|
+
|
|
153
|
+
The gate ships no UI beyond a fallback 403/401. Every visible surface is replaceable:
|
|
154
|
+
|
|
155
|
+
- Sign-in screen (`signin`): rendered in place of the default straight-to-IdP redirect for unauthenticated page visits — the URL stays put, your screen shows, its button points at `loginUrl` (which already carries the `returnTo`). Both examples render one. Silent re-authorization of expired sessions bypasses the screen and stays seamless.
|
|
156
|
+
- Denied page (`denied`): `denied: ({ loginUrl }) => new Response(myBrandedHtml, { status: 403, headers: { "Content-Type": "text/html" } })`
|
|
157
|
+
- API 401 shape (`unauthorized`): `unauthorized: ({ loginUrl }) => Response.json({ error: "login required", loginUrl }, { status: 401 })`
|
|
158
|
+
|
|
159
|
+
By default there is no login screen at all: unauthenticated visitors are redirected straight to the IdP's own consent page.
|
|
160
|
+
|
|
161
|
+
## Notes for SPAs
|
|
162
|
+
|
|
163
|
+
- `not_found_handling: "single-page-application"` keeps client-side routing working: unknown paths serve the (gated) `index.html`.
|
|
164
|
+
- Using `@cloudflare/vite-plugin`? Same worker entry, same wrangler config — `vite dev` runs the gate locally. Put the bindings in `.dev.vars` and register the localhost callback URL with your IdP.
|
|
165
|
+
- When the session expires mid-visit, background `fetch` calls get `401` instead of a redirect (shape the body with the `unauthorized` hook). The simple recovery is `location.reload()` — the reload goes through silent re-authorization and comes back to the same URL.
|
|
166
|
+
- To renew without losing app state, use a popup: top-level navigation carries the `SameSite=Lax` cookies, which hidden iframes and background fetches do not. Serve a self-closing page behind the gate and point the popup's `returnTo` at it:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
// worker: after the gate has passed
|
|
170
|
+
if (url.pathname === "/auth/done") {
|
|
171
|
+
return new Response("<script>window.close()</script>", {
|
|
172
|
+
headers: { "Content-Type": "text/html" },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// SPA: on 401 — tie this to a click so popup blockers stay calm
|
|
179
|
+
const popup = window.open(
|
|
180
|
+
"/auth/login?returnTo=%2Fauth%2Fdone&silent=1",
|
|
181
|
+
"_blank",
|
|
182
|
+
"width=520,height=640",
|
|
183
|
+
);
|
|
184
|
+
await new Promise<void>((resolve) => {
|
|
185
|
+
const timer = setInterval(() => {
|
|
186
|
+
if (popup?.closed) {
|
|
187
|
+
clearInterval(timer);
|
|
188
|
+
resolve();
|
|
189
|
+
}
|
|
190
|
+
}, 300);
|
|
191
|
+
});
|
|
192
|
+
// retry the failed request — app state untouched
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
If the silent pass fails (signed out of the IdP), the gate falls back to an interactive login inside the popup; the app keeps running either way.
|
|
196
|
+
|
|
197
|
+
## Limitations
|
|
198
|
+
|
|
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.
|
|
200
|
+
- Scope is redirect-and-callback flows (OAuth/OIDC-style). Basic auth or IP allowlists are a different animal.
|
|
201
|
+
- A `POST` issued with an expired session is redirected and loses its body — the follow-up navigation re-authorizes.
|
package/dist/cookie.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type JWTPayload } from "jose";
|
|
2
|
+
export interface VerifiedToken {
|
|
3
|
+
payload: unknown;
|
|
4
|
+
/** signature was valid but the exp claim has passed */
|
|
5
|
+
expired: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function sign(payload: JWTPayload, secret: string): Promise<string>;
|
|
8
|
+
export declare function verify(token: string, secret: string): Promise<VerifiedToken | null>;
|
package/dist/cookie.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { errors, jwtVerify, SignJWT } from "jose";
|
|
2
|
+
const encoder = new TextEncoder();
|
|
3
|
+
export function sign(payload, secret) {
|
|
4
|
+
return new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).sign(encoder.encode(secret));
|
|
5
|
+
}
|
|
6
|
+
export async function verify(token, secret) {
|
|
7
|
+
try {
|
|
8
|
+
const { payload } = await jwtVerify(token, encoder.encode(secret), {
|
|
9
|
+
algorithms: ["HS256"],
|
|
10
|
+
});
|
|
11
|
+
return { payload, expired: false };
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
// expired-but-valid must stay distinguishable: it drives silent re-auth
|
|
15
|
+
if (error instanceof errors.JWTExpired) {
|
|
16
|
+
return { payload: error.payload, expired: true };
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
package/dist/gate.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export interface AuthorizeUrlInput {
|
|
2
|
+
redirectUri: string;
|
|
3
|
+
state: string;
|
|
4
|
+
/**
|
|
5
|
+
* true when re-authorizing a returning visitor whose session just expired.
|
|
6
|
+
* OAuth-style providers may map this to prompt=none for a seamless refresh.
|
|
7
|
+
*/
|
|
8
|
+
silent: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface IdentifyInput {
|
|
11
|
+
code: string;
|
|
12
|
+
redirectUri: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The pluggable half of the gate: where to send visitors to prove themselves,
|
|
16
|
+
* and how to fetch the data about them that the filter will judge.
|
|
17
|
+
*
|
|
18
|
+
* Contract: authorizeUrl must request whatever scope/claims identify() needs
|
|
19
|
+
* to gather its data — the two are a pair. Providers procure, filters decide.
|
|
20
|
+
*/
|
|
21
|
+
export interface GateProvider<Data extends {} = {}> {
|
|
22
|
+
/** Build the URL the visitor is redirected to for authorization */
|
|
23
|
+
authorizeUrl(input: AuthorizeUrlInput): string;
|
|
24
|
+
/** Fetch the data about the returning visitor; null = could not identify */
|
|
25
|
+
identify(input: IdentifyInput): Promise<Data | null>;
|
|
26
|
+
}
|
|
27
|
+
export interface GateFilterContext {
|
|
28
|
+
request: Request;
|
|
29
|
+
}
|
|
30
|
+
export interface GateConfig<Data extends {} = {}> {
|
|
31
|
+
/** HMAC key for the session and state cookies, e.g. env.COOKIE_SECRET */
|
|
32
|
+
cookieSecret: string;
|
|
33
|
+
provider: GateProvider<Data>;
|
|
34
|
+
/**
|
|
35
|
+
* The authorization decision: given the data the provider identified,
|
|
36
|
+
* decide whether to let the visitor in. Runs on every (re-)login.
|
|
37
|
+
* Omitted = anyone the provider can identify is admitted.
|
|
38
|
+
*/
|
|
39
|
+
filter?: (data: Data, context: GateFilterContext) => boolean | Promise<boolean>;
|
|
40
|
+
/**
|
|
41
|
+
* Custom 403 response for visitors the provider couldn't identify or the
|
|
42
|
+
* filter rejected. Default: a minimal built-in HTML page.
|
|
43
|
+
* loginUrl carries the returnTo, so a retry lands on the original page.
|
|
44
|
+
*/
|
|
45
|
+
denied?: (context: {
|
|
46
|
+
request: Request;
|
|
47
|
+
loginUrl: string;
|
|
48
|
+
}) => Response | Promise<Response>;
|
|
49
|
+
/**
|
|
50
|
+
* Custom response for non-document requests (fetch/XHR) without a valid
|
|
51
|
+
* session. Default: a plain-text 401. loginUrl carries the returnTo.
|
|
52
|
+
*/
|
|
53
|
+
unauthorized?: (context: {
|
|
54
|
+
request: Request;
|
|
55
|
+
loginUrl: string;
|
|
56
|
+
}) => Response | Promise<Response>;
|
|
57
|
+
/**
|
|
58
|
+
* Custom sign-in screen for unauthenticated document requests.
|
|
59
|
+
* Default: redirect straight to the provider's authorize URL.
|
|
60
|
+
* Not consulted for silent re-authorization of expired sessions,
|
|
61
|
+
* which stays a seamless redirect. loginUrl carries the returnTo.
|
|
62
|
+
*/
|
|
63
|
+
signin?: (context: {
|
|
64
|
+
request: Request;
|
|
65
|
+
loginUrl: string;
|
|
66
|
+
}) => Response | Promise<Response>;
|
|
67
|
+
loginPath?: string;
|
|
68
|
+
callbackPath?: string;
|
|
69
|
+
logoutPath?: string;
|
|
70
|
+
sessionTtlSeconds?: number;
|
|
71
|
+
cookieName?: string;
|
|
72
|
+
stateCookieName?: string;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Returns a Response when the gate handles or denies the request
|
|
76
|
+
* (auth routes, redirects, 401/403), and null when the visitor may pass.
|
|
77
|
+
*/
|
|
78
|
+
export type Gate = (request: Request) => Promise<Response | null>;
|
|
79
|
+
export declare function createGate<Data extends {}>(config: GateConfig<Data>): Gate;
|
package/dist/gate.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { sign, verify } from "./cookie";
|
|
2
|
+
function nowSec() {
|
|
3
|
+
return Math.floor(Date.now() / 1000);
|
|
4
|
+
}
|
|
5
|
+
function redirect(location) {
|
|
6
|
+
return new Response(null, { status: 302, headers: { Location: location } });
|
|
7
|
+
}
|
|
8
|
+
function randomState() {
|
|
9
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
10
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
11
|
+
}
|
|
12
|
+
function forbiddenHtml(loginUrl) {
|
|
13
|
+
return `<!doctype html>
|
|
14
|
+
<meta charset="utf-8">
|
|
15
|
+
<title>403 Forbidden</title>
|
|
16
|
+
<p>You are not authorized to access this application.</p>
|
|
17
|
+
<p><a href="${loginUrl}">Sign in again</a></p>`;
|
|
18
|
+
}
|
|
19
|
+
function isSessionPayload(value) {
|
|
20
|
+
return (typeof value === "object" && value !== null && "exp" in value && typeof value.exp === "number");
|
|
21
|
+
}
|
|
22
|
+
function isStatePayload(value) {
|
|
23
|
+
return (typeof value === "object" &&
|
|
24
|
+
value !== null &&
|
|
25
|
+
"state" in value &&
|
|
26
|
+
typeof value.state === "string" &&
|
|
27
|
+
"returnTo" in value &&
|
|
28
|
+
typeof value.returnTo === "string" &&
|
|
29
|
+
"exp" in value &&
|
|
30
|
+
typeof value.exp === "number");
|
|
31
|
+
}
|
|
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");
|
|
36
|
+
}
|
|
37
|
+
return async function gate(request) {
|
|
38
|
+
const url = new URL(request.url);
|
|
39
|
+
const redirectUri = `${url.origin}${callbackPath}`;
|
|
40
|
+
const readCookie = (name) => {
|
|
41
|
+
const header = request.headers.get("Cookie") ?? "";
|
|
42
|
+
for (const part of header.split(/;\s*/)) {
|
|
43
|
+
const eq = part.indexOf("=");
|
|
44
|
+
if (eq > 0 && part.slice(0, eq) === name)
|
|
45
|
+
return part.slice(eq + 1);
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
};
|
|
49
|
+
const safeReturnTo = (value) => value?.startsWith("/") && !value.startsWith("//") ? value : "/";
|
|
50
|
+
const startLogin = async (silent) => {
|
|
51
|
+
const state = randomState();
|
|
52
|
+
const returnTo = safeReturnTo(url.searchParams.get("returnTo"));
|
|
53
|
+
const stateToken = await sign({ state, returnTo, exp: nowSec() + 600 }, cookieSecret);
|
|
54
|
+
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`);
|
|
56
|
+
return res;
|
|
57
|
+
};
|
|
58
|
+
const handleCallback = async () => {
|
|
59
|
+
const stateToken = readCookie(stateCookieName);
|
|
60
|
+
const verified = stateToken ? await verify(stateToken, cookieSecret) : null;
|
|
61
|
+
const statePayload = verified && !verified.expired && isStatePayload(verified.payload) ? verified.payload : null;
|
|
62
|
+
// providers report failed silent re-auth as an error param;
|
|
63
|
+
// fall back to interactive login instead of surfacing it
|
|
64
|
+
if (url.searchParams.get("error")) {
|
|
65
|
+
const returnTo = safeReturnTo(statePayload?.returnTo);
|
|
66
|
+
return redirect(`${loginPath}?returnTo=${encodeURIComponent(returnTo)}`);
|
|
67
|
+
}
|
|
68
|
+
const code = url.searchParams.get("code");
|
|
69
|
+
const state = url.searchParams.get("state");
|
|
70
|
+
if (!statePayload || !code || !state || state !== statePayload.state) {
|
|
71
|
+
return new Response("Invalid OAuth state", { status: 400 });
|
|
72
|
+
}
|
|
73
|
+
const data = await provider.identify({ code, redirectUri });
|
|
74
|
+
// loose != so a sloppy provider resolving undefined fails closed
|
|
75
|
+
const allowed = data != null && (filter ? await filter(data, { request }) : true);
|
|
76
|
+
if (!allowed) {
|
|
77
|
+
const retryUrl = `${loginPath}?returnTo=${encodeURIComponent(safeReturnTo(statePayload.returnTo))}`;
|
|
78
|
+
if (denied)
|
|
79
|
+
return denied({ request, loginUrl: retryUrl });
|
|
80
|
+
return new Response(forbiddenHtml(retryUrl), {
|
|
81
|
+
status: 403,
|
|
82
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const session = await sign({ exp: nowSec() + sessionTtlSeconds }, cookieSecret);
|
|
86
|
+
const res = redirect(safeReturnTo(statePayload.returnTo));
|
|
87
|
+
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`);
|
|
89
|
+
return res;
|
|
90
|
+
};
|
|
91
|
+
const logout = () => {
|
|
92
|
+
const res = redirect("/");
|
|
93
|
+
res.headers.set("Set-Cookie", `${cookieName}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`);
|
|
94
|
+
return res;
|
|
95
|
+
};
|
|
96
|
+
switch (url.pathname) {
|
|
97
|
+
case loginPath:
|
|
98
|
+
return startLogin(url.searchParams.get("silent") === "1");
|
|
99
|
+
case callbackPath:
|
|
100
|
+
return handleCallback();
|
|
101
|
+
case logoutPath:
|
|
102
|
+
return logout();
|
|
103
|
+
}
|
|
104
|
+
const sessionToken = readCookie(cookieName);
|
|
105
|
+
const verified = sessionToken ? await verify(sessionToken, cookieSecret) : null;
|
|
106
|
+
const session = verified && isSessionPayload(verified.payload) ? verified.payload : null;
|
|
107
|
+
if (session && !verified?.expired) {
|
|
108
|
+
return null; // authorized — the request is yours
|
|
109
|
+
}
|
|
110
|
+
const returnTo = encodeURIComponent(url.pathname + url.search);
|
|
111
|
+
const loginUrl = `${loginPath}?returnTo=${returnTo}`;
|
|
112
|
+
const isDocument = request.headers.get("Sec-Fetch-Dest") === "document" ||
|
|
113
|
+
(request.headers.get("Accept") ?? "").includes("text/html");
|
|
114
|
+
if (!isDocument) {
|
|
115
|
+
if (unauthorized)
|
|
116
|
+
return unauthorized({ request, loginUrl });
|
|
117
|
+
return new Response("Unauthorized", { status: 401 });
|
|
118
|
+
}
|
|
119
|
+
// a validly-signed but expired session means a returning visitor:
|
|
120
|
+
// silent re-authorization stays a seamless redirect, skipping signin
|
|
121
|
+
if (session)
|
|
122
|
+
return redirect(`${loginUrl}&silent=1`);
|
|
123
|
+
if (signin)
|
|
124
|
+
return signin({ request, loginUrl });
|
|
125
|
+
return redirect(loginUrl);
|
|
126
|
+
};
|
|
127
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/oauth.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { GateProvider } from "./gate";
|
|
2
|
+
export interface OAuthIdentifyInput {
|
|
3
|
+
accessToken: string;
|
|
4
|
+
/**
|
|
5
|
+
* The token endpoint's full parsed response — for extras like id_token
|
|
6
|
+
* (OIDC IdPs), granted scope, or refresh_token. Shape is IdP-specific.
|
|
7
|
+
*/
|
|
8
|
+
tokenResponse: unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface OAuthProviderConfig<Data extends {}> {
|
|
11
|
+
authorizeEndpoint: string;
|
|
12
|
+
tokenEndpoint: string;
|
|
13
|
+
clientId: string;
|
|
14
|
+
clientSecret: string;
|
|
15
|
+
/** Must cover whatever identify() fetches — scope and identify are a pair */
|
|
16
|
+
scope?: string;
|
|
17
|
+
/** Extra query params appended on silent re-authorization, e.g. { prompt: "none" } */
|
|
18
|
+
silentParams?: Record<string, string>;
|
|
19
|
+
/** Fetch the data about the visitor that the gate's filter will judge */
|
|
20
|
+
identify(input: OAuthIdentifyInput): Promise<Data | null>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Build a GateProvider from a vanilla OAuth2 authorization-code flow.
|
|
24
|
+
* Covers the protocol plumbing (authorize URL, code→token exchange);
|
|
25
|
+
* you supply the endpoints and the data-fetching identify().
|
|
26
|
+
*
|
|
27
|
+
* IdPs with heavier dialects (PKCE-required, custom token auth) are better
|
|
28
|
+
* served by implementing GateProvider directly, e.g. on top of openid-client.
|
|
29
|
+
*/
|
|
30
|
+
export declare function oauthProvider<Data extends {}>(config: OAuthProviderConfig<Data>): GateProvider<Data>;
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a GateProvider from a vanilla OAuth2 authorization-code flow.
|
|
3
|
+
* Covers the protocol plumbing (authorize URL, code→token exchange);
|
|
4
|
+
* you supply the endpoints and the data-fetching identify().
|
|
5
|
+
*
|
|
6
|
+
* IdPs with heavier dialects (PKCE-required, custom token auth) are better
|
|
7
|
+
* served by implementing GateProvider directly, e.g. on top of openid-client.
|
|
8
|
+
*/
|
|
9
|
+
export function oauthProvider(config) {
|
|
10
|
+
return {
|
|
11
|
+
authorizeUrl({ redirectUri, state, silent }) {
|
|
12
|
+
const url = new URL(config.authorizeEndpoint);
|
|
13
|
+
url.searchParams.set("client_id", config.clientId);
|
|
14
|
+
url.searchParams.set("response_type", "code");
|
|
15
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
16
|
+
if (config.scope)
|
|
17
|
+
url.searchParams.set("scope", config.scope);
|
|
18
|
+
url.searchParams.set("state", state);
|
|
19
|
+
if (silent) {
|
|
20
|
+
for (const [key, value] of Object.entries(config.silentParams ?? {})) {
|
|
21
|
+
url.searchParams.set(key, value);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return url.toString();
|
|
25
|
+
},
|
|
26
|
+
async identify({ code, redirectUri }) {
|
|
27
|
+
const res = await fetch(config.tokenEndpoint, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: {
|
|
30
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
31
|
+
// some IdPs (GitHub) answer form-urlencoded unless asked for JSON
|
|
32
|
+
Accept: "application/json",
|
|
33
|
+
},
|
|
34
|
+
body: new URLSearchParams({
|
|
35
|
+
grant_type: "authorization_code",
|
|
36
|
+
code,
|
|
37
|
+
client_id: config.clientId,
|
|
38
|
+
client_secret: config.clientSecret,
|
|
39
|
+
redirect_uri: redirectUri,
|
|
40
|
+
}).toString(),
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok)
|
|
43
|
+
return null;
|
|
44
|
+
const data = await res.json();
|
|
45
|
+
if (typeof data !== "object" ||
|
|
46
|
+
data === null ||
|
|
47
|
+
!("access_token" in data) ||
|
|
48
|
+
typeof data.access_token !== "string" ||
|
|
49
|
+
data.access_token === "") {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return config.identify({
|
|
53
|
+
accessToken: data.access_token,
|
|
54
|
+
tokenResponse: data,
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "workers-gate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Stateless authorization gate for apps on Cloudflare Workers — bring your own provider, no database, cookie signing delegated to jose",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"authorization",
|
|
7
|
+
"cloudflare-workers",
|
|
8
|
+
"gate",
|
|
9
|
+
"oauth2",
|
|
10
|
+
"stateless"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/sunya9/workers-gate#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/sunya9/workers-gate/issues"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "sunya (https://unsweets.net/)",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/sunya9/workers-gate.git"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public",
|
|
36
|
+
"provenance": true
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.build.json",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"test:watch": "vitest",
|
|
42
|
+
"fmt": "oxfmt",
|
|
43
|
+
"fmt:check": "oxfmt --check",
|
|
44
|
+
"lint": "oxlint",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"typecheck:examples": "pnpm --filter './examples/*' typecheck"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"jose": "^6.2.3"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^26.1.0",
|
|
53
|
+
"oxfmt": "^0.57.0",
|
|
54
|
+
"oxlint": "^1.72.0",
|
|
55
|
+
"typescript": "^6.0.3",
|
|
56
|
+
"vitest": "^4.1.9"
|
|
57
|
+
},
|
|
58
|
+
"packageManager": "pnpm@11.9.0"
|
|
59
|
+
}
|