@uln/auth 0.4.2 → 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/rate-limit.ts
CHANGED
|
@@ -5,6 +5,39 @@ export interface RateLimitResult {
|
|
|
5
5
|
remainingSeconds?: number;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Count an attempt against `key` and report whether the caller is now over the limit. The attempt is recorded
|
|
10
|
+
* *before* the decision, so concurrent requests can't all pass a check that ran before any of them was counted.
|
|
11
|
+
* Clear the key on success. Race-free only when the store implements `increment`.
|
|
12
|
+
*/
|
|
13
|
+
export async function consumeRateLimit(
|
|
14
|
+
store: RateLimitStore,
|
|
15
|
+
key: string,
|
|
16
|
+
config: RateLimitConfig,
|
|
17
|
+
): Promise<RateLimitResult> {
|
|
18
|
+
const entry = store.increment
|
|
19
|
+
? await store.increment(key, config.windowSeconds)
|
|
20
|
+
: await incrementViaGetSet(store, key, config);
|
|
21
|
+
if (entry.attempts <= config.maxAttempts) return { blocked: false };
|
|
22
|
+
const remaining = config.windowSeconds - Math.floor((Date.now() - entry.firstAttempt) / 1000);
|
|
23
|
+
return { blocked: true, remainingSeconds: Math.max(remaining, 1) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function incrementViaGetSet(store: RateLimitStore, key: string, config: RateLimitConfig) {
|
|
27
|
+
const existing = await store.get(key);
|
|
28
|
+
const windowOver = existing !== null && Date.now() - existing.firstAttempt >= config.windowSeconds * 1000;
|
|
29
|
+
const entry =
|
|
30
|
+
existing && !windowOver
|
|
31
|
+
? { attempts: existing.attempts + 1, firstAttempt: existing.firstAttempt }
|
|
32
|
+
: { attempts: 1, firstAttempt: Date.now() };
|
|
33
|
+
await store.set(key, entry, config.windowSeconds);
|
|
34
|
+
return entry;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @deprecated Check-then-record is racy: concurrent requests all pass the check before any is recorded. Use
|
|
39
|
+
* `consumeRateLimit`.
|
|
40
|
+
*/
|
|
8
41
|
export async function checkRateLimit(
|
|
9
42
|
store: RateLimitStore,
|
|
10
43
|
key: string,
|
|
@@ -24,7 +57,12 @@ export async function checkRateLimit(
|
|
|
24
57
|
return { blocked: false };
|
|
25
58
|
}
|
|
26
59
|
|
|
60
|
+
/** @deprecated Pair of `checkRateLimit`; use `consumeRateLimit`. */
|
|
27
61
|
export async function recordFailedAttempt(store: RateLimitStore, key: string, config: RateLimitConfig): Promise<void> {
|
|
62
|
+
if (store.increment) {
|
|
63
|
+
await store.increment(key, config.windowSeconds);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
28
66
|
const entry = await store.get(key);
|
|
29
67
|
if (entry) {
|
|
30
68
|
await store.set(key, { attempts: entry.attempts + 1, firstAttempt: entry.firstAttempt }, config.windowSeconds);
|
package/src/remote.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { importPublicKey, isTokenPayload } from "./token.ts";
|
|
2
|
+
import type { PublicKeyEntry, TokenPayload } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** Anything with a Fetch-compatible `fetch` — the global `fetch` wrapped, or a Cloudflare service binding. */
|
|
5
|
+
export interface FetchLike {
|
|
6
|
+
fetch(input: string, init?: RequestInit): Promise<Response>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Calls the auth service over the public internet. */
|
|
10
|
+
export function publicFetcher(authUrl: string): FetchLike {
|
|
11
|
+
return { fetch: (path, init) => fetch(new URL(path, authUrl), init) };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Calls the auth service through a Cloudflare service binding. The hostname is ignored by bindings. */
|
|
15
|
+
export function bindingFetcher(binding: { fetch(input: string, init?: RequestInit): Promise<Response> }): FetchLike {
|
|
16
|
+
return { fetch: (path, init) => binding.fetch(new URL(path, "https://auth").toString(), init) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
20
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toOrigin(value: string): string | null {
|
|
24
|
+
try {
|
|
25
|
+
return new URL(value).origin;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether a validated token grants access to `origin`. The token's `aud` must be this origin — a token issued to
|
|
33
|
+
* another site (or to the auth service itself) is never accepted, even for admins. Then admins can access every
|
|
34
|
+
* site; everyone else needs the origin in their `scopes` (loopback origins are exempt so local development works).
|
|
35
|
+
*
|
|
36
|
+
* A valid signature alone only proves the user has *an* account — every consuming site must also run this check.
|
|
37
|
+
*/
|
|
38
|
+
export function hasSiteAccess(payload: TokenPayload, origin: string): boolean {
|
|
39
|
+
return hasSiteAccessFor(payload, { audiences: [origin], scopeOrigin: origin });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `hasSiteAccess` for a site that answers on more than one origin — e.g. a Worker whose request URL origin (where the
|
|
44
|
+
* login callback landed, and so the token's audience) differs from the origin registered for scopes.
|
|
45
|
+
*/
|
|
46
|
+
export function hasSiteAccessFor(payload: TokenPayload, site: { audiences: string[]; scopeOrigin: string }): boolean {
|
|
47
|
+
const scopeOrigin = toOrigin(site.scopeOrigin);
|
|
48
|
+
if (!scopeOrigin || payload.aud === undefined) return false;
|
|
49
|
+
if (!site.audiences.some((audience) => toOrigin(audience) === payload.aud)) return false;
|
|
50
|
+
if (payload.admin) return true;
|
|
51
|
+
if (isLoopbackHostname(new URL(scopeOrigin).hostname)) return true;
|
|
52
|
+
return payload.scopes.includes(scopeOrigin);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readJsonObject(res: Response): Promise<object | null> {
|
|
56
|
+
try {
|
|
57
|
+
const body: unknown = await res.json();
|
|
58
|
+
return typeof body === "object" && body !== null ? body : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* POST /api/exchange-code — trade a one-time `?code=` for a signed token bound to `redirectOrigin`. `codeVerifier` is
|
|
66
|
+
* the PKCE verifier whose challenge went out with the login (required for codes issued with one).
|
|
67
|
+
*/
|
|
68
|
+
export async function exchangeAuthCode(
|
|
69
|
+
auth: FetchLike,
|
|
70
|
+
code: string,
|
|
71
|
+
redirectOrigin: string,
|
|
72
|
+
codeVerifier?: string,
|
|
73
|
+
): Promise<string | null> {
|
|
74
|
+
const res = await auth.fetch("/api/exchange-code", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "Content-Type": "application/json" },
|
|
77
|
+
body: JSON.stringify(
|
|
78
|
+
codeVerifier === undefined ? { code, redirectOrigin } : { code, redirectOrigin, codeVerifier },
|
|
79
|
+
),
|
|
80
|
+
});
|
|
81
|
+
if (!res.ok) return null;
|
|
82
|
+
const body = await readJsonObject(res);
|
|
83
|
+
if (!body || !("token" in body) || typeof body.token !== "string") return null;
|
|
84
|
+
return body.token;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type RemoteCheck =
|
|
88
|
+
| { status: "valid"; payload: TokenPayload }
|
|
89
|
+
| { status: "invalid" }
|
|
90
|
+
/** Network error or 5xx — the auth service didn't give an answer. */
|
|
91
|
+
| { status: "unavailable" };
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* POST /api/verify-token — validate a token with the auth service itself. Unlike local JWKS validation this also
|
|
95
|
+
* rejects tokens for deleted users or revoked sessions, and returns the user's *current* admin/scopes/flags. With an
|
|
96
|
+
* `audience`, the auth service also rejects tokens issued to any other origin.
|
|
97
|
+
*/
|
|
98
|
+
export async function checkTokenRemote(auth: FetchLike, token: string, audience?: string): Promise<RemoteCheck> {
|
|
99
|
+
let res: Response;
|
|
100
|
+
try {
|
|
101
|
+
res = await auth.fetch("/api/verify-token", {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: { "Content-Type": "application/json" },
|
|
104
|
+
body: JSON.stringify(audience === undefined ? { token } : { token, audience }),
|
|
105
|
+
});
|
|
106
|
+
} catch {
|
|
107
|
+
return { status: "unavailable" };
|
|
108
|
+
}
|
|
109
|
+
if (res.status >= 500) return { status: "unavailable" };
|
|
110
|
+
if (!res.ok) return { status: "invalid" };
|
|
111
|
+
const body = await readJsonObject(res);
|
|
112
|
+
if (!body || !("user" in body) || typeof body.user !== "object" || body.user === null) return { status: "invalid" };
|
|
113
|
+
const user = body.user;
|
|
114
|
+
if (!("id" in user)) return { status: "invalid" };
|
|
115
|
+
const candidate = { ...user, sub: user.id, iat: "iat" in user ? user.iat : 0, exp: "exp" in user ? user.exp : 0 };
|
|
116
|
+
if (!isTokenPayload(candidate)) return { status: "invalid" };
|
|
117
|
+
return {
|
|
118
|
+
status: "valid",
|
|
119
|
+
payload: {
|
|
120
|
+
sub: candidate.sub,
|
|
121
|
+
email: candidate.email,
|
|
122
|
+
name: candidate.name,
|
|
123
|
+
admin: candidate.admin,
|
|
124
|
+
scopes: candidate.scopes,
|
|
125
|
+
flags: candidate.flags,
|
|
126
|
+
kid: candidate.kid,
|
|
127
|
+
iat: candidate.iat,
|
|
128
|
+
exp: candidate.exp,
|
|
129
|
+
...(candidate.aud === undefined ? {} : { aud: candidate.aud }),
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `checkTokenRemote`, collapsed to the payload or null (an unreachable auth service counts as invalid). */
|
|
135
|
+
export async function verifyTokenRemote(
|
|
136
|
+
auth: FetchLike,
|
|
137
|
+
token: string,
|
|
138
|
+
audience?: string,
|
|
139
|
+
): Promise<TokenPayload | null> {
|
|
140
|
+
const result = await checkTokenRemote(auth, token, audience);
|
|
141
|
+
return result.status === "valid" ? result.payload : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Fetch and import the auth service's JWKS (`/.well-known/jwk.json`). Keys without a string `kid` are skipped. */
|
|
145
|
+
export async function fetchJwks(auth: FetchLike): Promise<PublicKeyEntry[]> {
|
|
146
|
+
const res = await auth.fetch("/.well-known/jwk.json");
|
|
147
|
+
if (!res.ok) throw new Error(`Failed to fetch JWKS: ${res.status}`);
|
|
148
|
+
const body = await readJsonObject(res);
|
|
149
|
+
if (!body || !("keys" in body) || !Array.isArray(body.keys)) throw new Error("Malformed JWKS response");
|
|
150
|
+
const entries: PublicKeyEntry[] = [];
|
|
151
|
+
for (const jwk of body.keys) {
|
|
152
|
+
if (typeof jwk !== "object" || jwk === null || !("kid" in jwk) || typeof jwk.kid !== "string") continue;
|
|
153
|
+
if (!("kty" in jwk) || typeof jwk.kty !== "string") continue;
|
|
154
|
+
const { kid, ...keyData } = jwk;
|
|
155
|
+
entries.push({ kid, key: await importPublicKey({ ...keyData, kty: jwk.kty }) });
|
|
156
|
+
}
|
|
157
|
+
return entries;
|
|
158
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -1,51 +1,38 @@
|
|
|
1
|
-
import { extractCookie
|
|
1
|
+
import { extractCookie } from "./cookie.ts";
|
|
2
|
+
import { DEFAULT_AUTH_URL, handleLoginCallback } from "./flow.ts";
|
|
3
|
+
import { bindingFetcher, hasSiteAccessFor, verifyTokenRemote } from "./remote.ts";
|
|
4
|
+
import { peekTokenPayload } from "./token.ts";
|
|
2
5
|
import type { CookieConfig, TokenPayload } from "./types.ts";
|
|
3
|
-
|
|
4
|
-
export
|
|
6
|
+
|
|
7
|
+
export { makeClearCookie, makeSessionCookie } from "./cookie.ts";
|
|
8
|
+
export { redirectToLogin } from "./flow.ts";
|
|
9
|
+
export { hasSiteAccess } from "./remote.ts";
|
|
10
|
+
export type { CookieConfig, TokenPayload } from "./types.ts";
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
13
|
* Validate a raw token string via the auth service binding.
|
|
8
|
-
* Returns the token payload or null if invalid.
|
|
14
|
+
* Returns the token payload (with the user's current admin/scopes/flags) or null if invalid or revoked. Pass the
|
|
15
|
+
* site's origin as `audience` so tokens issued to other origins are rejected too.
|
|
16
|
+
* This does not check site access — use `getAuthUser` or `hasSiteAccess` for that.
|
|
9
17
|
*/
|
|
10
|
-
export async function verifyToken(
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (!res.ok) return null;
|
|
17
|
-
const data = (await res.json()) as {
|
|
18
|
-
user: {
|
|
19
|
-
id: string;
|
|
20
|
-
email: string;
|
|
21
|
-
name: string;
|
|
22
|
-
admin: boolean;
|
|
23
|
-
scopes: string[];
|
|
24
|
-
flags: Record<string, string[]>;
|
|
25
|
-
kid: string;
|
|
26
|
-
};
|
|
27
|
-
};
|
|
28
|
-
return {
|
|
29
|
-
sub: data.user.id,
|
|
30
|
-
email: data.user.email,
|
|
31
|
-
name: data.user.name,
|
|
32
|
-
admin: data.user.admin,
|
|
33
|
-
scopes: data.user.scopes,
|
|
34
|
-
flags: data.user.flags,
|
|
35
|
-
kid: data.user.kid,
|
|
36
|
-
iat: 0,
|
|
37
|
-
exp: 0,
|
|
38
|
-
};
|
|
18
|
+
export async function verifyToken(
|
|
19
|
+
authService: Fetcher,
|
|
20
|
+
token: string,
|
|
21
|
+
audience?: string,
|
|
22
|
+
): Promise<TokenPayload | null> {
|
|
23
|
+
return verifyTokenRemote(bindingFetcher(authService), token, audience);
|
|
39
24
|
}
|
|
40
25
|
|
|
41
26
|
/**
|
|
42
27
|
* Get the authenticated user from the session cookie via the auth service binding.
|
|
43
|
-
* Returns the token
|
|
28
|
+
* Returns null if not authenticated, or if the token wasn't issued to — or the user doesn't have access to —
|
|
29
|
+
* `siteOrigin` (defaults to the request's origin).
|
|
44
30
|
*/
|
|
45
31
|
export async function getAuthUser(
|
|
46
32
|
request: Request,
|
|
47
33
|
authService: Fetcher,
|
|
48
34
|
cookieConfig: CookieConfig,
|
|
35
|
+
siteOrigin?: string,
|
|
49
36
|
): Promise<TokenPayload | null> {
|
|
50
37
|
const cookieHeader = request.headers.get("Cookie");
|
|
51
38
|
if (!cookieHeader) return null;
|
|
@@ -53,60 +40,33 @@ export async function getAuthUser(
|
|
|
53
40
|
const token = extractCookie(cookieHeader, cookieConfig.name);
|
|
54
41
|
if (!token) return null;
|
|
55
42
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
loginUrl.searchParams.set("response_type", "code");
|
|
67
|
-
return Response.redirect(loginUrl.toString(), 302);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async function exchangeCode(authService: Fetcher, code: string, redirectOrigin: string): Promise<string | null> {
|
|
71
|
-
const res = await authService.fetch("https://auth/api/exchange-code", {
|
|
72
|
-
method: "POST",
|
|
73
|
-
headers: { "Content-Type": "application/json" },
|
|
74
|
-
body: JSON.stringify({ code, redirectOrigin }),
|
|
75
|
-
});
|
|
76
|
-
if (!res.ok) return null;
|
|
77
|
-
const data = (await res.json()) as { token?: string };
|
|
78
|
-
return typeof data.token === "string" ? data.token : null;
|
|
43
|
+
// With a separate `siteOrigin`, either origin may be the token's audience. The unverified `aud` only picks which one
|
|
44
|
+
// to ask the auth service about (which checks it), so this is one call, and none for a token issued elsewhere.
|
|
45
|
+
const requestOrigin = new URL(request.url).origin;
|
|
46
|
+
const scopeOrigin = siteOrigin ?? requestOrigin;
|
|
47
|
+
const audiences = [requestOrigin, scopeOrigin];
|
|
48
|
+
const audience = peekTokenPayload(token)?.aud;
|
|
49
|
+
if (audience === undefined || !audiences.includes(audience)) return null;
|
|
50
|
+
const payload = await verifyToken(authService, token, audience);
|
|
51
|
+
if (!payload || !hasSiteAccessFor(payload, { audiences, scopeOrigin })) return null;
|
|
52
|
+
return payload;
|
|
79
53
|
}
|
|
80
54
|
|
|
81
55
|
/**
|
|
82
56
|
* Handle an auth callback on a consuming Worker using the auth service binding.
|
|
83
|
-
*
|
|
84
|
-
* Returns null if the request has no
|
|
57
|
+
* `?code=` callbacks started by this browser are exchanged for signed tokens before setting the local cookie.
|
|
58
|
+
* Returns 403 if the user doesn't have access to this site, or null if the request has no callback parameters.
|
|
85
59
|
*/
|
|
86
60
|
export async function handleCallback(
|
|
87
61
|
request: Request,
|
|
88
62
|
authService: Fetcher,
|
|
89
63
|
cookieConfig: CookieConfig,
|
|
90
64
|
): Promise<Response | null> {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const payload = await verifyToken(authService, token);
|
|
99
|
-
if (!payload) return redirectToLogin(url.origin);
|
|
100
|
-
|
|
101
|
-
url.searchParams.delete("code");
|
|
102
|
-
return new Response(null, {
|
|
103
|
-
status: 302,
|
|
104
|
-
headers: {
|
|
105
|
-
Location: url.toString(),
|
|
106
|
-
"Set-Cookie": makeSessionCookie(cookieConfig, token),
|
|
107
|
-
},
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return null;
|
|
65
|
+
const audience = new URL(request.url).origin;
|
|
66
|
+
return handleLoginCallback(request, {
|
|
67
|
+
auth: bindingFetcher(authService),
|
|
68
|
+
authUrl: DEFAULT_AUTH_URL,
|
|
69
|
+
cookieConfig,
|
|
70
|
+
validate: (token) => verifyToken(authService, token, audience),
|
|
71
|
+
});
|
|
112
72
|
}
|
package/src/token.ts
CHANGED
|
@@ -10,7 +10,8 @@ function base64urlEncode(data: Uint8Array): string {
|
|
|
10
10
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
function base64urlDecode(str: string): Uint8Array {
|
|
13
|
+
function base64urlDecode(str: string): Uint8Array<ArrayBuffer> {
|
|
14
|
+
if (!/^[A-Za-z0-9_-]*$/.test(str)) throw new Error("Invalid base64url");
|
|
14
15
|
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
15
16
|
const binary = atob(padded);
|
|
16
17
|
const bytes = new Uint8Array(binary.length);
|
|
@@ -52,11 +53,22 @@ export interface CreateTokenInput {
|
|
|
52
53
|
isAdmin: boolean;
|
|
53
54
|
scopes: string[];
|
|
54
55
|
flags: Record<string, string[]>;
|
|
56
|
+
/** The origin the token is for (see `TokenPayload.aud`). */
|
|
57
|
+
audience?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CreateTokenOptions {
|
|
61
|
+
/** Unix seconds to use as `iat` instead of now (e.g. to land just after a session revocation cut-off). */
|
|
62
|
+
issuedAt?: number;
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
/** Create a signed token: `base64url(JSON payload).base64url(ECDSA-SHA256 signature)`. */
|
|
58
|
-
export async function createToken(
|
|
59
|
-
|
|
66
|
+
export async function createToken(
|
|
67
|
+
config: SigningConfig,
|
|
68
|
+
user: CreateTokenInput,
|
|
69
|
+
options: CreateTokenOptions = {},
|
|
70
|
+
): Promise<string> {
|
|
71
|
+
const now = options.issuedAt ?? Math.floor(Date.now() / 1000);
|
|
60
72
|
const payload: TokenPayload = {
|
|
61
73
|
sub: user.id,
|
|
62
74
|
email: user.email,
|
|
@@ -67,6 +79,7 @@ export async function createToken(config: SigningConfig, user: CreateTokenInput)
|
|
|
67
79
|
kid: config.kid,
|
|
68
80
|
iat: now,
|
|
69
81
|
exp: now + config.maxAgeSeconds,
|
|
82
|
+
...(user.audience === undefined ? {} : { aud: user.audience }),
|
|
70
83
|
};
|
|
71
84
|
const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
|
|
72
85
|
const payloadBytes = encoder.encode(payloadB64);
|
|
@@ -75,28 +88,72 @@ export async function createToken(config: SigningConfig, user: CreateTokenInput)
|
|
|
75
88
|
return `${payloadB64}.${sigB64}`;
|
|
76
89
|
}
|
|
77
90
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
80
|
-
const dotIndex = token.indexOf(".");
|
|
81
|
-
if (dotIndex === -1) return null;
|
|
91
|
+
/** Allowed clock skew between the issuer and a verifier, in seconds. */
|
|
92
|
+
const CLOCK_SKEW_SECONDS = 60;
|
|
82
93
|
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
function isStringArray(value: unknown): value is string[] {
|
|
95
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
96
|
+
}
|
|
85
97
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
function isFlags(value: unknown): value is Record<string, string[]> {
|
|
99
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
100
|
+
return Object.values(value).every(isStringArray);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Structural check for a decoded token payload. Rejects anything whose fields don't match `TokenPayload`. */
|
|
104
|
+
export function isTokenPayload(value: unknown): value is TokenPayload {
|
|
105
|
+
if (typeof value !== "object" || value === null) return false;
|
|
106
|
+
if (
|
|
107
|
+
!("sub" in value) ||
|
|
108
|
+
!("email" in value) ||
|
|
109
|
+
!("name" in value) ||
|
|
110
|
+
!("admin" in value) ||
|
|
111
|
+
!("scopes" in value) ||
|
|
112
|
+
!("flags" in value) ||
|
|
113
|
+
!("kid" in value) ||
|
|
114
|
+
!("iat" in value) ||
|
|
115
|
+
!("exp" in value)
|
|
116
|
+
) {
|
|
117
|
+
return false;
|
|
92
118
|
}
|
|
119
|
+
return (
|
|
120
|
+
typeof value.sub === "string" &&
|
|
121
|
+
typeof value.email === "string" &&
|
|
122
|
+
typeof value.name === "string" &&
|
|
123
|
+
typeof value.admin === "boolean" &&
|
|
124
|
+
isStringArray(value.scopes) &&
|
|
125
|
+
isFlags(value.flags) &&
|
|
126
|
+
typeof value.kid === "string" &&
|
|
127
|
+
Number.isSafeInteger(value.iat) &&
|
|
128
|
+
Number.isSafeInteger(value.exp) &&
|
|
129
|
+
(!("aud" in value) || value.aud === undefined || typeof value.aud === "string")
|
|
130
|
+
);
|
|
131
|
+
}
|
|
93
132
|
|
|
94
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Validate a signed token. Returns the payload if the signature is valid and the token is within its lifetime,
|
|
135
|
+
* or null. Matches `kid` to find the correct public key. Tokens issued in the future (beyond a small clock skew)
|
|
136
|
+
* or older than `config.maxAgeSeconds` are rejected even if `exp` says otherwise.
|
|
137
|
+
*/
|
|
138
|
+
export async function validateToken(config: VerifyConfig, token: string): Promise<TokenPayload | null> {
|
|
139
|
+
const parts = token.split(".");
|
|
140
|
+
if (parts.length !== 2) return null;
|
|
141
|
+
const [payloadB64 = "", sigB64 = ""] = parts;
|
|
142
|
+
if (!payloadB64 || !sigB64) return null;
|
|
143
|
+
|
|
144
|
+
let decoded: unknown;
|
|
145
|
+
try {
|
|
146
|
+
decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
|
|
147
|
+
} catch {
|
|
95
148
|
return null;
|
|
96
149
|
}
|
|
150
|
+
if (!isTokenPayload(decoded)) return null;
|
|
151
|
+
const payload = decoded;
|
|
97
152
|
|
|
98
153
|
const now = Math.floor(Date.now() / 1000);
|
|
99
154
|
if (now > payload.exp) return null;
|
|
155
|
+
if (payload.iat > now + CLOCK_SKEW_SECONDS) return null;
|
|
156
|
+
if (now - payload.iat > config.maxAgeSeconds) return null;
|
|
100
157
|
|
|
101
158
|
const entry = config.publicKeys.find((k) => k.kid === payload.kid);
|
|
102
159
|
if (!entry) return null;
|
|
@@ -112,3 +169,31 @@ export async function validateToken(config: VerifyConfig, token: string): Promis
|
|
|
112
169
|
|
|
113
170
|
return payload;
|
|
114
171
|
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Decode a token's payload **without verifying it** — only to decide which key or account to look up, or which
|
|
175
|
+
* audience to ask about, before the real check. Null unless it is structurally a `TokenPayload`.
|
|
176
|
+
*/
|
|
177
|
+
export function peekTokenPayload(token: string): TokenPayload | null {
|
|
178
|
+
const payloadB64 = token.split(".")[0];
|
|
179
|
+
if (!payloadB64) return null;
|
|
180
|
+
try {
|
|
181
|
+
const decoded: unknown = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
|
|
182
|
+
return isTokenPayload(decoded) ? decoded : null;
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Read the `kid` claim from a token without verifying it. Used to decide whether a JWKS refresh is worthwhile. */
|
|
189
|
+
export function peekTokenKid(token: string): string | null {
|
|
190
|
+
const payloadB64 = token.split(".")[0];
|
|
191
|
+
if (!payloadB64) return null;
|
|
192
|
+
try {
|
|
193
|
+
const decoded: unknown = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
|
|
194
|
+
if (typeof decoded !== "object" || decoded === null || !("kid" in decoded)) return null;
|
|
195
|
+
return typeof decoded.kid === "string" ? decoded.kid : null;
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/totp.ts
CHANGED
|
@@ -111,19 +111,29 @@ export async function currentTotpCode(secret: string, nowMs = Date.now()): Promi
|
|
|
111
111
|
return generateCode(secret, step);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Verify a TOTP code against the secret, allowing ±1 time step for clock drift.
|
|
116
|
+
* Returns the matched time step, or null. Callers should persist the step and reject any code whose step is not
|
|
117
|
+
* strictly greater than the last accepted one — otherwise a code can be replayed for up to 90 seconds.
|
|
118
|
+
*/
|
|
119
|
+
export async function verifyTotpStep(secret: string, code: string, nowMs = Date.now()): Promise<number | null> {
|
|
120
|
+
if (!/^\d{6}$/.test(code)) return null;
|
|
117
121
|
|
|
118
|
-
const
|
|
119
|
-
const currentStep = Math.floor(now / TOTP_PERIOD);
|
|
122
|
+
const currentStep = Math.floor(nowMs / 1000 / TOTP_PERIOD);
|
|
120
123
|
|
|
124
|
+
// Check every step (no early exit) so timing doesn't reveal which window matched.
|
|
125
|
+
let matched: number | null = null;
|
|
121
126
|
for (let offset = -TOTP_WINDOW; offset <= TOTP_WINDOW; offset++) {
|
|
122
|
-
const
|
|
123
|
-
|
|
127
|
+
const step = currentStep + offset;
|
|
128
|
+
const expected = await generateCode(secret, step);
|
|
129
|
+
if (timingSafeEqual(code, expected)) matched = step;
|
|
124
130
|
}
|
|
131
|
+
return matched;
|
|
132
|
+
}
|
|
125
133
|
|
|
126
|
-
|
|
134
|
+
/** Verify a TOTP code against the secret. Allows ±1 time step for clock drift. Does not prevent replay. */
|
|
135
|
+
export async function verifyTotpCode(secret: string, code: string): Promise<boolean> {
|
|
136
|
+
return (await verifyTotpStep(secret, code)) !== null;
|
|
127
137
|
}
|
|
128
138
|
|
|
129
139
|
const BACKUP_CODE_COUNT = 10;
|
|
@@ -134,11 +144,14 @@ const BACKUP_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
|
134
144
|
export function generateBackupCodes(): string[] {
|
|
135
145
|
const codes: string[] = [];
|
|
136
146
|
for (let i = 0; i < BACKUP_CODE_COUNT; i++) {
|
|
137
|
-
const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
|
|
138
|
-
crypto.getRandomValues(bytes);
|
|
139
147
|
let code = "";
|
|
140
|
-
|
|
141
|
-
|
|
148
|
+
while (code.length < BACKUP_CODE_LENGTH) {
|
|
149
|
+
const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
|
|
150
|
+
crypto.getRandomValues(bytes);
|
|
151
|
+
for (const byte of bytes) {
|
|
152
|
+
// Rejection sampling: 252 is the largest multiple of 36 below 256, so every character is equally likely.
|
|
153
|
+
if (byte < 252 && code.length < BACKUP_CODE_LENGTH) code += BACKUP_CODE_CHARS[byte % BACKUP_CODE_CHARS.length];
|
|
154
|
+
}
|
|
142
155
|
}
|
|
143
156
|
codes.push(code);
|
|
144
157
|
}
|
package/src/types.ts
CHANGED
|
@@ -28,6 +28,11 @@ export interface RateLimitStore {
|
|
|
28
28
|
get(key: string): Promise<RateLimitEntry | null>;
|
|
29
29
|
set(key: string, entry: RateLimitEntry, ttlSeconds: number): Promise<void>;
|
|
30
30
|
delete(key: string): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Atomically add one attempt (creating the entry if needed) and return the result. Optional, but stores that
|
|
33
|
+
* implement it are race-free under concurrent requests; otherwise a read-modify-write via get/set is used.
|
|
34
|
+
*/
|
|
35
|
+
increment?(key: string, ttlSeconds: number): Promise<RateLimitEntry>;
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
export interface RateLimitConfig {
|
|
@@ -45,6 +50,12 @@ export interface TokenPayload {
|
|
|
45
50
|
kid: string;
|
|
46
51
|
iat: number;
|
|
47
52
|
exp: number;
|
|
53
|
+
/**
|
|
54
|
+
* Audience: the origin this token was issued to (a site's origin, or the auth service's own origin for its session
|
|
55
|
+
* cookie). A token is only honoured where `aud` matches. Absent only on tokens minted before audience binding
|
|
56
|
+
* (library < 0.5); current verifiers reject those.
|
|
57
|
+
*/
|
|
58
|
+
aud?: string;
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
export interface PublicKeyEntry {
|
|
@@ -61,6 +72,12 @@ export interface SigningConfig {
|
|
|
61
72
|
export interface VerifyConfig {
|
|
62
73
|
publicKeys: PublicKeyEntry[];
|
|
63
74
|
maxAgeSeconds: number;
|
|
75
|
+
/**
|
|
76
|
+
* Also confirm locally valid tokens with the auth service (at most once per token every `intervalSeconds`, default
|
|
77
|
+
* 300), so password resets, deleted users and removed scopes take effect without waiting for the token to expire.
|
|
78
|
+
* If the auth service can't be reached the local result stands.
|
|
79
|
+
*/
|
|
80
|
+
revalidate?: { authUrl: string; intervalSeconds?: number };
|
|
64
81
|
}
|
|
65
82
|
|
|
66
83
|
export interface CookieConfig {
|
package/src/users.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hashPassword, verifyPassword } from "./password.ts";
|
|
1
|
+
import { hashPassword, MAX_PASSWORD_LENGTH, needsRehash, verifyPassword } from "./password.ts";
|
|
2
2
|
import type { User, UserStore } from "./types.ts";
|
|
3
3
|
|
|
4
4
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
@@ -13,6 +13,8 @@ export async function createUser(
|
|
|
13
13
|
input: { email: string; name: string; password?: string; isAdmin?: boolean },
|
|
14
14
|
): Promise<User> {
|
|
15
15
|
if (!isValidEmail(input.email)) throw new Error("Invalid email address");
|
|
16
|
+
if (input.password !== undefined && input.password.length > MAX_PASSWORD_LENGTH)
|
|
17
|
+
throw new Error("Password is too long");
|
|
16
18
|
const existing = await store.getUserByEmail(input.email);
|
|
17
19
|
if (existing) throw new Error(`User with email ${input.email} already exists`);
|
|
18
20
|
|
|
@@ -35,12 +37,27 @@ export async function createUser(
|
|
|
35
37
|
return user;
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
let dummyHash: Promise<string> | null = null;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check an email/password pair. When the user doesn't exist a dummy hash is still verified, so response timing
|
|
44
|
+
* doesn't reveal which emails have accounts. A correct password stored under older hashing parameters is re-hashed
|
|
45
|
+
* with the current ones.
|
|
46
|
+
*/
|
|
38
47
|
export async function verifyUserCredentials(store: UserStore, email: string, password: string): Promise<User | null> {
|
|
39
48
|
const user = await store.getUserByEmail(email.toLowerCase());
|
|
40
|
-
if (!user)
|
|
49
|
+
if (!user) {
|
|
50
|
+
dummyHash ??= hashPassword(crypto.randomUUID());
|
|
51
|
+
await verifyPassword(password, await dummyHash);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
41
54
|
|
|
42
55
|
const valid = await verifyPassword(password, user.passwordHash);
|
|
43
|
-
|
|
56
|
+
if (!valid) return null;
|
|
57
|
+
if (!needsRehash(user.passwordHash)) return user;
|
|
58
|
+
const upgraded: User = { ...user, passwordHash: await hashPassword(password), updatedAt: new Date().toISOString() };
|
|
59
|
+
await store.saveUser(upgraded);
|
|
60
|
+
return upgraded;
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
export async function updateName(store: UserStore, userId: string, name: string): Promise<void> {
|
|
@@ -56,6 +73,7 @@ export async function updateName(store: UserStore, userId: string, name: string)
|
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
export async function changePassword(store: UserStore, userId: string, newPassword: string): Promise<void> {
|
|
76
|
+
if (newPassword.length > MAX_PASSWORD_LENGTH) throw new Error("Password is too long");
|
|
59
77
|
const user = await store.getUser(userId);
|
|
60
78
|
if (!user) throw new Error(`User ${userId} not found`);
|
|
61
79
|
|