@uln/auth 0.4.1 → 0.6.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/dist/client.d.ts +7 -5
- package/dist/client.js +341 -95
- package/dist/cookie.js +2 -2
- package/dist/flow.d.ts +53 -0
- package/dist/flow.js +393 -0
- package/dist/hono.d.ts +30 -4
- package/dist/hono.js +346 -122
- package/dist/index.d.ts +10 -9
- package/dist/index.js +492 -172
- package/dist/middleware.d.ts +15 -5
- package/dist/middleware.js +309 -57
- package/dist/password.d.ts +9 -2
- package/dist/password.js +53 -23
- package/dist/rate-limit.d.ts +11 -0
- package/dist/rate-limit.js +21 -2
- package/dist/remote.d.ts +52 -0
- package/dist/remote.js +264 -0
- package/dist/service.d.ts +13 -13
- package/dist/service.js +375 -56
- package/dist/token.d.ts +21 -2
- package/dist/token.js +67 -16
- package/dist/totp.d.ts +7 -1
- package/dist/totp.js +26 -18
- package/dist/types.d.ts +20 -0
- package/dist/users.d.ts +5 -0
- package/dist/users.js +69 -27
- package/package.json +35 -20
- package/src/client.ts +10 -16
- package/src/flow.ts +170 -0
- package/src/hono.ts +120 -134
- package/src/index.ts +31 -23
- package/src/middleware.ts +50 -50
- package/src/password.ts +68 -28
- package/src/rate-limit.ts +38 -0
- package/src/remote.ts +158 -0
- package/src/service.ts +40 -80
- package/src/token.ts +101 -16
- package/src/totp.ts +25 -12
- package/src/types.ts +17 -0
- package/src/users.ts +21 -3
package/src/hono.ts
CHANGED
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
import type { Context, MiddlewareHandler } from "hono";
|
|
2
2
|
import { extractCookie } from "./cookie.ts";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_AUTH_URL,
|
|
5
|
+
DEFAULT_REVALIDATE_SECONDS,
|
|
6
|
+
handleLoginCallback,
|
|
7
|
+
redirectToLogin,
|
|
8
|
+
revalidateToken,
|
|
9
|
+
} from "./flow.ts";
|
|
10
|
+
import {
|
|
11
|
+
bindingFetcher,
|
|
12
|
+
type FetchLike,
|
|
13
|
+
fetchJwks,
|
|
14
|
+
hasSiteAccessFor,
|
|
15
|
+
publicFetcher,
|
|
16
|
+
verifyTokenRemote,
|
|
17
|
+
} from "./remote.ts";
|
|
18
|
+
import { peekTokenKid, peekTokenPayload, validateToken } from "./token.ts";
|
|
6
19
|
import type { CookieConfig, PublicKeyEntry, TokenPayload } from "./types.ts";
|
|
7
20
|
|
|
21
|
+
declare module "hono" {
|
|
22
|
+
interface ContextVariableMap {
|
|
23
|
+
user: TokenPayload;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
8
27
|
interface AuthOptions {
|
|
9
28
|
/**
|
|
10
29
|
* Service binding name (e.g. "AUTH"). If provided, validates tokens
|
|
11
30
|
* via the auth worker service binding instead of fetching public keys.
|
|
31
|
+
* Binding mode also rejects tokens for deleted users and revoked sessions.
|
|
12
32
|
*/
|
|
13
33
|
binding?: string;
|
|
14
34
|
|
|
@@ -23,15 +43,30 @@ interface AuthOptions {
|
|
|
23
43
|
*/
|
|
24
44
|
authUrl?: string;
|
|
25
45
|
|
|
26
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Cookie name. Defaults to "__Host-uln_auth": the `__Host-` prefix means only this exact host can set it, so a page on
|
|
48
|
+
* a sibling subdomain can't plant a session cookie (session fixation / cookie tossing).
|
|
49
|
+
*/
|
|
27
50
|
cookie?: string;
|
|
28
51
|
|
|
29
52
|
/** Cookie/token max age in seconds. Defaults to 86400 (24h). */
|
|
30
53
|
maxAge?: number;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The registered origin of this site, used for the scope check. Defaults to the request URL's origin.
|
|
57
|
+
* Set this when the Worker is reached through a different hostname than the one registered in auth.
|
|
58
|
+
*/
|
|
59
|
+
siteOrigin?: string;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Public-key mode only: confirm a locally valid token with the auth service at most this often (seconds, per
|
|
63
|
+
* token), so password resets, deleted users and removed scopes take effect within this window instead of when the
|
|
64
|
+
* token expires. If the auth service is unreachable the local result stands. Defaults to 300; 0 disables.
|
|
65
|
+
*/
|
|
66
|
+
revalidateSeconds?: number;
|
|
31
67
|
}
|
|
32
68
|
|
|
33
|
-
const
|
|
34
|
-
const DEFAULT_COOKIE = "uln_auth";
|
|
69
|
+
const DEFAULT_COOKIE = "__Host-uln_auth";
|
|
35
70
|
const DEFAULT_MAX_AGE = 86400;
|
|
36
71
|
|
|
37
72
|
function isPublicPath(pathname: string, patterns: string[]): boolean {
|
|
@@ -48,98 +83,55 @@ function isPublicPath(pathname: string, patterns: string[]): boolean {
|
|
|
48
83
|
|
|
49
84
|
// --- Public key caching (client mode) ---
|
|
50
85
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
async function getPublicKeys(authUrl: string): Promise<PublicKeyEntry[]> {
|
|
56
|
-
if (cachedKeys && Date.now() - cachedAt < CACHE_TTL) {
|
|
57
|
-
return cachedKeys;
|
|
58
|
-
}
|
|
59
|
-
const res = await fetch(`${authUrl}/.well-known/jwk.json`);
|
|
60
|
-
if (!res.ok) throw new Error(`Failed to fetch JWKS from ${authUrl}: ${res.status}`);
|
|
61
|
-
const jwks = (await res.json()) as { keys: (JsonWebKey & { kid: string })[] };
|
|
62
|
-
cachedKeys = await Promise.all(
|
|
63
|
-
jwks.keys.map(async (jwk) => ({
|
|
64
|
-
kid: jwk.kid,
|
|
65
|
-
key: await importPublicKey(jwk),
|
|
66
|
-
})),
|
|
67
|
-
);
|
|
68
|
-
cachedAt = Date.now();
|
|
69
|
-
return cachedKeys;
|
|
86
|
+
interface KeyCache {
|
|
87
|
+
keys: PublicKeyEntry[];
|
|
88
|
+
fetchedAt: number;
|
|
70
89
|
}
|
|
71
90
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
const keyCaches = new Map<string, KeyCache>();
|
|
92
|
+
const CACHE_TTL = 10 * 60_000;
|
|
93
|
+
/** Minimum spacing between forced refreshes triggered by an unknown `kid`, so bad tokens can't hammer JWKS. */
|
|
94
|
+
const MIN_REFRESH_INTERVAL = 10_000;
|
|
95
|
+
|
|
96
|
+
async function getPublicKeys(authUrl: string, wantedKid: string | null): Promise<PublicKeyEntry[]> {
|
|
97
|
+
const cached = keyCaches.get(authUrl);
|
|
98
|
+
const age = cached ? Date.now() - cached.fetchedAt : Number.POSITIVE_INFINITY;
|
|
99
|
+
const hasKid = wantedKid === null || cached?.keys.some((k) => k.kid === wantedKid);
|
|
100
|
+
// After a key rotation, tokens carry a kid we haven't seen yet — refetch instead of waiting out the TTL.
|
|
101
|
+
if (cached && age < CACHE_TTL && (hasKid || age < MIN_REFRESH_INTERVAL)) {
|
|
102
|
+
return cached.keys;
|
|
103
|
+
}
|
|
104
|
+
const keys = await fetchJwks(publicFetcher(authUrl));
|
|
105
|
+
keyCaches.set(authUrl, { keys, fetchedAt: Date.now() });
|
|
106
|
+
return keys;
|
|
77
107
|
}
|
|
78
108
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
headers: { "Content-Type": "application/json" },
|
|
83
|
-
body: JSON.stringify({ token }),
|
|
84
|
-
});
|
|
85
|
-
if (!res.ok) return null;
|
|
86
|
-
const data = (await res.json()) as {
|
|
87
|
-
user: {
|
|
88
|
-
id: string;
|
|
89
|
-
email: string;
|
|
90
|
-
name: string;
|
|
91
|
-
admin: boolean;
|
|
92
|
-
scopes: string[];
|
|
93
|
-
flags: Record<string, string[]>;
|
|
94
|
-
kid: string;
|
|
95
|
-
};
|
|
96
|
-
};
|
|
97
|
-
return {
|
|
98
|
-
sub: data.user.id,
|
|
99
|
-
email: data.user.email,
|
|
100
|
-
name: data.user.name,
|
|
101
|
-
admin: data.user.admin,
|
|
102
|
-
scopes: data.user.scopes,
|
|
103
|
-
flags: data.user.flags,
|
|
104
|
-
kid: data.user.kid,
|
|
105
|
-
iat: 0,
|
|
106
|
-
exp: 0,
|
|
107
|
-
};
|
|
109
|
+
/** Test hook: forget cached JWKS. */
|
|
110
|
+
export function clearPublicKeyCache(): void {
|
|
111
|
+
keyCaches.clear();
|
|
108
112
|
}
|
|
109
113
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
redirectOrigin: string,
|
|
113
|
-
authUrl: string,
|
|
114
|
-
): Promise<string | null> {
|
|
115
|
-
const res = await fetch(`${authUrl}/api/exchange-code`, {
|
|
116
|
-
method: "POST",
|
|
117
|
-
headers: { "Content-Type": "application/json" },
|
|
118
|
-
body: JSON.stringify({ code, redirectOrigin }),
|
|
119
|
-
});
|
|
120
|
-
if (!res.ok) return null;
|
|
121
|
-
const data = (await res.json()) as { token?: string };
|
|
122
|
-
return typeof data.token === "string" ? data.token : null;
|
|
114
|
+
function isFetcher(value: unknown): value is { fetch(input: string, init?: RequestInit): Promise<Response> } {
|
|
115
|
+
return typeof value === "object" && value !== null && "fetch" in value && typeof value.fetch === "function";
|
|
123
116
|
}
|
|
124
117
|
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
body: JSON.stringify({ code, redirectOrigin }),
|
|
130
|
-
});
|
|
131
|
-
if (!res.ok) return null;
|
|
132
|
-
const data = (await res.json()) as { token?: string };
|
|
133
|
-
return typeof data.token === "string" ? data.token : null;
|
|
118
|
+
function getBinding(env: unknown, name: string): FetchLike {
|
|
119
|
+
const binding: unknown = typeof env === "object" && env !== null ? Reflect.get(env, name) : undefined;
|
|
120
|
+
if (!isFetcher(binding)) throw new Error(`Service binding "${name}" not found in env`);
|
|
121
|
+
return bindingFetcher(binding);
|
|
134
122
|
}
|
|
135
123
|
|
|
136
124
|
/**
|
|
137
125
|
* Hono middleware that handles the full auth flow:
|
|
138
126
|
*
|
|
139
|
-
* 1. If `?code=` in URL → exchange for token, set cookie, redirect to
|
|
140
|
-
*
|
|
127
|
+
* 1. If `?code=` in URL → check it belongs to this browser's login, exchange for token, set cookie, redirect to
|
|
128
|
+
* clean URL
|
|
129
|
+
* 2. If session cookie or `Authorization: Bearer` present → validate, check site access, set `c.get("user")`
|
|
141
130
|
* 3. If unauthenticated → redirect to auth.uln.industries/login
|
|
142
131
|
*
|
|
132
|
+
* Tokens issued to another origin are rejected. Users without access to this site (not admin, origin not in
|
|
133
|
+
* `scopes`) get a 403.
|
|
134
|
+
*
|
|
143
135
|
* Usage:
|
|
144
136
|
* ```ts
|
|
145
137
|
* import { auth } from "@uln/auth/hono";
|
|
@@ -163,33 +155,38 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
|
|
|
163
155
|
const maxAge = options?.maxAge ?? DEFAULT_MAX_AGE;
|
|
164
156
|
const publicPaths = options?.public ?? [];
|
|
165
157
|
const bindingName = options?.binding;
|
|
158
|
+
const revalidateSeconds = options?.revalidateSeconds ?? DEFAULT_REVALIDATE_SECONDS;
|
|
166
159
|
|
|
167
160
|
const cookieConf: CookieConfig = { name: cookieName, maxAgeSeconds: maxAge, secure: true };
|
|
168
161
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const binding = env[bindingName] as Fetcher | undefined;
|
|
172
|
-
if (!binding) throw new Error(`Service binding "${bindingName}" not found in env`);
|
|
173
|
-
return validateWithBinding(token, binding);
|
|
174
|
-
}
|
|
175
|
-
return validateWithPublicKeys(token, authUrl, maxAge);
|
|
162
|
+
function authService(env: unknown): FetchLike {
|
|
163
|
+
return bindingName ? getBinding(env, bindingName) : publicFetcher(authUrl);
|
|
176
164
|
}
|
|
177
165
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Validate a token issued to one of `audiences` (this site's origins); a token for any other audience — including
|
|
168
|
+
* one from before audience binding — counts as no session, so the user is sent to sign in. Scopes are checked by
|
|
169
|
+
* the caller.
|
|
170
|
+
*/
|
|
171
|
+
async function validate(token: string, env: unknown, audiences: string[]): Promise<TokenPayload | null> {
|
|
183
172
|
if (bindingName) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
173
|
+
// The unverified `aud` only picks which audience to ask about (one call instead of one per origin); the auth
|
|
174
|
+
// service still checks it. Tokens for any other audience are refused without a call.
|
|
175
|
+
const audience = peekTokenPayload(token)?.aud;
|
|
176
|
+
if (audience === undefined || !audiences.includes(audience)) return null;
|
|
177
|
+
return verifyTokenRemote(getBinding(env, bindingName), token, audience);
|
|
187
178
|
}
|
|
188
|
-
|
|
179
|
+
const publicKeys = await getPublicKeys(authUrl, peekTokenKid(token));
|
|
180
|
+
const local = await validateToken({ publicKeys, maxAgeSeconds: maxAge }, token);
|
|
181
|
+
if (!local || local.aud === undefined || !audiences.includes(local.aud)) return null;
|
|
182
|
+
if (revalidateSeconds <= 0) return local;
|
|
183
|
+
return revalidateToken(publicFetcher(authUrl), token, local, local.aud, revalidateSeconds);
|
|
189
184
|
}
|
|
190
185
|
|
|
191
186
|
return async (c, next) => {
|
|
192
187
|
const url = new URL(c.req.url);
|
|
188
|
+
const scopeOrigin = options?.siteOrigin ?? url.origin;
|
|
189
|
+
const site = { audiences: [url.origin, scopeOrigin], scopeOrigin };
|
|
193
190
|
|
|
194
191
|
// Public paths bypass auth
|
|
195
192
|
if (isPublicPath(url.pathname, publicPaths)) {
|
|
@@ -197,63 +194,52 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
|
|
|
197
194
|
}
|
|
198
195
|
|
|
199
196
|
// Step 1: Handle ?code= callback (login redirect landing)
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return c.text("Invalid token", 401);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
url.searchParams.delete("code");
|
|
212
|
-
return new Response(null, {
|
|
213
|
-
status: 302,
|
|
214
|
-
headers: {
|
|
215
|
-
Location: url.toString(),
|
|
216
|
-
"Set-Cookie": makeSessionCookie(cookieConf, token),
|
|
217
|
-
},
|
|
218
|
-
});
|
|
219
|
-
}
|
|
197
|
+
const callback = await handleLoginCallback(c.req.raw, {
|
|
198
|
+
auth: authService(c.env),
|
|
199
|
+
authUrl,
|
|
200
|
+
cookieConfig: cookieConf,
|
|
201
|
+
validate: (token) => validate(token, c.env, site.audiences),
|
|
202
|
+
scopeOrigin: site.scopeOrigin,
|
|
203
|
+
});
|
|
204
|
+
if (callback) return callback;
|
|
220
205
|
|
|
221
206
|
// Step 2: Check session cookie
|
|
222
207
|
const cookieHeader = c.req.header("Cookie");
|
|
223
208
|
if (cookieHeader) {
|
|
224
209
|
const token = extractCookie(cookieHeader, cookieName);
|
|
225
210
|
if (token) {
|
|
226
|
-
const payload = await validate(token, c.env
|
|
211
|
+
const payload = await validate(token, c.env, site.audiences);
|
|
227
212
|
if (payload) {
|
|
213
|
+
if (!hasSiteAccessFor(payload, site)) {
|
|
214
|
+
return c.text("You don't have access to this site", 403);
|
|
215
|
+
}
|
|
228
216
|
c.set("user", payload);
|
|
229
217
|
return next();
|
|
230
218
|
}
|
|
231
219
|
}
|
|
232
220
|
}
|
|
233
221
|
|
|
234
|
-
// Step
|
|
222
|
+
// Step 3: Check Authorization header
|
|
235
223
|
const authHeader = c.req.header("Authorization");
|
|
236
224
|
if (authHeader?.startsWith("Bearer ")) {
|
|
237
225
|
const token = authHeader.substring(7);
|
|
238
|
-
const payload = await validate(token, c.env
|
|
239
|
-
if (payload) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
return c.json({ error: "Invalid token" }, 401);
|
|
226
|
+
const payload = await validate(token, c.env, site.audiences);
|
|
227
|
+
if (!payload) return c.json({ error: "Invalid token" }, 401);
|
|
228
|
+
if (!hasSiteAccessFor(payload, site)) return c.json({ error: "Forbidden" }, 403);
|
|
229
|
+
c.set("user", payload);
|
|
230
|
+
return next();
|
|
244
231
|
}
|
|
245
232
|
|
|
246
|
-
// Step
|
|
247
|
-
|
|
248
|
-
loginUrl.searchParams.set("redirect", c.req.url);
|
|
249
|
-
loginUrl.searchParams.set("response_type", "code");
|
|
250
|
-
return c.redirect(loginUrl.toString(), 302);
|
|
233
|
+
// Step 4: Redirect to login
|
|
234
|
+
return redirectToLogin(c.req.url, authUrl);
|
|
251
235
|
};
|
|
252
236
|
}
|
|
253
237
|
|
|
254
|
-
/**
|
|
238
|
+
/** Get the authenticated user from context. Throws if called on a route that `auth()` doesn't cover. */
|
|
255
239
|
export function getUser(c: Context): TokenPayload {
|
|
256
|
-
|
|
240
|
+
const user: TokenPayload | undefined = c.get("user");
|
|
241
|
+
if (!user) throw new Error("getUser() called on a route that is not behind the auth() middleware");
|
|
242
|
+
return user;
|
|
257
243
|
}
|
|
258
244
|
|
|
259
245
|
export type { TokenPayload } from "./types.ts";
|
package/src/index.ts
CHANGED
|
@@ -1,29 +1,37 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
RateLimitEntry,
|
|
5
|
-
RateLimitStore,
|
|
6
|
-
RateLimitConfig,
|
|
7
|
-
TokenPayload,
|
|
8
|
-
SigningConfig,
|
|
9
|
-
VerifyConfig,
|
|
10
|
-
PublicKeyEntry,
|
|
11
|
-
CookieConfig,
|
|
12
|
-
} from "./types.ts";
|
|
13
|
-
|
|
14
|
-
export { hashPassword, verifyPassword } from "./password.ts";
|
|
15
|
-
export { createToken, validateToken, generateKeyPair, importPrivateKey, importPublicKey } from "./token.ts";
|
|
16
|
-
export type { CreateTokenInput } from "./token.ts";
|
|
17
|
-
export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
|
|
18
|
-
export { checkRateLimit, recordFailedAttempt, clearRateLimit } from "./rate-limit.ts";
|
|
1
|
+
export { extractCookie, makeClearCookie, makeSessionCookie } from "./cookie.ts";
|
|
2
|
+
export { checkBearerToken, getAuthUser, getClientIP, redirectToLogin } from "./middleware.ts";
|
|
3
|
+
export { hashPassword, MAX_PASSWORD_LENGTH, needsRehash, verifyPassword } from "./password.ts";
|
|
19
4
|
export type { RateLimitResult } from "./rate-limit.ts";
|
|
20
|
-
export {
|
|
21
|
-
export {
|
|
5
|
+
export { checkRateLimit, clearRateLimit, consumeRateLimit, recordFailedAttempt } from "./rate-limit.ts";
|
|
6
|
+
export { hasSiteAccess } from "./remote.ts";
|
|
7
|
+
export type { CreateTokenInput } from "./token.ts";
|
|
8
|
+
export {
|
|
9
|
+
createToken,
|
|
10
|
+
generateKeyPair,
|
|
11
|
+
importPrivateKey,
|
|
12
|
+
importPublicKey,
|
|
13
|
+
isTokenPayload,
|
|
14
|
+
validateToken,
|
|
15
|
+
} from "./token.ts";
|
|
22
16
|
export {
|
|
23
|
-
generateTotpSecret,
|
|
24
|
-
getTotpUri,
|
|
25
|
-
verifyTotpCode,
|
|
26
17
|
base32Encode,
|
|
27
18
|
generateBackupCodes,
|
|
19
|
+
generateTotpSecret,
|
|
20
|
+
getTotpUri,
|
|
28
21
|
hashBackupCode,
|
|
22
|
+
verifyTotpCode,
|
|
23
|
+
verifyTotpStep,
|
|
29
24
|
} from "./totp.ts";
|
|
25
|
+
export type {
|
|
26
|
+
CookieConfig,
|
|
27
|
+
PublicKeyEntry,
|
|
28
|
+
RateLimitConfig,
|
|
29
|
+
RateLimitEntry,
|
|
30
|
+
RateLimitStore,
|
|
31
|
+
SigningConfig,
|
|
32
|
+
TokenPayload,
|
|
33
|
+
User,
|
|
34
|
+
UserStore,
|
|
35
|
+
VerifyConfig,
|
|
36
|
+
} from "./types.ts";
|
|
37
|
+
export { changePassword, createUser, isValidEmail, verifyUserCredentials } from "./users.ts";
|
package/src/middleware.ts
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
import { extractCookie
|
|
1
|
+
import { extractCookie } from "./cookie.ts";
|
|
2
|
+
import { DEFAULT_AUTH_URL, handleLoginCallback, revalidateToken } from "./flow.ts";
|
|
3
|
+
import { hasSiteAccessFor, publicFetcher } from "./remote.ts";
|
|
2
4
|
import { validateToken } from "./token.ts";
|
|
3
5
|
import type { CookieConfig, TokenPayload, VerifyConfig } from "./types.ts";
|
|
4
6
|
|
|
7
|
+
export { redirectToLogin } from "./flow.ts";
|
|
8
|
+
|
|
5
9
|
export interface AuthCodeExchangeConfig {
|
|
6
10
|
authUrl: string;
|
|
7
11
|
}
|
|
8
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Best-effort client IP. Prefers Cloudflare's `cf-connecting-ip`, which clients cannot spoof when the request
|
|
15
|
+
* arrives through Cloudflare. `x-forwarded-for` is only a fallback and is attacker-controlled off Cloudflare.
|
|
16
|
+
*/
|
|
9
17
|
export function getClientIP(request: Request): string {
|
|
10
18
|
return (
|
|
11
19
|
request.headers.get("cf-connecting-ip") ??
|
|
@@ -14,15 +22,29 @@ export function getClientIP(request: Request): string {
|
|
|
14
22
|
);
|
|
15
23
|
}
|
|
16
24
|
|
|
25
|
+
/** Constant-time check that the request carries `Authorization: Bearer <expectedToken>`. */
|
|
17
26
|
export function checkBearerToken(request: Request, expectedToken: string): boolean {
|
|
18
|
-
const header = request.headers.get("Authorization");
|
|
19
|
-
|
|
27
|
+
const header = request.headers.get("Authorization") ?? "";
|
|
28
|
+
const expected = `Bearer ${expectedToken}`;
|
|
29
|
+
const a = new TextEncoder().encode(header);
|
|
30
|
+
const b = new TextEncoder().encode(expected);
|
|
31
|
+
let diff = a.length ^ b.length;
|
|
32
|
+
for (let i = 0; i < b.length; i++) {
|
|
33
|
+
diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
|
|
34
|
+
}
|
|
35
|
+
return diff === 0;
|
|
20
36
|
}
|
|
21
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Get the authenticated user from the session cookie, validated locally against `verifyConfig` (and confirmed with
|
|
40
|
+
* the auth service when `verifyConfig.revalidate` is set). Returns null if not authenticated, or if the token wasn't
|
|
41
|
+
* issued to — or the user doesn't have access to — `siteOrigin` (defaults to the request's origin).
|
|
42
|
+
*/
|
|
22
43
|
export async function getAuthUser(
|
|
23
44
|
request: Request,
|
|
24
45
|
verifyConfig: VerifyConfig,
|
|
25
46
|
cookieConfig: CookieConfig,
|
|
47
|
+
siteOrigin?: string,
|
|
26
48
|
): Promise<TokenPayload | null> {
|
|
27
49
|
const cookieHeader = request.headers.get("Cookie");
|
|
28
50
|
if (!cookieHeader) return null;
|
|
@@ -30,36 +52,29 @@ export async function getAuthUser(
|
|
|
30
52
|
const token = extractCookie(cookieHeader, cookieConfig.name);
|
|
31
53
|
if (!token) return null;
|
|
32
54
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
loginUrl.searchParams.set("redirect", currentUrl);
|
|
39
|
-
loginUrl.searchParams.set("response_type", "code");
|
|
40
|
-
return Response.redirect(loginUrl.toString(), 302);
|
|
41
|
-
}
|
|
55
|
+
const audience = new URL(request.url).origin;
|
|
56
|
+
const scopeOrigin = siteOrigin ?? audience;
|
|
57
|
+
const site = { audiences: [audience, scopeOrigin], scopeOrigin };
|
|
58
|
+
const payload = await validateToken(verifyConfig, token);
|
|
59
|
+
if (!payload || !hasSiteAccessFor(payload, site)) return null;
|
|
42
60
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (!res.ok) return null;
|
|
54
|
-
const data = (await res.json()) as { token?: string };
|
|
55
|
-
return typeof data.token === "string" ? data.token : null;
|
|
61
|
+
const { revalidate } = verifyConfig;
|
|
62
|
+
if (!revalidate) return payload;
|
|
63
|
+
const current = await revalidateToken(
|
|
64
|
+
publicFetcher(revalidate.authUrl),
|
|
65
|
+
token,
|
|
66
|
+
payload,
|
|
67
|
+
payload.aud ?? audience,
|
|
68
|
+
revalidate.intervalSeconds,
|
|
69
|
+
);
|
|
70
|
+
return current && hasSiteAccessFor(current, site) ? current : null;
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
/**
|
|
59
74
|
* Handle the auth callback on consuming sites.
|
|
60
|
-
* If the request URL has a `?code=` param, exchanges it for a token,
|
|
61
|
-
* sets a session cookie, and redirects to the same URL without the
|
|
62
|
-
* Returns null if there's no callback param.
|
|
75
|
+
* If the request URL has a `?code=` param (and the login was started by this browser), exchanges it for a token,
|
|
76
|
+
* sets a session cookie, and redirects to the same URL without the callback params.
|
|
77
|
+
* Returns 403 if the user doesn't have access to this site, or null if there's no callback param.
|
|
63
78
|
*/
|
|
64
79
|
export async function handleCallback(
|
|
65
80
|
request: Request,
|
|
@@ -67,26 +82,11 @@ export async function handleCallback(
|
|
|
67
82
|
cookieConfig: CookieConfig,
|
|
68
83
|
exchangeConfig?: AuthCodeExchangeConfig,
|
|
69
84
|
): Promise<Response | null> {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const payload = await validateToken(verifyConfig, token);
|
|
79
|
-
if (!payload) return redirectToLogin(url.origin);
|
|
80
|
-
|
|
81
|
-
url.searchParams.delete("code");
|
|
82
|
-
return new Response(null, {
|
|
83
|
-
status: 302,
|
|
84
|
-
headers: {
|
|
85
|
-
Location: url.toString(),
|
|
86
|
-
"Set-Cookie": makeSessionCookie(cookieConfig, token),
|
|
87
|
-
},
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return null;
|
|
85
|
+
const authUrl = exchangeConfig?.authUrl ?? DEFAULT_AUTH_URL;
|
|
86
|
+
return handleLoginCallback(request, {
|
|
87
|
+
auth: publicFetcher(authUrl),
|
|
88
|
+
authUrl,
|
|
89
|
+
cookieConfig,
|
|
90
|
+
validate: (token) => validateToken(verifyConfig, token),
|
|
91
|
+
});
|
|
92
92
|
}
|
package/src/password.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
const SALT_LENGTH = 32;
|
|
2
2
|
const KEY_LENGTH = 64;
|
|
3
|
-
|
|
3
|
+
// 100k is the maximum PBKDF2 iteration count the Cloudflare Workers runtime accepts in one call. Higher work factors
|
|
4
|
+
// chain calls: each stage's output is the next stage's key material (same salt), so a guess still costs every stage.
|
|
5
|
+
const STAGE_ITERATIONS = 100_000;
|
|
6
|
+
/** OWASP's recommendation for PBKDF2-HMAC-SHA256. New hashes use this; older ones are upgraded on the next login. */
|
|
7
|
+
const ITERATIONS = 600_000;
|
|
8
|
+
const MAX_ITERATIONS = 10_000_000;
|
|
9
|
+
/** Upper bound enforced wherever a password is *set*. Verification accepts any length so no stored password is locked out. */
|
|
10
|
+
export const MAX_PASSWORD_LENGTH = 1024;
|
|
11
|
+
const HEX_REGEX = /^(?:[0-9a-f]{2})+$/;
|
|
12
|
+
const PREFIX = "pbkdf2-sha256";
|
|
4
13
|
|
|
5
14
|
function toHex(buffer: ArrayBuffer): string {
|
|
6
15
|
return Array.from(new Uint8Array(buffer))
|
|
@@ -16,44 +25,75 @@ function fromHex(hex: string): Uint8Array<ArrayBuffer> {
|
|
|
16
25
|
return bytes;
|
|
17
26
|
}
|
|
18
27
|
|
|
19
|
-
async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
salt,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer>, iterations: number): Promise<ArrayBuffer> {
|
|
29
|
+
let material: BufferSource = new TextEncoder().encode(password);
|
|
30
|
+
let derived = new ArrayBuffer(0);
|
|
31
|
+
for (let done = 0; done < iterations; done += STAGE_ITERATIONS) {
|
|
32
|
+
const keyMaterial = await crypto.subtle.importKey("raw", material, "PBKDF2", false, ["deriveBits"]);
|
|
33
|
+
derived = await crypto.subtle.deriveBits(
|
|
34
|
+
{ name: "PBKDF2", salt, iterations: STAGE_ITERATIONS, hash: "SHA-256" },
|
|
35
|
+
keyMaterial,
|
|
36
|
+
KEY_LENGTH * 8,
|
|
37
|
+
);
|
|
38
|
+
material = new Uint8Array(derived);
|
|
39
|
+
}
|
|
40
|
+
return derived;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ParsedHash {
|
|
44
|
+
iterations: number;
|
|
45
|
+
salt: Uint8Array<ArrayBuffer>;
|
|
46
|
+
key: Uint8Array<ArrayBuffer>;
|
|
32
47
|
}
|
|
33
48
|
|
|
34
|
-
/**
|
|
49
|
+
/** `pbkdf2-sha256$<iterations>$<salt>$<key>`, or the original `<salt>:<key>` (one 100k stage). */
|
|
50
|
+
function parseHash(storedHash: string): ParsedHash | null {
|
|
51
|
+
let iterations = STAGE_ITERATIONS;
|
|
52
|
+
let saltHex: string;
|
|
53
|
+
let keyHex: string;
|
|
54
|
+
if (storedHash.startsWith(`${PREFIX}$`)) {
|
|
55
|
+
const [, rawIterations = "", salt = "", key = ""] = storedHash.split("$");
|
|
56
|
+
if (!/^[1-9]\d*$/.test(rawIterations)) return null;
|
|
57
|
+
iterations = Number(rawIterations);
|
|
58
|
+
if (iterations % STAGE_ITERATIONS !== 0 || iterations > MAX_ITERATIONS) return null;
|
|
59
|
+
saltHex = salt;
|
|
60
|
+
keyHex = key;
|
|
61
|
+
} else {
|
|
62
|
+
const separatorIndex = storedHash.indexOf(":");
|
|
63
|
+
if (separatorIndex === -1) return null;
|
|
64
|
+
saltHex = storedHash.substring(0, separatorIndex);
|
|
65
|
+
keyHex = storedHash.substring(separatorIndex + 1);
|
|
66
|
+
}
|
|
67
|
+
if (!HEX_REGEX.test(saltHex) || !HEX_REGEX.test(keyHex)) return null;
|
|
68
|
+
return { iterations, salt: fromHex(saltHex), key: fromHex(keyHex) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Hash a password with PBKDF2-SHA256 (600k iterations, 32-byte salt, 64-byte key). Returns
|
|
73
|
+
* `pbkdf2-sha256$600000$hex(salt)$hex(key)`.
|
|
74
|
+
*/
|
|
35
75
|
export async function hashPassword(password: string): Promise<string> {
|
|
36
76
|
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
37
|
-
const derived = await deriveKey(password, salt);
|
|
38
|
-
return `${toHex(salt.buffer)}
|
|
77
|
+
const derived = await deriveKey(password, salt, ITERATIONS);
|
|
78
|
+
return `${PREFIX}$${ITERATIONS}$${toHex(salt.buffer)}$${toHex(derived)}`;
|
|
39
79
|
}
|
|
40
80
|
|
|
41
|
-
/** Verify a password against
|
|
81
|
+
/** Verify a password against any hash `hashPassword` produces now or produced before. Uses constant-time comparison. */
|
|
42
82
|
export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
const saltHex = storedHash.substring(0, separatorIndex);
|
|
47
|
-
const keyHex = storedHash.substring(separatorIndex + 1);
|
|
48
|
-
const salt = fromHex(saltHex);
|
|
49
|
-
const expectedKey = fromHex(keyHex);
|
|
50
|
-
const derived = new Uint8Array(await deriveKey(password, salt));
|
|
83
|
+
const parsed = parseHash(storedHash);
|
|
84
|
+
if (!parsed) return false;
|
|
85
|
+
const derived = new Uint8Array(await deriveKey(password, parsed.salt, parsed.iterations));
|
|
51
86
|
|
|
52
|
-
if (derived.length !==
|
|
87
|
+
if (derived.length !== parsed.key.length) return false;
|
|
53
88
|
|
|
54
89
|
let diff = 0;
|
|
55
90
|
for (let i = 0; i < derived.length; i++) {
|
|
56
|
-
diff |= (derived[i] ?? 0) ^ (
|
|
91
|
+
diff |= (derived[i] ?? 0) ^ (parsed.key[i] ?? 0);
|
|
57
92
|
}
|
|
58
93
|
return diff === 0;
|
|
59
94
|
}
|
|
95
|
+
|
|
96
|
+
/** Whether a stored hash is weaker than what `hashPassword` produces now, so it should be replaced after a login. */
|
|
97
|
+
export function needsRehash(storedHash: string): boolean {
|
|
98
|
+
return !storedHash.startsWith(`${PREFIX}$`) || (parseHash(storedHash)?.iterations ?? 0) < ITERATIONS;
|
|
99
|
+
}
|