@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.
@@ -0,0 +1,52 @@
1
+ import type { PublicKeyEntry, TokenPayload } from "./types.ts";
2
+ /** Anything with a Fetch-compatible `fetch` — the global `fetch` wrapped, or a Cloudflare service binding. */
3
+ export interface FetchLike {
4
+ fetch(input: string, init?: RequestInit): Promise<Response>;
5
+ }
6
+ /** Calls the auth service over the public internet. */
7
+ export declare function publicFetcher(authUrl: string): FetchLike;
8
+ /** Calls the auth service through a Cloudflare service binding. The hostname is ignored by bindings. */
9
+ export declare function bindingFetcher(binding: {
10
+ fetch(input: string, init?: RequestInit): Promise<Response>;
11
+ }): FetchLike;
12
+ /**
13
+ * Whether a validated token grants access to `origin`. The token's `aud` must be this origin — a token issued to
14
+ * another site (or to the auth service itself) is never accepted, even for admins. Then admins can access every
15
+ * site; everyone else needs the origin in their `scopes` (loopback origins are exempt so local development works).
16
+ *
17
+ * A valid signature alone only proves the user has *an* account — every consuming site must also run this check.
18
+ */
19
+ export declare function hasSiteAccess(payload: TokenPayload, origin: string): boolean;
20
+ /**
21
+ * `hasSiteAccess` for a site that answers on more than one origin — e.g. a Worker whose request URL origin (where the
22
+ * login callback landed, and so the token's audience) differs from the origin registered for scopes.
23
+ */
24
+ export declare function hasSiteAccessFor(payload: TokenPayload, site: {
25
+ audiences: string[];
26
+ scopeOrigin: string;
27
+ }): boolean;
28
+ /**
29
+ * POST /api/exchange-code — trade a one-time `?code=` for a signed token bound to `redirectOrigin`. `codeVerifier` is
30
+ * the PKCE verifier whose challenge went out with the login (required for codes issued with one).
31
+ */
32
+ export declare function exchangeAuthCode(auth: FetchLike, code: string, redirectOrigin: string, codeVerifier?: string): Promise<string | null>;
33
+ export type RemoteCheck = {
34
+ status: "valid";
35
+ payload: TokenPayload;
36
+ } | {
37
+ status: "invalid";
38
+ }
39
+ /** Network error or 5xx — the auth service didn't give an answer. */
40
+ | {
41
+ status: "unavailable";
42
+ };
43
+ /**
44
+ * POST /api/verify-token — validate a token with the auth service itself. Unlike local JWKS validation this also
45
+ * rejects tokens for deleted users or revoked sessions, and returns the user's *current* admin/scopes/flags. With an
46
+ * `audience`, the auth service also rejects tokens issued to any other origin.
47
+ */
48
+ export declare function checkTokenRemote(auth: FetchLike, token: string, audience?: string): Promise<RemoteCheck>;
49
+ /** `checkTokenRemote`, collapsed to the payload or null (an unreachable auth service counts as invalid). */
50
+ export declare function verifyTokenRemote(auth: FetchLike, token: string, audience?: string): Promise<TokenPayload | null>;
51
+ /** Fetch and import the auth service's JWKS (`/.well-known/jwk.json`). Keys without a string `kid` are skipped. */
52
+ export declare function fetchJwks(auth: FetchLike): Promise<PublicKeyEntry[]>;
package/dist/remote.js ADDED
@@ -0,0 +1,264 @@
1
+ // @bun
2
+ // src/token.ts
3
+ var encoder = new TextEncoder;
4
+ function base64urlEncode(data) {
5
+ let binary = "";
6
+ for (const byte of data) {
7
+ binary += String.fromCharCode(byte);
8
+ }
9
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
10
+ }
11
+ function base64urlDecode(str) {
12
+ if (!/^[A-Za-z0-9_-]*$/.test(str))
13
+ throw new Error("Invalid base64url");
14
+ const padded = str.replace(/-/g, "+").replace(/_/g, "/");
15
+ const binary = atob(padded);
16
+ const bytes = new Uint8Array(binary.length);
17
+ for (let i = 0;i < binary.length; i++) {
18
+ bytes[i] = binary.charCodeAt(i);
19
+ }
20
+ return bytes;
21
+ }
22
+ var ECDSA_PARAMS = { name: "ECDSA", namedCurve: "P-256" };
23
+ var SIGN_PARAMS = { name: "ECDSA", hash: "SHA-256" };
24
+ async function generateKeyPair() {
25
+ const kid = crypto.randomUUID().slice(0, 8);
26
+ const keyPair = await crypto.subtle.generateKey(ECDSA_PARAMS, true, ["sign", "verify"]);
27
+ if (!("privateKey" in keyPair))
28
+ throw new Error("Expected CryptoKeyPair from ECDSA generateKey");
29
+ const privateExport = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
30
+ const publicExport = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
31
+ if (privateExport instanceof ArrayBuffer || publicExport instanceof ArrayBuffer)
32
+ throw new Error("Expected JWK from exportKey");
33
+ return { kid, privateKeyJwk: privateExport, publicKeyJwk: publicExport };
34
+ }
35
+ async function importPrivateKey(jwk) {
36
+ return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["sign"]);
37
+ }
38
+ async function importPublicKey(jwk) {
39
+ return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["verify"]);
40
+ }
41
+ async function createToken(config, user, options = {}) {
42
+ const now = options.issuedAt ?? Math.floor(Date.now() / 1000);
43
+ const payload = {
44
+ sub: user.id,
45
+ email: user.email,
46
+ name: user.name,
47
+ admin: user.isAdmin,
48
+ scopes: user.scopes,
49
+ flags: user.flags,
50
+ kid: config.kid,
51
+ iat: now,
52
+ exp: now + config.maxAgeSeconds,
53
+ ...user.audience === undefined ? {} : { aud: user.audience }
54
+ };
55
+ const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
56
+ const payloadBytes = encoder.encode(payloadB64);
57
+ const signature = await crypto.subtle.sign(SIGN_PARAMS, config.privateKey, payloadBytes);
58
+ const sigB64 = base64urlEncode(new Uint8Array(signature));
59
+ return `${payloadB64}.${sigB64}`;
60
+ }
61
+ var CLOCK_SKEW_SECONDS = 60;
62
+ function isStringArray(value) {
63
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
64
+ }
65
+ function isFlags(value) {
66
+ if (typeof value !== "object" || value === null || Array.isArray(value))
67
+ return false;
68
+ return Object.values(value).every(isStringArray);
69
+ }
70
+ function isTokenPayload(value) {
71
+ if (typeof value !== "object" || value === null)
72
+ return false;
73
+ if (!("sub" in value) || !("email" in value) || !("name" in value) || !("admin" in value) || !("scopes" in value) || !("flags" in value) || !("kid" in value) || !("iat" in value) || !("exp" in value)) {
74
+ return false;
75
+ }
76
+ return typeof value.sub === "string" && typeof value.email === "string" && typeof value.name === "string" && typeof value.admin === "boolean" && isStringArray(value.scopes) && isFlags(value.flags) && typeof value.kid === "string" && Number.isSafeInteger(value.iat) && Number.isSafeInteger(value.exp) && (!("aud" in value) || value.aud === undefined || typeof value.aud === "string");
77
+ }
78
+ async function validateToken(config, token) {
79
+ const parts = token.split(".");
80
+ if (parts.length !== 2)
81
+ return null;
82
+ const [payloadB64 = "", sigB64 = ""] = parts;
83
+ if (!payloadB64 || !sigB64)
84
+ return null;
85
+ let decoded;
86
+ try {
87
+ decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
88
+ } catch {
89
+ return null;
90
+ }
91
+ if (!isTokenPayload(decoded))
92
+ return null;
93
+ const payload = decoded;
94
+ const now = Math.floor(Date.now() / 1000);
95
+ if (now > payload.exp)
96
+ return null;
97
+ if (payload.iat > now + CLOCK_SKEW_SECONDS)
98
+ return null;
99
+ if (now - payload.iat > config.maxAgeSeconds)
100
+ return null;
101
+ const entry = config.publicKeys.find((k) => k.kid === payload.kid);
102
+ if (!entry)
103
+ return null;
104
+ try {
105
+ const signatureBytes = base64urlDecode(sigB64);
106
+ const payloadBytes = encoder.encode(payloadB64);
107
+ const valid = await crypto.subtle.verify(SIGN_PARAMS, entry.key, signatureBytes, payloadBytes);
108
+ if (!valid)
109
+ return null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ return payload;
114
+ }
115
+ function peekTokenPayload(token) {
116
+ const payloadB64 = token.split(".")[0];
117
+ if (!payloadB64)
118
+ return null;
119
+ try {
120
+ const decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
121
+ return isTokenPayload(decoded) ? decoded : null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+ function peekTokenKid(token) {
127
+ const payloadB64 = token.split(".")[0];
128
+ if (!payloadB64)
129
+ return null;
130
+ try {
131
+ const decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
132
+ if (typeof decoded !== "object" || decoded === null || !("kid" in decoded))
133
+ return null;
134
+ return typeof decoded.kid === "string" ? decoded.kid : null;
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ // src/remote.ts
141
+ function publicFetcher(authUrl) {
142
+ return { fetch: (path, init) => fetch(new URL(path, authUrl), init) };
143
+ }
144
+ function bindingFetcher(binding) {
145
+ return { fetch: (path, init) => binding.fetch(new URL(path, "https://auth").toString(), init) };
146
+ }
147
+ function isLoopbackHostname(hostname) {
148
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
149
+ }
150
+ function toOrigin(value) {
151
+ try {
152
+ return new URL(value).origin;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+ function hasSiteAccess(payload, origin) {
158
+ return hasSiteAccessFor(payload, { audiences: [origin], scopeOrigin: origin });
159
+ }
160
+ function hasSiteAccessFor(payload, site) {
161
+ const scopeOrigin = toOrigin(site.scopeOrigin);
162
+ if (!scopeOrigin || payload.aud === undefined)
163
+ return false;
164
+ if (!site.audiences.some((audience) => toOrigin(audience) === payload.aud))
165
+ return false;
166
+ if (payload.admin)
167
+ return true;
168
+ if (isLoopbackHostname(new URL(scopeOrigin).hostname))
169
+ return true;
170
+ return payload.scopes.includes(scopeOrigin);
171
+ }
172
+ async function readJsonObject(res) {
173
+ try {
174
+ const body = await res.json();
175
+ return typeof body === "object" && body !== null ? body : null;
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+ async function exchangeAuthCode(auth, code, redirectOrigin, codeVerifier) {
181
+ const res = await auth.fetch("/api/exchange-code", {
182
+ method: "POST",
183
+ headers: { "Content-Type": "application/json" },
184
+ body: JSON.stringify(codeVerifier === undefined ? { code, redirectOrigin } : { code, redirectOrigin, codeVerifier })
185
+ });
186
+ if (!res.ok)
187
+ return null;
188
+ const body = await readJsonObject(res);
189
+ if (!body || !("token" in body) || typeof body.token !== "string")
190
+ return null;
191
+ return body.token;
192
+ }
193
+ async function checkTokenRemote(auth, token, audience) {
194
+ let res;
195
+ try {
196
+ res = await auth.fetch("/api/verify-token", {
197
+ method: "POST",
198
+ headers: { "Content-Type": "application/json" },
199
+ body: JSON.stringify(audience === undefined ? { token } : { token, audience })
200
+ });
201
+ } catch {
202
+ return { status: "unavailable" };
203
+ }
204
+ if (res.status >= 500)
205
+ return { status: "unavailable" };
206
+ if (!res.ok)
207
+ return { status: "invalid" };
208
+ const body = await readJsonObject(res);
209
+ if (!body || !("user" in body) || typeof body.user !== "object" || body.user === null)
210
+ return { status: "invalid" };
211
+ const user = body.user;
212
+ if (!("id" in user))
213
+ return { status: "invalid" };
214
+ const candidate = { ...user, sub: user.id, iat: "iat" in user ? user.iat : 0, exp: "exp" in user ? user.exp : 0 };
215
+ if (!isTokenPayload(candidate))
216
+ return { status: "invalid" };
217
+ return {
218
+ status: "valid",
219
+ payload: {
220
+ sub: candidate.sub,
221
+ email: candidate.email,
222
+ name: candidate.name,
223
+ admin: candidate.admin,
224
+ scopes: candidate.scopes,
225
+ flags: candidate.flags,
226
+ kid: candidate.kid,
227
+ iat: candidate.iat,
228
+ exp: candidate.exp,
229
+ ...candidate.aud === undefined ? {} : { aud: candidate.aud }
230
+ }
231
+ };
232
+ }
233
+ async function verifyTokenRemote(auth, token, audience) {
234
+ const result = await checkTokenRemote(auth, token, audience);
235
+ return result.status === "valid" ? result.payload : null;
236
+ }
237
+ async function fetchJwks(auth) {
238
+ const res = await auth.fetch("/.well-known/jwk.json");
239
+ if (!res.ok)
240
+ throw new Error(`Failed to fetch JWKS: ${res.status}`);
241
+ const body = await readJsonObject(res);
242
+ if (!body || !("keys" in body) || !Array.isArray(body.keys))
243
+ throw new Error("Malformed JWKS response");
244
+ const entries = [];
245
+ for (const jwk of body.keys) {
246
+ if (typeof jwk !== "object" || jwk === null || !("kid" in jwk) || typeof jwk.kid !== "string")
247
+ continue;
248
+ if (!("kty" in jwk) || typeof jwk.kty !== "string")
249
+ continue;
250
+ const { kid, ...keyData } = jwk;
251
+ entries.push({ kid, key: await importPublicKey({ ...keyData, kty: jwk.kty }) });
252
+ }
253
+ return entries;
254
+ }
255
+ export {
256
+ bindingFetcher,
257
+ checkTokenRemote,
258
+ exchangeAuthCode,
259
+ fetchJwks,
260
+ hasSiteAccess,
261
+ hasSiteAccessFor,
262
+ publicFetcher,
263
+ verifyTokenRemote
264
+ };
package/dist/service.d.ts CHANGED
@@ -1,24 +1,24 @@
1
1
  import type { CookieConfig, TokenPayload } from "./types.ts";
2
- export { makeSessionCookie, makeClearCookie } from "./cookie.ts";
3
- export type { TokenPayload, CookieConfig } from "./types.ts";
2
+ export { makeClearCookie, makeSessionCookie } from "./cookie.ts";
3
+ export { redirectToLogin } from "./flow.ts";
4
+ export { hasSiteAccess } from "./remote.ts";
5
+ export type { CookieConfig, TokenPayload } from "./types.ts";
4
6
  /**
5
7
  * Validate a raw token string via the auth service binding.
6
- * Returns the token payload or null if invalid.
8
+ * Returns the token payload (with the user's current admin/scopes/flags) or null if invalid or revoked. Pass the
9
+ * site's origin as `audience` so tokens issued to other origins are rejected too.
10
+ * This does not check site access — use `getAuthUser` or `hasSiteAccess` for that.
7
11
  */
8
- export declare function verifyToken(authService: Fetcher, token: string): Promise<TokenPayload | null>;
12
+ export declare function verifyToken(authService: Fetcher, token: string, audience?: string): Promise<TokenPayload | null>;
9
13
  /**
10
14
  * Get the authenticated user from the session cookie via the auth service binding.
11
- * Returns the token payload or null if not authenticated.
15
+ * Returns null if not authenticated, or if the token wasn't issued to — or the user doesn't have access to —
16
+ * `siteOrigin` (defaults to the request's origin).
12
17
  */
13
- export declare function getAuthUser(request: Request, authService: Fetcher, cookieConfig: CookieConfig): Promise<TokenPayload | null>;
14
- /**
15
- * Redirect to the centralized login page at auth.uln.industries.
16
- * Requests one-time auth-code callbacks so signed tokens are not sent through URLs.
17
- */
18
- export declare function redirectToLogin(currentUrl: string): Response;
18
+ export declare function getAuthUser(request: Request, authService: Fetcher, cookieConfig: CookieConfig, siteOrigin?: string): Promise<TokenPayload | null>;
19
19
  /**
20
20
  * Handle an auth callback on a consuming Worker using the auth service binding.
21
- * Preferred `?code=` callbacks are exchanged for signed tokens before setting the local cookie.
22
- * Returns null if the request has no auth callback parameters.
21
+ * `?code=` callbacks started by this browser are exchanged for signed tokens before setting the local cookie.
22
+ * Returns 403 if the user doesn't have access to this site, or null if the request has no callback parameters.
23
23
  */
24
24
  export declare function handleCallback(request: Request, authService: Fetcher, cookieConfig: CookieConfig): Promise<Response | null>;