@ghuri/oauth 0.0.0-stage → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,231 @@
1
+ import {
2
+ authenticate,
3
+ completeLogin,
4
+ failureResponse,
5
+ loginBackendName,
6
+ runStrategy,
7
+ userInfo
8
+ } from "./chunk-OIBPPYBX.js";
9
+ import {
10
+ OAuth2Client,
11
+ OAuth2Strategy,
12
+ OAuthError,
13
+ OIDCStrategy,
14
+ SUPPORTED_ALGORITHMS,
15
+ TokenValidationError,
16
+ b64url,
17
+ clearDiscoveryCache,
18
+ clearJwksCache,
19
+ decodeJwt,
20
+ discover,
21
+ envValue,
22
+ fromB64url,
23
+ fromUtf8,
24
+ getJson,
25
+ importPkcs8,
26
+ pkcePair,
27
+ profileFromClaims,
28
+ providerFetch,
29
+ randomToken,
30
+ readBody,
31
+ savePending,
32
+ seal,
33
+ setProviderFetch,
34
+ sha256,
35
+ signJwt,
36
+ stateSettings,
37
+ takePending,
38
+ timingSafeEqual,
39
+ toTokens,
40
+ unseal,
41
+ utf8,
42
+ verifyJwt
43
+ } from "./chunk-TSKPVGEC.js";
44
+ import {
45
+ CustomStrategy,
46
+ Strategy,
47
+ resolveStrategy,
48
+ strategies
49
+ } from "./chunk-IS4XX7EU.js";
50
+
51
+ // src/local.ts
52
+ import { authenticate as authAuthenticate, getUserModel, readData, saltedHmac } from "@ghuri/auth";
53
+ import { getHost, isSecure, tryGetContext, wantsJson } from "@ghuri/core";
54
+ var LocalStrategy = class extends Strategy {
55
+ constructor(options = {}, verify = ({ username, password, c }) => authAuthenticate(c, { username, password })) {
56
+ super();
57
+ this.verify = verify;
58
+ this.name = options.name ?? "local";
59
+ this.usernameField = options.usernameField ?? "username";
60
+ this.passwordField = options.passwordField ?? "password";
61
+ }
62
+ verify;
63
+ name;
64
+ usernameField;
65
+ passwordField;
66
+ async authenticate(c) {
67
+ if (c.req.method !== "POST") return this.pass();
68
+ const data = await readData(c);
69
+ const username = data[this.usernameField];
70
+ const password = data[this.passwordField];
71
+ if (typeof username !== "string" || !username || typeof password !== "string" || !password) {
72
+ return this.fail("Missing credentials.", 400);
73
+ }
74
+ const user = await this.verify({ username, password, c });
75
+ return user ? this.success(user) : this.fail("Invalid credentials.", 401);
76
+ }
77
+ };
78
+ var MAGIC_SALT = "ghuri.oauth.MagicLinkStrategy";
79
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
80
+ function hashValue(user, ts) {
81
+ const lastLogin = user.lastLogin ? new Date(user.lastLogin).toISOString() : "";
82
+ const emailField = user.constructor?.getEmailFieldName?.() ?? "email";
83
+ return `${user.pk}${user.password ?? ""}${lastLogin}${ts}${user[emailField] ?? ""}`;
84
+ }
85
+ async function mac(user, ts, secret) {
86
+ return b64url(await saltedHmac(MAGIC_SALT, hashValue(user, ts), secret, "sha256"));
87
+ }
88
+ var MagicLinkStrategy = class extends Strategy {
89
+ constructor(options, verify) {
90
+ super();
91
+ this.options = options;
92
+ this.verify = verify;
93
+ this.name = options.name ?? "magic-link";
94
+ }
95
+ options;
96
+ verify;
97
+ name;
98
+ /** A login token for `user` (exposed for custom delivery). */
99
+ async makeToken(user) {
100
+ const ts = Math.floor(Date.now() / 1e3);
101
+ return `${b64url(utf8(String(user.pk)))}.${ts.toString(36)}.${await mac(user, ts)}`;
102
+ }
103
+ async checkToken(token) {
104
+ const [pkPart, tsPart, sig] = token.split(".");
105
+ if (!pkPart || !tsPart || !sig) return null;
106
+ const ts = Number.parseInt(tsPart, 36);
107
+ if (!Number.isFinite(ts) || Date.now() / 1e3 - ts > (this.options.maxAge ?? 900) || ts > Date.now() / 1e3 + 60) {
108
+ return null;
109
+ }
110
+ let pk;
111
+ try {
112
+ pk = fromUtf8(fromB64url(pkPart));
113
+ } catch {
114
+ return null;
115
+ }
116
+ const User = getUserModel();
117
+ let value;
118
+ try {
119
+ value = User._meta.pk.toPython(pk);
120
+ } catch {
121
+ return null;
122
+ }
123
+ const user = await User.objects.filter({ pk: value }).first();
124
+ if (!user) return null;
125
+ const secrets = [
126
+ void 0,
127
+ ...tryGetContext()?.settings.SECRET_KEY_FALLBACKS ?? []
128
+ ];
129
+ for (const secret of secrets) {
130
+ if (timingSafeEqual(await mac(user, ts, secret), sig)) return user;
131
+ }
132
+ return null;
133
+ }
134
+ async authenticate(c, _options) {
135
+ if (c.req.method === "POST") return this.request(c);
136
+ const token = c.req.query("token");
137
+ if (!token) return this.pass();
138
+ const user = await this.checkToken(token);
139
+ if (!user) return this.fail("This login link is invalid or has expired.", 400);
140
+ return this.success(user, { next: c.req.query("next") || void 0 });
141
+ }
142
+ async request(c) {
143
+ const data = await readData(c);
144
+ const email = String(data[this.options.emailField ?? "email"] ?? "").trim();
145
+ if (!EMAIL_RE.test(email)) return this.fail("Enter a valid email address.", 400);
146
+ const user = await this.verify({ email, c });
147
+ if (user && user.isActive !== false) {
148
+ const url = new URL(c.req.path, `${isSecure(c) ? "https" : "http"}://${getHost(c)}`);
149
+ url.searchParams.set("token", await this.makeToken(user));
150
+ const next = typeof data.next === "string" ? data.next : c.req.query("next");
151
+ if (next) url.searchParams.set("next", next);
152
+ await this.options.send({ email, url: url.toString(), user, c });
153
+ }
154
+ const detail = "If an account exists for that address, we sent it a login link.";
155
+ if (wantsJson(c)) return { type: "response", response: c.json({ detail }) };
156
+ return { type: "redirect", url: this.options.sentRedirect ?? `${c.req.path}?sent=1`, status: 302 };
157
+ }
158
+ };
159
+ var BearerStrategy = class extends Strategy {
160
+ constructor(verify, options = {}) {
161
+ super();
162
+ this.verify = verify;
163
+ this.name = options.name ?? "bearer";
164
+ this.realm = options.realm ?? "api";
165
+ }
166
+ verify;
167
+ name;
168
+ realm;
169
+ async authenticate(c) {
170
+ const header = c.req.header("authorization");
171
+ if (!header) return this.pass();
172
+ const m = /^Bearer\s+(\S+)\s*$/i.exec(header);
173
+ if (!m) return this.pass();
174
+ const result = await this.verify(m[1], c);
175
+ if (!result) {
176
+ return this.fail("Invalid token.", 401, `Bearer realm="${this.realm}", error="invalid_token"`);
177
+ }
178
+ if (typeof result === "object" && "user" in result && Object.keys(result).every((k) => k === "user" || k === "info")) {
179
+ return this.success(result.user, result.info);
180
+ }
181
+ return this.success(result);
182
+ }
183
+ };
184
+ export {
185
+ BearerStrategy,
186
+ CustomStrategy,
187
+ LocalStrategy,
188
+ MagicLinkStrategy,
189
+ OAuth2Client,
190
+ OAuth2Strategy,
191
+ OAuthError,
192
+ OIDCStrategy,
193
+ SUPPORTED_ALGORITHMS,
194
+ Strategy,
195
+ TokenValidationError,
196
+ authenticate,
197
+ b64url,
198
+ clearDiscoveryCache,
199
+ clearJwksCache,
200
+ completeLogin,
201
+ decodeJwt,
202
+ discover,
203
+ envValue,
204
+ failureResponse,
205
+ fromB64url,
206
+ getJson,
207
+ importPkcs8,
208
+ loginBackendName,
209
+ pkcePair,
210
+ profileFromClaims,
211
+ providerFetch,
212
+ randomToken,
213
+ readBody,
214
+ resolveStrategy,
215
+ runStrategy,
216
+ savePending,
217
+ seal,
218
+ setProviderFetch,
219
+ sha256,
220
+ signJwt,
221
+ stateSettings,
222
+ strategies,
223
+ takePending,
224
+ timingSafeEqual,
225
+ toTokens,
226
+ unseal,
227
+ userInfo,
228
+ utf8,
229
+ verifyJwt
230
+ };
231
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/local.ts"],"sourcesContent":["// Username/password, magic links and bearer tokens.\nimport { authenticate as authAuthenticate, getUserModel, readData, saltedHmac } from '@ghuri/auth'\nimport { getHost, isSecure, tryGetContext, wantsJson } from '@ghuri/core'\nimport type { Context } from 'hono'\nimport { b64url, fromB64url, fromUtf8, timingSafeEqual, utf8 } from './crypto'\nimport { Strategy } from './strategy'\nimport type { AuthenticateOptions, StrategyResult, StrategyUser } from './types'\n\n// ---- LocalStrategy ----\n\nexport interface LocalStrategyOptions {\n name?: string\n usernameField?: string\n passwordField?: string\n}\n\nexport type LocalVerify = (params: {\n username: string\n password: string\n c: Context\n}) => Promise<StrategyUser | null>\n\n/**\n * Username and password from a JSON or form POST. The default `verify` is `@ghuri/auth`'s `authenticate()`, so\n * `AUTHENTICATION_BACKENDS`, password hashers and `userLoginFailed` all apply.\n */\nexport class LocalStrategy extends Strategy {\n readonly name: string\n private readonly usernameField: string\n private readonly passwordField: string\n\n constructor(\n options: LocalStrategyOptions = {},\n readonly verify: LocalVerify = ({ username, password, c }) => authAuthenticate(c, { username, password }),\n ) {\n super()\n this.name = options.name ?? 'local'\n this.usernameField = options.usernameField ?? 'username'\n this.passwordField = options.passwordField ?? 'password'\n }\n\n async authenticate(c: Context): Promise<StrategyResult> {\n if (c.req.method !== 'POST') return this.pass()\n const data = await readData(c)\n const username = data[this.usernameField]\n const password = data[this.passwordField]\n if (typeof username !== 'string' || !username || typeof password !== 'string' || !password) {\n return this.fail('Missing credentials.', 400)\n }\n const user = await this.verify({ username, password, c })\n return user ? this.success(user) : this.fail('Invalid credentials.', 401)\n }\n}\n\n// ---- MagicLinkStrategy ----\n\nexport interface MagicLinkOptions {\n name?: string\n /** Deliver the link. Called only when `verify` found a user for the address. */\n send: (params: { email: string; url: string; user: StrategyUser; c: Context }) => Promise<void>\n /** Seconds a link stays valid (default 900). */\n maxAge?: number\n emailField?: string\n /** Where forms go after asking for a link (default: the same URL with `?sent=1`). */\n sentRedirect?: string\n}\n\nexport type MagicLinkVerify = (params: { email: string; c: Context }) => Promise<StrategyUser | null>\n\nconst MAGIC_SALT = 'ghuri.oauth.MagicLinkStrategy'\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\nfunction hashValue(user: any, ts: number): string {\n const lastLogin = user.lastLogin ? new Date(user.lastLogin).toISOString() : ''\n const emailField = (user.constructor?.getEmailFieldName?.() as string | undefined) ?? 'email'\n return `${user.pk}${user.password ?? ''}${lastLogin}${ts}${user[emailField] ?? ''}`\n}\n\nasync function mac(user: any, ts: number, secret?: string): Promise<string> {\n return b64url(await saltedHmac(MAGIC_SALT, hashValue(user, ts), secret, 'sha256'))\n}\n\n/**\n * Passwordless login: `POST { email }` sends a link, `GET ?token=` logs in. Links expire after `maxAge` and stop\n * working after one login (the signature covers `lastLogin`) or a password/email change. The POST answers the\n * same whether or not the address has an account.\n */\nexport class MagicLinkStrategy extends Strategy {\n readonly name: string\n\n constructor(\n readonly options: MagicLinkOptions,\n readonly verify: MagicLinkVerify,\n ) {\n super()\n this.name = options.name ?? 'magic-link'\n }\n\n /** A login token for `user` (exposed for custom delivery). */\n async makeToken(user: any): Promise<string> {\n const ts = Math.floor(Date.now() / 1000)\n return `${b64url(utf8(String(user.pk)))}.${ts.toString(36)}.${await mac(user, ts)}`\n }\n\n async checkToken(token: string): Promise<StrategyUser | null> {\n const [pkPart, tsPart, sig] = token.split('.')\n if (!pkPart || !tsPart || !sig) return null\n const ts = Number.parseInt(tsPart, 36)\n if (\n !Number.isFinite(ts) ||\n Date.now() / 1000 - ts > (this.options.maxAge ?? 900) ||\n ts > Date.now() / 1000 + 60\n ) {\n return null\n }\n let pk: string\n try {\n pk = fromUtf8(fromB64url(pkPart))\n } catch {\n return null\n }\n const User = getUserModel()\n let value: unknown\n try {\n value = User._meta.pk.toPython(pk)\n } catch {\n return null\n }\n const user = await User.objects.filter({ pk: value }).first()\n if (!user) return null\n const secrets = [\n undefined,\n ...((tryGetContext()?.settings.SECRET_KEY_FALLBACKS as string[] | undefined) ?? []),\n ]\n for (const secret of secrets) {\n if (timingSafeEqual(await mac(user, ts, secret), sig)) return user\n }\n return null\n }\n\n async authenticate(c: Context, _options: AuthenticateOptions): Promise<StrategyResult> {\n if (c.req.method === 'POST') return this.request(c)\n const token = c.req.query('token')\n if (!token) return this.pass()\n const user = await this.checkToken(token)\n if (!user) return this.fail('This login link is invalid or has expired.', 400)\n return this.success(user, { next: c.req.query('next') || undefined })\n }\n\n private async request(c: Context): Promise<StrategyResult> {\n const data = await readData(c)\n const email = String(data[this.options.emailField ?? 'email'] ?? '').trim()\n if (!EMAIL_RE.test(email)) return this.fail('Enter a valid email address.', 400)\n const user = await this.verify({ email, c })\n if (user && user.isActive !== false) {\n const url = new URL(c.req.path, `${isSecure(c) ? 'https' : 'http'}://${getHost(c)}`)\n url.searchParams.set('token', await this.makeToken(user))\n const next = typeof data.next === 'string' ? data.next : c.req.query('next')\n if (next) url.searchParams.set('next', next)\n await this.options.send({ email, url: url.toString(), user, c })\n }\n const detail = 'If an account exists for that address, we sent it a login link.'\n if (wantsJson(c)) return { type: 'response', response: c.json({ detail }) }\n return { type: 'redirect', url: this.options.sentRedirect ?? `${c.req.path}?sent=1`, status: 302 }\n }\n}\n\n// ---- BearerStrategy ----\n\nexport type BearerVerify = (\n token: string,\n c: Context,\n) => Promise<StrategyUser | null | { user: StrategyUser; info?: Record<string, unknown> }>\n\n/** `Authorization: Bearer <token>` → `verify(token)`. No header → pass; unknown token → 401 with a challenge. */\nexport class BearerStrategy extends Strategy {\n readonly name: string\n private readonly realm: string\n\n constructor(\n readonly verify: BearerVerify,\n options: { name?: string; realm?: string } = {},\n ) {\n super()\n this.name = options.name ?? 'bearer'\n this.realm = options.realm ?? 'api'\n }\n\n async authenticate(c: Context): Promise<StrategyResult> {\n const header = c.req.header('authorization')\n if (!header) return this.pass()\n const m = /^Bearer\\s+(\\S+)\\s*$/i.exec(header)\n if (!m) return this.pass()\n const result = await this.verify(m[1], c)\n if (!result) {\n return this.fail('Invalid token.', 401, `Bearer realm=\"${this.realm}\", error=\"invalid_token\"`)\n }\n if (\n typeof result === 'object' &&\n 'user' in result &&\n Object.keys(result).every((k) => k === 'user' || k === 'info')\n ) {\n return this.success(result.user, result.info)\n }\n return this.success(result)\n }\n}\n"],"mappings":"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","names":[]}
package/dist/rest.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { RequestUser } from '@ghuri/core';
2
+ import { Context } from 'hono';
3
+ import { a as StrategyRef } from './strategy-nxNiKJFS.js';
4
+
5
+ /**
6
+ * A REST authentication class that runs strategies (usually `BearerStrategy` / `CustomStrategy`):
7
+ * `authenticationClasses = [StrategyAuthentication(['api-key'])]`.
8
+ */
9
+ declare function StrategyAuthentication(refs: StrategyRef | StrategyRef[], opts?: {
10
+ header?: string;
11
+ }): {
12
+ new (): {
13
+ authenticate(c: Context): Promise<[RequestUser, unknown] | null>;
14
+ authenticateHeader(): string;
15
+ };
16
+ };
17
+
18
+ export { StrategyAuthentication };
package/dist/rest.js ADDED
@@ -0,0 +1,33 @@
1
+ import {
2
+ runStrategy
3
+ } from "./chunk-OIBPPYBX.js";
4
+ import "./chunk-IS4XX7EU.js";
5
+
6
+ // src/rest.ts
7
+ import { AuthenticationFailed } from "@ghuri/rest";
8
+ function StrategyAuthentication(refs, opts = {}) {
9
+ return class StrategyAuthenticationClass {
10
+ async authenticate(c) {
11
+ const result = await runStrategy(c, refs);
12
+ switch (result.type) {
13
+ case "success":
14
+ if (result.user?.isActive === false) throw new AuthenticationFailed("User inactive or deleted.");
15
+ return [result.user, result.info ?? null];
16
+ case "error":
17
+ throw result.error;
18
+ case "fail":
19
+ if (result.strategy === void 0) return null;
20
+ throw new AuthenticationFailed(result.message);
21
+ default:
22
+ return null;
23
+ }
24
+ }
25
+ authenticateHeader() {
26
+ return opts.header ?? "Bearer";
27
+ }
28
+ };
29
+ }
30
+ export {
31
+ StrategyAuthentication
32
+ };
33
+ //# sourceMappingURL=rest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/rest.ts"],"sourcesContent":["// @ghuri/oauth/rest — use strategies as REST authentication classes.\nimport type { RequestUser } from '@ghuri/core'\nimport { AuthenticationFailed } from '@ghuri/rest'\nimport type { Context } from 'hono'\nimport { runStrategy } from './authenticate'\nimport type { StrategyRef } from './strategy'\n\n/**\n * A REST authentication class that runs strategies (usually `BearerStrategy` / `CustomStrategy`):\n * `authenticationClasses = [StrategyAuthentication(['api-key'])]`.\n */\nexport function StrategyAuthentication(refs: StrategyRef | StrategyRef[], opts: { header?: string } = {}) {\n return class StrategyAuthenticationClass {\n async authenticate(c: Context): Promise<[RequestUser, unknown] | null> {\n const result = await runStrategy(c, refs)\n switch (result.type) {\n case 'success':\n if (result.user?.isActive === false) throw new AuthenticationFailed('User inactive or deleted.')\n return [result.user, result.info ?? null]\n case 'error':\n throw result.error\n case 'fail':\n // All strategies passed: no credentials of this kind; let the next class try.\n if (result.strategy === undefined) return null\n throw new AuthenticationFailed(result.message)\n default:\n return null\n }\n }\n\n authenticateHeader(): string {\n return opts.header ?? 'Bearer'\n }\n }\n}\n"],"mappings":";;;;;;AAEA,SAAS,4BAA4B;AAS9B,SAAS,uBAAuB,MAAmC,OAA4B,CAAC,GAAG;AACxG,SAAO,MAAM,4BAA4B;AAAA,IACvC,MAAM,aAAa,GAAoD;AACrE,YAAM,SAAS,MAAM,YAAY,GAAG,IAAI;AACxC,cAAQ,OAAO,MAAM;AAAA,QACnB,KAAK;AACH,cAAI,OAAO,MAAM,aAAa,MAAO,OAAM,IAAI,qBAAqB,2BAA2B;AAC/F,iBAAO,CAAC,OAAO,MAAM,OAAO,QAAQ,IAAI;AAAA,QAC1C,KAAK;AACH,gBAAM,OAAO;AAAA,QACf,KAAK;AAEH,cAAI,OAAO,aAAa,OAAW,QAAO;AAC1C,gBAAM,IAAI,qBAAqB,OAAO,OAAO;AAAA,QAC/C;AACE,iBAAO;AAAA,MACX;AAAA,IACF;AAAA,IAEA,qBAA6B;AAC3B,aAAO,KAAK,UAAU;AAAA,IACxB;AAAA,EACF;AACF;","names":[]}
@@ -0,0 +1,109 @@
1
+ import { Context } from 'hono';
2
+
3
+ /** A user as the strategies see it: whatever your user model is (usually `@ghuri/auth`'s `User`). */
4
+ type StrategyUser = any;
5
+ type StrategyResult = {
6
+ type: 'success';
7
+ user: StrategyUser;
8
+ info?: Record<string, unknown>;
9
+ }
10
+ /** `challenge` becomes `WWW-Authenticate`; `message` the error shown or returned. */
11
+ | {
12
+ type: 'fail';
13
+ status?: number;
14
+ challenge?: string;
15
+ message?: string;
16
+ } | {
17
+ type: 'redirect';
18
+ url: string;
19
+ status?: number;
20
+ }
21
+ /** Not for this strategy: try the next one. */
22
+ | {
23
+ type: 'pass';
24
+ } | {
25
+ type: 'error';
26
+ error: unknown;
27
+ }
28
+ /** The strategy answered the request itself (e.g. "we sent you a link"). */
29
+ | {
30
+ type: 'response';
31
+ response: Response;
32
+ };
33
+ interface AuthenticateOptions {
34
+ successRedirect?: string;
35
+ failureRedirect?: string;
36
+ /** Go to the `next` kept across the round trip when it is safe, else here. */
37
+ successReturnToOrRedirect?: string;
38
+ /** Log the user in with `@ghuri/auth` (default true). */
39
+ session?: boolean;
40
+ /** What to answer on success: a session login (default), a JWT pair, or your own response. */
41
+ respond?: 'session' | 'jwt' | ((c: Context, user: StrategyUser, info?: Record<string, unknown>) => Response | Promise<Response>);
42
+ /** Extra scopes for this request (OAuth strategies). */
43
+ scope?: string[];
44
+ /** Where to go after login (default: the `next` query parameter); kept across the OAuth round trip. */
45
+ next?: string;
46
+ /** Extra values kept across the OAuth round trip, returned in `info.state`. */
47
+ state?: Record<string, string>;
48
+ }
49
+ /** Provider tokens from an OAuth token endpoint. */
50
+ interface OAuthTokens {
51
+ accessToken: string;
52
+ tokenType?: string;
53
+ refreshToken?: string;
54
+ /** Seconds, as sent by the provider. */
55
+ expiresIn?: number;
56
+ /** Absolute expiry (ms since epoch), computed from `expiresIn`. */
57
+ expiresAt?: number;
58
+ idToken?: string;
59
+ scope?: string;
60
+ raw: Record<string, unknown>;
61
+ }
62
+ /** A user profile from an identity provider, normalized. */
63
+ interface Profile {
64
+ provider: string;
65
+ id: string;
66
+ email?: string;
67
+ emailVerified?: boolean;
68
+ username?: string;
69
+ name?: string;
70
+ firstName?: string;
71
+ lastName?: string;
72
+ avatarUrl?: string;
73
+ raw: Record<string, unknown>;
74
+ }
75
+ /** A value, or a function of the Worker/Node env that returns it (`(env) => env.GITHUB_SECRET`). */
76
+ type EnvValue<T> = T | ((env: Record<string, any>) => T);
77
+
78
+ /**
79
+ * A way to authenticate a request (Passport.js's `Strategy`). `authenticate()` returns what happened:
80
+ * success, fail, redirect, pass (not for me), error, or a response of its own.
81
+ */
82
+ declare abstract class Strategy {
83
+ abstract readonly name: string;
84
+ abstract authenticate(c: Context, options: AuthenticateOptions): Promise<StrategyResult>;
85
+ protected success(user: unknown, info?: Record<string, unknown>): StrategyResult;
86
+ protected fail(message?: string, status?: number, challenge?: string): StrategyResult;
87
+ protected redirect(url: string, status?: number): StrategyResult;
88
+ protected pass(): StrategyResult;
89
+ }
90
+ /** A one-off strategy from a function: `new CustomStrategy('header', async (c) => …)`. */
91
+ declare class CustomStrategy extends Strategy {
92
+ readonly name: string;
93
+ private readonly fn;
94
+ constructor(name: string, fn: (c: Context, options: AuthenticateOptions) => Promise<StrategyResult> | StrategyResult);
95
+ authenticate(c: Context, options: AuthenticateOptions): Promise<StrategyResult>;
96
+ }
97
+ /** The strategy registry (Passport's `passport.use()`). */
98
+ declare const strategies: {
99
+ use(name: string, strategy: Strategy): void;
100
+ get(name: string): Strategy;
101
+ has(name: string): boolean;
102
+ unuse(name: string): boolean;
103
+ names(): string[];
104
+ };
105
+ /** A strategy instance or a registered name. */
106
+ type StrategyRef = string | Strategy;
107
+ declare function resolveStrategy(ref: StrategyRef): Strategy;
108
+
109
+ export { type AuthenticateOptions as A, CustomStrategy as C, type EnvValue as E, type OAuthTokens as O, type Profile as P, type StrategyResult as S, type StrategyRef as a, type StrategyUser as b, Strategy as c, resolveStrategy as r, strategies as s };
@@ -0,0 +1,68 @@
1
+ interface MockProviderOptions {
2
+ /** Base URL of the fake provider (default `https://provider.test`); its OIDC issuer. */
3
+ issuer?: string;
4
+ /** Claims / userinfo document of the user who "logs in" (OIDC style: `sub`, `email`, …). */
5
+ profile?: Record<string, any>;
6
+ /** Extra fields for token responses. */
7
+ tokenResponse?: Record<string, unknown>;
8
+ /** Extra URLs answered for authenticated GETs (provider APIs such as `https://api.github.com/user`). */
9
+ routes?: Record<string, unknown | ((req: Request) => unknown | Promise<unknown>)>;
10
+ /** Treat any token endpoint POST as this provider's (default true). */
11
+ anyTokenEndpoint?: boolean;
12
+ }
13
+ /**
14
+ * ```ts
15
+ * const provider = mockOAuthProvider({ profile: { sub: '42', email: 'ann@example.com', email_verified: true } })
16
+ * const res = await followOAuth(client, '/login/acme/', provider)
17
+ * provider.restore()
18
+ * ```
19
+ */
20
+ declare class MockProvider {
21
+ readonly options: MockProviderOptions;
22
+ readonly issuer: string;
23
+ profile: Record<string, any>;
24
+ readonly requests: Array<{
25
+ method: string;
26
+ url: string;
27
+ }>;
28
+ private readonly codes;
29
+ private readonly accessTokens;
30
+ private keys?;
31
+ constructor(options?: MockProviderOptions);
32
+ get endpoints(): {
33
+ authorization: string;
34
+ token: string;
35
+ userinfo: string;
36
+ jwks: string;
37
+ revocation: string;
38
+ };
39
+ private signingKey;
40
+ /**
41
+ * Act as the user approving the request at `authorizeUrl`: returns the callback URL (with `code` and `state`, or
42
+ * `error=access_denied` when `deny`).
43
+ */
44
+ authorize(authorizeUrl: string, opts?: {
45
+ deny?: boolean;
46
+ }): string;
47
+ private tokenResponse;
48
+ private authed;
49
+ private handle;
50
+ /** Stop intercepting provider requests and forget cached discovery documents and keys. */
51
+ restore(): void;
52
+ }
53
+ declare function mockOAuthProvider(options?: MockProviderOptions): MockProvider;
54
+ interface TestClientLike {
55
+ get(path: string, opts?: any): Promise<Response>;
56
+ post?(path: string, opts?: any): Promise<Response>;
57
+ }
58
+ /**
59
+ * Run the redirect round trip with the core test client: request `startPath` (GET, or POST with `method`), approve
60
+ * at the mock provider, then request the callback. Returns the callback's response.
61
+ */
62
+ declare function followOAuth(client: TestClientLike, startPath: string, provider: MockProvider, opts?: {
63
+ deny?: boolean;
64
+ method?: 'GET' | 'POST';
65
+ body?: Record<string, unknown>;
66
+ }): Promise<Response>;
67
+
68
+ export { MockProvider, type MockProviderOptions, followOAuth, mockOAuthProvider };
@@ -0,0 +1,191 @@
1
+ import {
2
+ b64url,
3
+ clearDiscoveryCache,
4
+ clearJwksCache,
5
+ randomToken,
6
+ setProviderFetch,
7
+ sha256,
8
+ signJwt
9
+ } from "./chunk-TSKPVGEC.js";
10
+ import "./chunk-IS4XX7EU.js";
11
+
12
+ // src/testing.ts
13
+ function json(data, status = 200) {
14
+ return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
15
+ }
16
+ var MockProvider = class {
17
+ constructor(options = {}) {
18
+ this.options = options;
19
+ this.issuer = (options.issuer ?? "https://provider.test").replace(/\/+$/, "");
20
+ this.profile = options.profile ?? {
21
+ sub: "1",
22
+ email: "user@example.com",
23
+ email_verified: true,
24
+ name: "Test User"
25
+ };
26
+ setProviderFetch((input, init) => this.handle(new Request(input, init)));
27
+ }
28
+ options;
29
+ issuer;
30
+ profile;
31
+ requests = [];
32
+ codes = /* @__PURE__ */ new Map();
33
+ accessTokens = /* @__PURE__ */ new Set();
34
+ keys;
35
+ get endpoints() {
36
+ return {
37
+ authorization: `${this.issuer}/authorize`,
38
+ token: `${this.issuer}/token`,
39
+ userinfo: `${this.issuer}/userinfo`,
40
+ jwks: `${this.issuer}/jwks`,
41
+ revocation: `${this.issuer}/revoke`
42
+ };
43
+ }
44
+ signingKey() {
45
+ this.keys ??= (async () => {
46
+ const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
47
+ "sign",
48
+ "verify"
49
+ ]);
50
+ const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
51
+ return { privateKey: pair.privateKey, jwk: { ...jwk, kid: "mock-1", use: "sig", alg: "ES256" } };
52
+ })();
53
+ return this.keys;
54
+ }
55
+ /**
56
+ * Act as the user approving the request at `authorizeUrl`: returns the callback URL (with `code` and `state`, or
57
+ * `error=access_denied` when `deny`).
58
+ */
59
+ authorize(authorizeUrl, opts = {}) {
60
+ const u = new URL(authorizeUrl);
61
+ const q = u.searchParams;
62
+ const redirectUri = q.get("redirect_uri");
63
+ if (!redirectUri) throw new Error("The authorization URL has no redirect_uri.");
64
+ const back = new URL(redirectUri);
65
+ if (q.get("state")) back.searchParams.set("state", q.get("state"));
66
+ if (opts.deny) {
67
+ back.searchParams.set("error", "access_denied");
68
+ return back.toString();
69
+ }
70
+ const code = randomToken(16);
71
+ this.codes.set(code, {
72
+ clientId: q.get("client_id") ?? "",
73
+ redirectUri,
74
+ nonce: q.get("nonce") ?? void 0,
75
+ challenge: q.get("code_challenge") ?? void 0,
76
+ scope: (q.get("scope") ?? "").split(/[ ,]+/).filter(Boolean)
77
+ });
78
+ back.searchParams.set("code", code);
79
+ return back.toString();
80
+ }
81
+ async tokenResponse(form) {
82
+ const code = form.get("code") ?? "";
83
+ const issued = this.codes.get(code);
84
+ if (!issued) return json({ error: "invalid_grant", error_description: "Unknown or used code." }, 400);
85
+ this.codes.delete(code);
86
+ if (form.get("redirect_uri") !== issued.redirectUri)
87
+ return json({ error: "invalid_grant", error_description: "redirect_uri mismatch" }, 400);
88
+ if (issued.challenge) {
89
+ const verifier = form.get("code_verifier") ?? "";
90
+ if (b64url(await sha256(verifier)) !== issued.challenge) {
91
+ return json({ error: "invalid_grant", error_description: "PKCE verification failed" }, 400);
92
+ }
93
+ }
94
+ const accessToken = randomToken(16);
95
+ this.accessTokens.add(accessToken);
96
+ const body = {
97
+ access_token: accessToken,
98
+ token_type: "Bearer",
99
+ expires_in: 3600,
100
+ refresh_token: randomToken(16),
101
+ scope: issued.scope.join(" "),
102
+ ...this.options.tokenResponse
103
+ };
104
+ if (issued.scope.includes("openid")) {
105
+ const now = Math.floor(Date.now() / 1e3);
106
+ const { privateKey } = await this.signingKey();
107
+ body.id_token = await signJwt(
108
+ {
109
+ iss: this.issuer,
110
+ aud: issued.clientId,
111
+ iat: now,
112
+ exp: now + 600,
113
+ ...issued.nonce ? { nonce: issued.nonce } : {},
114
+ ...this.profile,
115
+ sub: String(this.profile.sub ?? this.profile.id)
116
+ },
117
+ privateKey,
118
+ "ES256",
119
+ { kid: "mock-1" }
120
+ );
121
+ }
122
+ return json(body);
123
+ }
124
+ authed(req) {
125
+ const m = /^Bearer\s+(\S+)$/i.exec(req.headers.get("authorization") ?? "");
126
+ return !!m && this.accessTokens.has(m[1]);
127
+ }
128
+ async handle(req) {
129
+ this.requests.push({ method: req.method, url: req.url });
130
+ const url = req.url.split("?")[0];
131
+ if (req.method === "POST") {
132
+ const form = new URLSearchParams(await req.text());
133
+ if (url === `${this.issuer}/revoke`) return new Response(null, { status: 200 });
134
+ if (url === this.endpoints.token || this.options.anyTokenEndpoint !== false && form.get("grant_type")) {
135
+ if (form.get("grant_type") === "refresh_token") {
136
+ const accessToken = randomToken(16);
137
+ this.accessTokens.add(accessToken);
138
+ return json({ access_token: accessToken, token_type: "Bearer", expires_in: 3600 });
139
+ }
140
+ return this.tokenResponse(form);
141
+ }
142
+ }
143
+ if (url === `${this.issuer}/.well-known/openid-configuration`) {
144
+ return json({
145
+ issuer: this.issuer,
146
+ authorization_endpoint: this.endpoints.authorization,
147
+ token_endpoint: this.endpoints.token,
148
+ userinfo_endpoint: this.endpoints.userinfo,
149
+ jwks_uri: this.endpoints.jwks,
150
+ revocation_endpoint: this.endpoints.revocation
151
+ });
152
+ }
153
+ if (url === this.endpoints.jwks) return json({ keys: [(await this.signingKey()).jwk] });
154
+ const route = this.options.routes?.[url] ?? this.options.routes?.[req.url];
155
+ if (route !== void 0 || url === this.endpoints.userinfo) {
156
+ if (!this.authed(req)) return json({ error: "invalid_token" }, 401);
157
+ const value = route === void 0 ? this.profile : typeof route === "function" ? await route(req) : route;
158
+ return value instanceof Response ? value : json(value);
159
+ }
160
+ return json({ error: "not_found", url: req.url }, 404);
161
+ }
162
+ /** Stop intercepting provider requests and forget cached discovery documents and keys. */
163
+ restore() {
164
+ setProviderFetch(void 0);
165
+ clearDiscoveryCache();
166
+ clearJwksCache();
167
+ }
168
+ };
169
+ function mockOAuthProvider(options = {}) {
170
+ clearDiscoveryCache();
171
+ clearJwksCache();
172
+ return new MockProvider(options);
173
+ }
174
+ function pathOf(url) {
175
+ const u = new URL(url, "http://testserver");
176
+ return `${u.pathname}${u.search}`;
177
+ }
178
+ async function followOAuth(client, startPath, provider, opts = {}) {
179
+ const start = opts.method === "POST" && client.post ? await client.post(startPath, { form: opts.body ?? {} }) : await client.get(startPath);
180
+ const location = start.headers.get("location");
181
+ if (start.status < 300 || start.status >= 400 || !location) {
182
+ throw new Error(`Expected a redirect to the provider from ${startPath}, got ${start.status}.`);
183
+ }
184
+ return client.get(pathOf(provider.authorize(location, opts)));
185
+ }
186
+ export {
187
+ MockProvider,
188
+ followOAuth,
189
+ mockOAuthProvider
190
+ };
191
+ //# sourceMappingURL=testing.js.map