@ghuri/oauth 0.0.0-stage → 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/dist/chunk-IS4XX7EU.js +68 -0
- package/dist/chunk-IS4XX7EU.js.map +1 -0
- package/dist/chunk-OIBPPYBX.js +112 -0
- package/dist/chunk-OIBPPYBX.js.map +1 -0
- package/dist/chunk-TSKPVGEC.js +717 -0
- package/dist/chunk-TSKPVGEC.js.map +1 -0
- package/dist/index.d.ts +338 -0
- package/dist/index.js +228 -0
- package/dist/index.js.map +1 -0
- package/dist/rest.d.ts +18 -0
- package/dist/rest.js +33 -0
- package/dist/rest.js.map +1 -0
- package/dist/strategy-nxNiKJFS.d.ts +109 -0
- package/dist/testing.d.ts +68 -0
- package/dist/testing.js +191 -0
- package/dist/testing.js.map +1 -0
- package/package.json +51 -4
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Strategy
|
|
3
|
+
} from "./chunk-IS4XX7EU.js";
|
|
4
|
+
|
|
5
|
+
// src/errors.ts
|
|
6
|
+
import { GhuriError } from "@ghuri/core";
|
|
7
|
+
var OAuthError = class extends GhuriError {
|
|
8
|
+
constructor(message, code, details) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.details = details;
|
|
12
|
+
}
|
|
13
|
+
code;
|
|
14
|
+
details;
|
|
15
|
+
};
|
|
16
|
+
var TokenValidationError = class extends OAuthError {
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// src/http.ts
|
|
20
|
+
import { tryGetContext } from "@ghuri/core";
|
|
21
|
+
var KEY = /* @__PURE__ */ Symbol.for("ghuri.oauth.fetch");
|
|
22
|
+
function setProviderFetch(fn) {
|
|
23
|
+
;
|
|
24
|
+
globalThis[KEY] = fn;
|
|
25
|
+
}
|
|
26
|
+
function providerFetch(input, init = {}) {
|
|
27
|
+
const fn = globalThis[KEY] ?? fetch;
|
|
28
|
+
const timeout = tryGetContext()?.settings.OAUTH?.HTTP_TIMEOUT ?? 1e4;
|
|
29
|
+
const headers = new Headers(init.headers);
|
|
30
|
+
if (!headers.has("user-agent")) headers.set("user-agent", "ghuri-oauth");
|
|
31
|
+
return fn(input, { ...init, headers, signal: init.signal ?? AbortSignal.timeout(timeout) });
|
|
32
|
+
}
|
|
33
|
+
async function readBody(res) {
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
const type = res.headers.get("content-type") ?? "";
|
|
36
|
+
if (type.includes("application/x-www-form-urlencoded") || !type.includes("json") && text.includes("=") && !text.trim().startsWith("{")) {
|
|
37
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
return text ? JSON.parse(text) : {};
|
|
41
|
+
} catch {
|
|
42
|
+
throw new OAuthError(
|
|
43
|
+
`Provider answered ${res.status} with a body that is not JSON.`,
|
|
44
|
+
"invalid_response",
|
|
45
|
+
text.slice(0, 200)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function getJson(url, init = {}) {
|
|
50
|
+
const headers = new Headers(init.headers);
|
|
51
|
+
if (!headers.has("accept")) headers.set("accept", "application/json");
|
|
52
|
+
const res = await providerFetch(url, { ...init, headers });
|
|
53
|
+
const body = await readBody(res);
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
throw new OAuthError(
|
|
56
|
+
`GET ${new URL(url).origin}${new URL(url).pathname} failed with ${res.status}.`,
|
|
57
|
+
body.error ?? "http_error",
|
|
58
|
+
body
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return body;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/client.ts
|
|
65
|
+
function toTokens(body) {
|
|
66
|
+
const accessToken = body.access_token;
|
|
67
|
+
if (typeof accessToken !== "string" || !accessToken) {
|
|
68
|
+
throw new OAuthError(
|
|
69
|
+
String(body.error_description ?? body.error ?? "The provider did not return an access token."),
|
|
70
|
+
typeof body.error === "string" ? body.error : "invalid_token_response",
|
|
71
|
+
body
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const expiresIn = body.expires_in === void 0 ? void 0 : Number(body.expires_in);
|
|
75
|
+
return {
|
|
76
|
+
accessToken,
|
|
77
|
+
tokenType: typeof body.token_type === "string" ? body.token_type : void 0,
|
|
78
|
+
refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : void 0,
|
|
79
|
+
expiresIn: Number.isFinite(expiresIn) ? expiresIn : void 0,
|
|
80
|
+
expiresAt: Number.isFinite(expiresIn) ? Date.now() + expiresIn * 1e3 : void 0,
|
|
81
|
+
idToken: typeof body.id_token === "string" ? body.id_token : void 0,
|
|
82
|
+
scope: typeof body.scope === "string" ? body.scope : void 0,
|
|
83
|
+
raw: body
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
var OAuth2Client = class {
|
|
87
|
+
constructor(config) {
|
|
88
|
+
this.config = config;
|
|
89
|
+
}
|
|
90
|
+
config;
|
|
91
|
+
authorizationUrl(opts) {
|
|
92
|
+
const url = new URL(this.config.authorizationURL);
|
|
93
|
+
const q = url.searchParams;
|
|
94
|
+
q.set("response_type", "code");
|
|
95
|
+
q.set("client_id", this.config.clientId);
|
|
96
|
+
q.set("redirect_uri", opts.redirectUri);
|
|
97
|
+
q.set("state", opts.state);
|
|
98
|
+
if (opts.scope?.length) q.set("scope", opts.scope.join(this.config.scopeSeparator ?? " "));
|
|
99
|
+
if (opts.codeChallenge) {
|
|
100
|
+
q.set("code_challenge", opts.codeChallenge);
|
|
101
|
+
q.set("code_challenge_method", "S256");
|
|
102
|
+
}
|
|
103
|
+
if (opts.nonce) q.set("nonce", opts.nonce);
|
|
104
|
+
for (const [k, v] of Object.entries(opts.params ?? {})) q.set(k, v);
|
|
105
|
+
return url.toString();
|
|
106
|
+
}
|
|
107
|
+
async tokenRequest(form) {
|
|
108
|
+
const body = new URLSearchParams(form);
|
|
109
|
+
const headers = {
|
|
110
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
111
|
+
accept: "application/json"
|
|
112
|
+
};
|
|
113
|
+
if (this.config.tokenAuth === "basic" && this.config.clientSecret !== void 0) {
|
|
114
|
+
const id = encodeURIComponent(this.config.clientId);
|
|
115
|
+
const secret = encodeURIComponent(this.config.clientSecret);
|
|
116
|
+
headers.authorization = `Basic ${btoa(`${id}:${secret}`)}`;
|
|
117
|
+
} else {
|
|
118
|
+
body.set("client_id", this.config.clientId);
|
|
119
|
+
if (this.config.clientSecret !== void 0) body.set("client_secret", this.config.clientSecret);
|
|
120
|
+
}
|
|
121
|
+
const res = await providerFetch(this.config.tokenURL, { method: "POST", headers, body });
|
|
122
|
+
const data = await readBody(res);
|
|
123
|
+
if (!res.ok && !data.error) {
|
|
124
|
+
throw new OAuthError(`The token endpoint answered ${res.status}.`, "http_error", data);
|
|
125
|
+
}
|
|
126
|
+
return toTokens(data);
|
|
127
|
+
}
|
|
128
|
+
exchangeCode(code, redirectUri, codeVerifier) {
|
|
129
|
+
const form = { grant_type: "authorization_code", code, redirect_uri: redirectUri };
|
|
130
|
+
if (codeVerifier) form.code_verifier = codeVerifier;
|
|
131
|
+
return this.tokenRequest(form);
|
|
132
|
+
}
|
|
133
|
+
refresh(refreshToken, scope) {
|
|
134
|
+
const form = { grant_type: "refresh_token", refresh_token: refreshToken };
|
|
135
|
+
if (scope?.length) form.scope = scope.join(this.config.scopeSeparator ?? " ");
|
|
136
|
+
return this.tokenRequest(form);
|
|
137
|
+
}
|
|
138
|
+
/** RFC 7009 token revocation (needs `revocationURL`). */
|
|
139
|
+
async revoke(token, hint) {
|
|
140
|
+
if (!this.config.revocationURL) throw new OAuthError("This provider has no revocation endpoint.");
|
|
141
|
+
const body = new URLSearchParams({ token, client_id: this.config.clientId });
|
|
142
|
+
if (hint) body.set("token_type_hint", hint);
|
|
143
|
+
if (this.config.clientSecret !== void 0) body.set("client_secret", this.config.clientSecret);
|
|
144
|
+
const res = await providerFetch(this.config.revocationURL, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
147
|
+
body
|
|
148
|
+
});
|
|
149
|
+
if (!res.ok) throw new OAuthError(`Token revocation failed with ${res.status}.`, "revocation_failed");
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// src/crypto.ts
|
|
154
|
+
import { ImproperlyConfigured, tryGetContext as tryGetContext2 } from "@ghuri/core";
|
|
155
|
+
var enc = new TextEncoder();
|
|
156
|
+
var dec = new TextDecoder();
|
|
157
|
+
function b64url(bytes) {
|
|
158
|
+
const u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
159
|
+
let s = "";
|
|
160
|
+
for (const b of u) s += String.fromCharCode(b);
|
|
161
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
162
|
+
}
|
|
163
|
+
function fromB64url(value) {
|
|
164
|
+
const s = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
165
|
+
const bin = atob(s + "=".repeat((4 - s.length % 4) % 4));
|
|
166
|
+
const out = new Uint8Array(bin.length);
|
|
167
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
function utf8(value) {
|
|
171
|
+
return enc.encode(value);
|
|
172
|
+
}
|
|
173
|
+
function fromUtf8(bytes) {
|
|
174
|
+
return dec.decode(bytes);
|
|
175
|
+
}
|
|
176
|
+
function randomToken(bytes = 32) {
|
|
177
|
+
return b64url(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
178
|
+
}
|
|
179
|
+
async function sha256(value) {
|
|
180
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", utf8(value)));
|
|
181
|
+
}
|
|
182
|
+
async function pkcePair() {
|
|
183
|
+
const verifier = randomToken(32);
|
|
184
|
+
return { verifier, challenge: b64url(await sha256(verifier)) };
|
|
185
|
+
}
|
|
186
|
+
function secretKey() {
|
|
187
|
+
const key = tryGetContext2()?.settings.SECRET_KEY;
|
|
188
|
+
if (!key) throw new ImproperlyConfigured("The SECRET_KEY setting must not be empty.");
|
|
189
|
+
return key;
|
|
190
|
+
}
|
|
191
|
+
function fallbackKeys() {
|
|
192
|
+
return tryGetContext2()?.settings.SECRET_KEY_FALLBACKS ?? [];
|
|
193
|
+
}
|
|
194
|
+
var keyCache = /* @__PURE__ */ new Map();
|
|
195
|
+
async function aesKey(secret, purpose) {
|
|
196
|
+
const id = `${purpose}\0${secret}`;
|
|
197
|
+
let k = keyCache.get(id);
|
|
198
|
+
if (!k) {
|
|
199
|
+
k = (async () => {
|
|
200
|
+
const base = await crypto.subtle.importKey("raw", utf8(secret), "HKDF", false, ["deriveKey"]);
|
|
201
|
+
return crypto.subtle.deriveKey(
|
|
202
|
+
{ name: "HKDF", hash: "SHA-256", salt: utf8("ghuri.oauth"), info: utf8(purpose) },
|
|
203
|
+
base,
|
|
204
|
+
{ name: "AES-GCM", length: 256 },
|
|
205
|
+
false,
|
|
206
|
+
["encrypt", "decrypt"]
|
|
207
|
+
);
|
|
208
|
+
})();
|
|
209
|
+
if (keyCache.size > 32) keyCache.clear();
|
|
210
|
+
keyCache.set(id, k);
|
|
211
|
+
}
|
|
212
|
+
return k;
|
|
213
|
+
}
|
|
214
|
+
async function seal(plaintext, purpose) {
|
|
215
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
216
|
+
const key = await aesKey(secretKey(), purpose);
|
|
217
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, utf8(plaintext)));
|
|
218
|
+
const out = new Uint8Array(iv.length + ct.length);
|
|
219
|
+
out.set(iv);
|
|
220
|
+
out.set(ct, iv.length);
|
|
221
|
+
return b64url(out);
|
|
222
|
+
}
|
|
223
|
+
async function unseal(sealed, purpose) {
|
|
224
|
+
let data;
|
|
225
|
+
try {
|
|
226
|
+
data = fromB64url(sealed);
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
if (data.length < 13) return null;
|
|
231
|
+
const iv = data.slice(0, 12);
|
|
232
|
+
const ct = data.slice(12);
|
|
233
|
+
for (const secret of [secretKey(), ...fallbackKeys()]) {
|
|
234
|
+
try {
|
|
235
|
+
const key = await aesKey(secret, purpose);
|
|
236
|
+
return fromUtf8(new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct)));
|
|
237
|
+
} catch {
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
function timingSafeEqual(a, b) {
|
|
243
|
+
if (a.length !== b.length) return false;
|
|
244
|
+
let r = 0;
|
|
245
|
+
for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
246
|
+
return r === 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/jwt.ts
|
|
250
|
+
var SUPPORTED_ALGORITHMS = [
|
|
251
|
+
"RS256",
|
|
252
|
+
"RS384",
|
|
253
|
+
"RS512",
|
|
254
|
+
"PS256",
|
|
255
|
+
"PS384",
|
|
256
|
+
"PS512",
|
|
257
|
+
"ES256",
|
|
258
|
+
"ES384",
|
|
259
|
+
"ES512",
|
|
260
|
+
"EdDSA"
|
|
261
|
+
];
|
|
262
|
+
function params(alg) {
|
|
263
|
+
const hash = `SHA-${alg.slice(2)}`;
|
|
264
|
+
switch (alg.slice(0, 2)) {
|
|
265
|
+
case "RS":
|
|
266
|
+
return {
|
|
267
|
+
importParams: { name: "RSASSA-PKCS1-v1_5", hash },
|
|
268
|
+
verifyParams: { name: "RSASSA-PKCS1-v1_5" }
|
|
269
|
+
};
|
|
270
|
+
case "PS":
|
|
271
|
+
return {
|
|
272
|
+
importParams: { name: "RSA-PSS", hash },
|
|
273
|
+
verifyParams: { name: "RSA-PSS", saltLength: Number(alg.slice(2)) / 8 }
|
|
274
|
+
};
|
|
275
|
+
case "ES": {
|
|
276
|
+
const namedCurve = { ES256: "P-256", ES384: "P-384", ES512: "P-521" }[alg];
|
|
277
|
+
return { importParams: { name: "ECDSA", namedCurve }, verifyParams: { name: "ECDSA", hash } };
|
|
278
|
+
}
|
|
279
|
+
default:
|
|
280
|
+
return { importParams: { name: "Ed25519" }, verifyParams: { name: "Ed25519" } };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function decodeJwt(token) {
|
|
284
|
+
const parts = token.split(".");
|
|
285
|
+
if (parts.length !== 3) throw new TokenValidationError("Malformed JWT.");
|
|
286
|
+
try {
|
|
287
|
+
return {
|
|
288
|
+
header: JSON.parse(fromUtf8(fromB64url(parts[0]))),
|
|
289
|
+
payload: JSON.parse(fromUtf8(fromB64url(parts[1]))),
|
|
290
|
+
signingInput: `${parts[0]}.${parts[1]}`,
|
|
291
|
+
signature: fromB64url(parts[2])
|
|
292
|
+
};
|
|
293
|
+
} catch {
|
|
294
|
+
throw new TokenValidationError("Malformed JWT.");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
var JWKS_TTL = 60 * 60 * 1e3;
|
|
298
|
+
var JWKS_MIN_REFRESH = 60 * 1e3;
|
|
299
|
+
var jwksCache = /* @__PURE__ */ new Map();
|
|
300
|
+
async function fetchJwks(uri) {
|
|
301
|
+
const doc = await getJson(uri);
|
|
302
|
+
const keys = Array.isArray(doc.keys) ? doc.keys : [];
|
|
303
|
+
jwksCache.set(uri, { keys, fetchedAt: Date.now() });
|
|
304
|
+
return keys;
|
|
305
|
+
}
|
|
306
|
+
async function findKey(uri, kid, kty) {
|
|
307
|
+
const pick = (keys) => keys.find((k) => (kid === void 0 || k.kid === kid) && k.kty === kty && (!k.use || k.use === "sig"));
|
|
308
|
+
const cached = jwksCache.get(uri);
|
|
309
|
+
if (cached && Date.now() - cached.fetchedAt < JWKS_TTL) {
|
|
310
|
+
const k = pick(cached.keys);
|
|
311
|
+
if (k) return k;
|
|
312
|
+
if (Date.now() - cached.fetchedAt < JWKS_MIN_REFRESH) return void 0;
|
|
313
|
+
}
|
|
314
|
+
return pick(await fetchJwks(uri));
|
|
315
|
+
}
|
|
316
|
+
function clearJwksCache() {
|
|
317
|
+
jwksCache.clear();
|
|
318
|
+
}
|
|
319
|
+
function ktyFor(alg) {
|
|
320
|
+
return alg.startsWith("ES") ? "EC" : alg === "EdDSA" ? "OKP" : "RSA";
|
|
321
|
+
}
|
|
322
|
+
async function verifyJwt(token, opts) {
|
|
323
|
+
const { header, payload, signingInput, signature } = decodeJwt(token);
|
|
324
|
+
const alg = header.alg;
|
|
325
|
+
if (!(opts.algorithms ?? SUPPORTED_ALGORITHMS).includes(alg)) {
|
|
326
|
+
throw new TokenValidationError(`Unsupported or disallowed JWT algorithm '${header.alg}'.`);
|
|
327
|
+
}
|
|
328
|
+
const kty = ktyFor(alg);
|
|
329
|
+
let jwk;
|
|
330
|
+
if (opts.keys)
|
|
331
|
+
jwk = opts.keys.find((k) => (header.kid === void 0 || k.kid === header.kid) && k.kty === kty);
|
|
332
|
+
else if (opts.jwksUri) jwk = await findKey(opts.jwksUri, header.kid, kty);
|
|
333
|
+
if (!jwk) throw new TokenValidationError(`No signing key found for kid '${header.kid ?? ""}'.`);
|
|
334
|
+
const { importParams, verifyParams } = params(alg);
|
|
335
|
+
let ok;
|
|
336
|
+
try {
|
|
337
|
+
const { alg: _a, use: _u, key_ops: _k, ...bare } = jwk;
|
|
338
|
+
const key = await crypto.subtle.importKey("jwk", bare, importParams, false, ["verify"]);
|
|
339
|
+
ok = await crypto.subtle.verify(verifyParams, key, signature, utf8(signingInput));
|
|
340
|
+
} catch {
|
|
341
|
+
ok = false;
|
|
342
|
+
}
|
|
343
|
+
if (!ok) throw new TokenValidationError("Invalid JWT signature.");
|
|
344
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
345
|
+
const leeway = opts.leeway ?? 60;
|
|
346
|
+
if (opts.issuer !== void 0) {
|
|
347
|
+
const iss = String(payload.iss ?? "");
|
|
348
|
+
const good = typeof opts.issuer === "function" ? opts.issuer(iss) : iss === opts.issuer;
|
|
349
|
+
if (!good) throw new TokenValidationError(`Unexpected issuer '${iss}'.`);
|
|
350
|
+
}
|
|
351
|
+
if (opts.audience !== void 0) {
|
|
352
|
+
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
353
|
+
if (!aud.includes(opts.audience))
|
|
354
|
+
throw new TokenValidationError("The token was issued for another client.");
|
|
355
|
+
if (aud.length > 1 && payload.azp !== void 0 && payload.azp !== opts.audience) {
|
|
356
|
+
throw new TokenValidationError("The token was issued for another client (azp).");
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (typeof payload.exp !== "number" || payload.exp + leeway < now)
|
|
360
|
+
throw new TokenValidationError("The token expired.");
|
|
361
|
+
if (typeof payload.nbf === "number" && payload.nbf - leeway > now)
|
|
362
|
+
throw new TokenValidationError("The token is not valid yet.");
|
|
363
|
+
if (typeof payload.iat === "number" && payload.iat - leeway > now)
|
|
364
|
+
throw new TokenValidationError("The token was issued in the future.");
|
|
365
|
+
if (opts.nonce !== void 0 && payload.nonce !== opts.nonce)
|
|
366
|
+
throw new TokenValidationError("Nonce mismatch.");
|
|
367
|
+
return payload;
|
|
368
|
+
}
|
|
369
|
+
async function signJwt(payload, key, alg, header = {}) {
|
|
370
|
+
const { importParams, verifyParams } = params(alg);
|
|
371
|
+
const cryptoKey = key instanceof CryptoKey ? key : await crypto.subtle.importKey(
|
|
372
|
+
"jwk",
|
|
373
|
+
(({ alg: _a, use: _u, key_ops: _k, ...bare }) => bare)(key),
|
|
374
|
+
importParams,
|
|
375
|
+
false,
|
|
376
|
+
["sign"]
|
|
377
|
+
);
|
|
378
|
+
const h = b64url(utf8(JSON.stringify({ alg, typ: "JWT", ...header })));
|
|
379
|
+
const p = b64url(utf8(JSON.stringify(payload)));
|
|
380
|
+
const sig = await crypto.subtle.sign(verifyParams, cryptoKey, utf8(`${h}.${p}`));
|
|
381
|
+
return `${h}.${p}.${b64url(sig)}`;
|
|
382
|
+
}
|
|
383
|
+
async function importPkcs8(pem, alg) {
|
|
384
|
+
const body = pem.replace(/-----(BEGIN|END) PRIVATE KEY-----/g, "").replace(/\s+/g, "");
|
|
385
|
+
const der = Uint8Array.from(atob(body), (ch) => ch.charCodeAt(0));
|
|
386
|
+
return crypto.subtle.importKey("pkcs8", der, params(alg).importParams, false, ["sign"]);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// src/state.ts
|
|
390
|
+
import { isSecure, tryGetContext as tryGetContext3 } from "@ghuri/core";
|
|
391
|
+
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
392
|
+
var PURPOSE = "oauth-state";
|
|
393
|
+
var MAX_FLOWS = 5;
|
|
394
|
+
function stateSettings() {
|
|
395
|
+
const s = tryGetContext3()?.settings.OAUTH ?? {};
|
|
396
|
+
return { cookie: s.STATE_COOKIE ?? "ghuri_oauth", maxAge: s.STATE_MAX_AGE ?? 600 };
|
|
397
|
+
}
|
|
398
|
+
async function read(c) {
|
|
399
|
+
const { cookie, maxAge } = stateSettings();
|
|
400
|
+
const raw = getCookie(c, cookie);
|
|
401
|
+
if (!raw) return [];
|
|
402
|
+
const json = await unseal(raw, PURPOSE);
|
|
403
|
+
if (!json) return [];
|
|
404
|
+
try {
|
|
405
|
+
const flows = JSON.parse(json);
|
|
406
|
+
const now = Date.now();
|
|
407
|
+
return Array.isArray(flows) ? flows.filter((f) => f && now - f.createdAt <= maxAge * 1e3) : [];
|
|
408
|
+
} catch {
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
async function write(c, flows) {
|
|
413
|
+
const { cookie, maxAge } = stateSettings();
|
|
414
|
+
if (!flows.length) {
|
|
415
|
+
deleteCookie(c, cookie, { path: "/", secure: isSecure(c) });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
setCookie(c, cookie, await seal(JSON.stringify(flows), PURPOSE), {
|
|
419
|
+
path: "/",
|
|
420
|
+
httpOnly: true,
|
|
421
|
+
secure: isSecure(c),
|
|
422
|
+
// Lax: the provider's redirect back is a top-level GET navigation, which Lax cookies accompany.
|
|
423
|
+
sameSite: "Lax",
|
|
424
|
+
maxAge
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
async function savePending(c, flow) {
|
|
428
|
+
const flows = (await read(c)).filter((f) => f.state !== flow.state);
|
|
429
|
+
flows.push(flow);
|
|
430
|
+
await write(c, flows.slice(-MAX_FLOWS));
|
|
431
|
+
}
|
|
432
|
+
async function takePending(c, state) {
|
|
433
|
+
if (!state) return null;
|
|
434
|
+
const flows = await read(c);
|
|
435
|
+
const flow = flows.find((f) => f.state === state) ?? null;
|
|
436
|
+
if (flow)
|
|
437
|
+
await write(
|
|
438
|
+
c,
|
|
439
|
+
flows.filter((f) => f !== flow)
|
|
440
|
+
);
|
|
441
|
+
return flow;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// src/oauth2.ts
|
|
445
|
+
import { getHost, isSecure as isSecure2, tryGetContext as tryGetContext4 } from "@ghuri/core";
|
|
446
|
+
async function envValue(value) {
|
|
447
|
+
if (typeof value === "function") {
|
|
448
|
+
return value(tryGetContext4()?.env ?? {});
|
|
449
|
+
}
|
|
450
|
+
return value;
|
|
451
|
+
}
|
|
452
|
+
function profileFromClaims(provider, raw) {
|
|
453
|
+
const id = raw.sub ?? raw.id ?? raw.user_id;
|
|
454
|
+
if (id === void 0 || id === null)
|
|
455
|
+
throw new OAuthError("The provider profile has no user id.", "invalid_profile");
|
|
456
|
+
const verified = raw.email_verified ?? raw.verified_email;
|
|
457
|
+
return {
|
|
458
|
+
provider,
|
|
459
|
+
id: String(id),
|
|
460
|
+
email: typeof raw.email === "string" ? raw.email : void 0,
|
|
461
|
+
emailVerified: verified === void 0 ? void 0 : verified === true || verified === "true",
|
|
462
|
+
username: raw.preferred_username ?? raw.login ?? raw.username ?? raw.nickname ?? void 0,
|
|
463
|
+
name: raw.name ?? void 0,
|
|
464
|
+
firstName: raw.given_name ?? raw.first_name ?? void 0,
|
|
465
|
+
lastName: raw.family_name ?? raw.last_name ?? void 0,
|
|
466
|
+
avatarUrl: raw.picture ?? raw.avatar_url ?? void 0,
|
|
467
|
+
raw
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
function origin(c) {
|
|
471
|
+
const base = tryGetContext4()?.settings.OAUTH?.CALLBACK_BASE_URL;
|
|
472
|
+
if (base) return base.replace(/\/+$/, "");
|
|
473
|
+
return `${isSecure2(c) ? "https" : "http"}://${getHost(c)}`;
|
|
474
|
+
}
|
|
475
|
+
var OAuth2Strategy = class extends Strategy {
|
|
476
|
+
constructor(options, verify) {
|
|
477
|
+
super();
|
|
478
|
+
this.options = options;
|
|
479
|
+
this.verify = verify;
|
|
480
|
+
this.name = options.name ?? "oauth2";
|
|
481
|
+
}
|
|
482
|
+
options;
|
|
483
|
+
verify;
|
|
484
|
+
name;
|
|
485
|
+
async client() {
|
|
486
|
+
const o = this.options;
|
|
487
|
+
return new OAuth2Client({
|
|
488
|
+
authorizationURL: await envValue(o.authorizationURL),
|
|
489
|
+
tokenURL: await envValue(o.tokenURL),
|
|
490
|
+
clientId: await envValue(o.clientId),
|
|
491
|
+
clientSecret: o.clientSecret === void 0 ? void 0 : await envValue(o.clientSecret),
|
|
492
|
+
tokenAuth: o.tokenAuth,
|
|
493
|
+
revocationURL: o.revocationURL === void 0 ? void 0 : await envValue(o.revocationURL),
|
|
494
|
+
scopeSeparator: o.scopeSeparator
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
async callbackUrl(c) {
|
|
498
|
+
const configured = this.options.callbackURL === void 0 ? void 0 : await envValue(this.options.callbackURL);
|
|
499
|
+
if (configured && /^https?:\/\//i.test(configured)) return configured;
|
|
500
|
+
return `${origin(c)}${configured ?? c.req.path}`;
|
|
501
|
+
}
|
|
502
|
+
/** Extra authorization parameters and flow fields; OIDC adds a nonce. */
|
|
503
|
+
async startExtras(_c) {
|
|
504
|
+
return {};
|
|
505
|
+
}
|
|
506
|
+
scopes(options) {
|
|
507
|
+
return [.../* @__PURE__ */ new Set([...this.options.scope ?? [], ...options.scope ?? []])];
|
|
508
|
+
}
|
|
509
|
+
async authenticate(c, options) {
|
|
510
|
+
const q = c.req.query();
|
|
511
|
+
if (q.error) {
|
|
512
|
+
await takePending(c, q.state);
|
|
513
|
+
const message = q.error === "access_denied" ? "You cancelled the login." : q.error_description || q.error;
|
|
514
|
+
return { type: "fail", status: 401, message };
|
|
515
|
+
}
|
|
516
|
+
if (q.code) return this.callback(c, q.code, q.state);
|
|
517
|
+
return this.start(c, options);
|
|
518
|
+
}
|
|
519
|
+
async start(c, options) {
|
|
520
|
+
const client = await this.client();
|
|
521
|
+
const state = randomToken(24);
|
|
522
|
+
const pkce = this.options.pkce === false ? void 0 : await pkcePair();
|
|
523
|
+
const extras = await this.startExtras(c);
|
|
524
|
+
const redirectUri = await this.callbackUrl(c);
|
|
525
|
+
const next = options.next ?? c.req.query("next");
|
|
526
|
+
await savePending(c, {
|
|
527
|
+
state,
|
|
528
|
+
strategy: this.name,
|
|
529
|
+
redirectUri,
|
|
530
|
+
verifier: pkce?.verifier,
|
|
531
|
+
nonce: extras.nonce,
|
|
532
|
+
next: next || void 0,
|
|
533
|
+
extra: options.state,
|
|
534
|
+
createdAt: Date.now()
|
|
535
|
+
});
|
|
536
|
+
const ap = this.options.authorizationParams;
|
|
537
|
+
const params2 = { ...typeof ap === "function" ? ap(c) : ap ?? {}, ...extras.params ?? {} };
|
|
538
|
+
const url = client.authorizationUrl({
|
|
539
|
+
redirectUri,
|
|
540
|
+
state,
|
|
541
|
+
scope: this.scopes(options),
|
|
542
|
+
codeChallenge: pkce?.challenge,
|
|
543
|
+
nonce: extras.nonce,
|
|
544
|
+
params: params2
|
|
545
|
+
});
|
|
546
|
+
return { type: "redirect", url, status: 302 };
|
|
547
|
+
}
|
|
548
|
+
async callback(c, code, state) {
|
|
549
|
+
const flow = await takePending(c, state);
|
|
550
|
+
if (!flow || flow.strategy !== this.name) {
|
|
551
|
+
return {
|
|
552
|
+
type: "fail",
|
|
553
|
+
status: 400,
|
|
554
|
+
message: "This login link expired or was already used. Please try again."
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
let tokens;
|
|
558
|
+
let profile;
|
|
559
|
+
try {
|
|
560
|
+
const client = await this.client();
|
|
561
|
+
tokens = await client.exchangeCode(code, flow.redirectUri, flow.verifier);
|
|
562
|
+
profile = await this.userProfile(tokens, c, flow);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
if (err instanceof OAuthError) return { type: "fail", status: 401, message: err.message };
|
|
565
|
+
throw err;
|
|
566
|
+
}
|
|
567
|
+
const result = await this.verify({ profile, tokens, c, flow });
|
|
568
|
+
if (!result) return { type: "fail", status: 401, message: "This account is not allowed to log in." };
|
|
569
|
+
const info = { profile, tokens, next: flow.next, state: flow.extra };
|
|
570
|
+
if (typeof result === "object" && "user" in result && Object.keys(result).every((k) => k === "user" || k === "info")) {
|
|
571
|
+
return { type: "success", user: result.user, info: { ...info, ...result.info ?? {} } };
|
|
572
|
+
}
|
|
573
|
+
return { type: "success", user: result, info };
|
|
574
|
+
}
|
|
575
|
+
profileContext(c, tokens) {
|
|
576
|
+
return {
|
|
577
|
+
c,
|
|
578
|
+
getJson,
|
|
579
|
+
getJsonAuthed: (url, init = {}) => {
|
|
580
|
+
const headers = new Headers(init.headers);
|
|
581
|
+
headers.set("authorization", `Bearer ${tokens.accessToken}`);
|
|
582
|
+
return getJson(url, { ...init, headers });
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
/** The normalized profile for these tokens. */
|
|
587
|
+
async userProfile(tokens, c, _flow) {
|
|
588
|
+
const ctx = this.profileContext(c, tokens);
|
|
589
|
+
if (this.options.profile) return this.options.profile(tokens, ctx);
|
|
590
|
+
if (!this.options.userInfoURL) {
|
|
591
|
+
throw new OAuthError(`Strategy '${this.name}' needs a userInfoURL or a profile() function.`);
|
|
592
|
+
}
|
|
593
|
+
return profileFromClaims(this.name, await ctx.getJsonAuthed(await envValue(this.options.userInfoURL)));
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
// src/oidc.ts
|
|
598
|
+
var DISCOVERY_TTL = 60 * 60 * 1e3;
|
|
599
|
+
var discoveryCache = /* @__PURE__ */ new Map();
|
|
600
|
+
async function discover(issuer) {
|
|
601
|
+
const key = issuer.replace(/\/+$/, "");
|
|
602
|
+
const hit = discoveryCache.get(key);
|
|
603
|
+
if (hit && Date.now() - hit.fetchedAt < DISCOVERY_TTL) return hit.doc;
|
|
604
|
+
const doc = await getJson(`${key}/.well-known/openid-configuration`);
|
|
605
|
+
for (const f of ["authorization_endpoint", "token_endpoint", "jwks_uri"]) {
|
|
606
|
+
if (typeof doc[f] !== "string") throw new OAuthError(`The discovery document of ${key} has no ${f}.`);
|
|
607
|
+
}
|
|
608
|
+
discoveryCache.set(key, { doc, fetchedAt: Date.now() });
|
|
609
|
+
return doc;
|
|
610
|
+
}
|
|
611
|
+
function clearDiscoveryCache() {
|
|
612
|
+
discoveryCache.clear();
|
|
613
|
+
}
|
|
614
|
+
var OIDCStrategy = class extends OAuth2Strategy {
|
|
615
|
+
constructor(options, verify) {
|
|
616
|
+
const lazy = (pick, override) => async () => override ?? pick(await discover(await envValue(options.issuer))) ?? "";
|
|
617
|
+
super(
|
|
618
|
+
{
|
|
619
|
+
...options,
|
|
620
|
+
name: options.name ?? "oidc",
|
|
621
|
+
scope: options.scope ?? ["openid", "email", "profile"],
|
|
622
|
+
authorizationURL: lazy(
|
|
623
|
+
(d) => d.authorization_endpoint,
|
|
624
|
+
options.endpoints?.authorization_endpoint
|
|
625
|
+
),
|
|
626
|
+
tokenURL: lazy((d) => d.token_endpoint, options.endpoints?.token_endpoint),
|
|
627
|
+
revocationURL: options.revocationURL ?? lazy((d) => d.revocation_endpoint, options.endpoints?.revocation_endpoint)
|
|
628
|
+
},
|
|
629
|
+
verify
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
async discovery() {
|
|
633
|
+
const e = this.options.endpoints;
|
|
634
|
+
const issuer = await envValue(this.options.issuer);
|
|
635
|
+
if (e?.authorization_endpoint && e.token_endpoint && e.jwks_uri) {
|
|
636
|
+
return { issuer, ...e };
|
|
637
|
+
}
|
|
638
|
+
return { ...await discover(issuer), ...e ?? {} };
|
|
639
|
+
}
|
|
640
|
+
async startExtras(_c) {
|
|
641
|
+
return { nonce: randomToken(16) };
|
|
642
|
+
}
|
|
643
|
+
/** Validate an ID token from this provider (also used for tokens obtained by native SDKs). */
|
|
644
|
+
async validateIdToken(idToken, nonce) {
|
|
645
|
+
const doc = await this.discovery();
|
|
646
|
+
const issuer = await envValue(this.options.issuer);
|
|
647
|
+
return verifyJwt(idToken, {
|
|
648
|
+
jwksUri: doc.jwks_uri,
|
|
649
|
+
issuer: this.options.issuerValidation ?? doc.issuer ?? issuer,
|
|
650
|
+
audience: await envValue(this.options.clientId),
|
|
651
|
+
nonce,
|
|
652
|
+
algorithms: this.options.algorithms
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
async userProfile(tokens, c, flow) {
|
|
656
|
+
if (!tokens.idToken) throw new OAuthError("The provider did not return an ID token.", "missing_id_token");
|
|
657
|
+
let claims;
|
|
658
|
+
try {
|
|
659
|
+
claims = await this.validateIdToken(tokens.idToken, flow?.nonce);
|
|
660
|
+
} catch (err) {
|
|
661
|
+
if (err instanceof TokenValidationError)
|
|
662
|
+
throw new OAuthError(`Invalid ID token: ${err.message}`, "invalid_id_token");
|
|
663
|
+
throw err;
|
|
664
|
+
}
|
|
665
|
+
const doc = await this.discovery();
|
|
666
|
+
if (this.options.fetchUserInfo !== false && doc.userinfo_endpoint) {
|
|
667
|
+
const info = await this.profileContext(c, tokens).getJsonAuthed(doc.userinfo_endpoint);
|
|
668
|
+
if (info.sub !== void 0 && String(info.sub) !== String(claims.sub)) {
|
|
669
|
+
throw new OAuthError("The userinfo subject does not match the ID token.", "invalid_userinfo");
|
|
670
|
+
}
|
|
671
|
+
claims = { ...info, ...claims };
|
|
672
|
+
}
|
|
673
|
+
if (this.options.profile)
|
|
674
|
+
return this.options.profile(
|
|
675
|
+
{ ...tokens, raw: { ...tokens.raw, claims } },
|
|
676
|
+
this.profileContext(c, tokens)
|
|
677
|
+
);
|
|
678
|
+
return profileFromClaims(this.name, claims);
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
export {
|
|
683
|
+
OAuthError,
|
|
684
|
+
TokenValidationError,
|
|
685
|
+
setProviderFetch,
|
|
686
|
+
providerFetch,
|
|
687
|
+
readBody,
|
|
688
|
+
getJson,
|
|
689
|
+
toTokens,
|
|
690
|
+
OAuth2Client,
|
|
691
|
+
b64url,
|
|
692
|
+
fromB64url,
|
|
693
|
+
utf8,
|
|
694
|
+
fromUtf8,
|
|
695
|
+
randomToken,
|
|
696
|
+
sha256,
|
|
697
|
+
pkcePair,
|
|
698
|
+
seal,
|
|
699
|
+
unseal,
|
|
700
|
+
timingSafeEqual,
|
|
701
|
+
SUPPORTED_ALGORITHMS,
|
|
702
|
+
decodeJwt,
|
|
703
|
+
clearJwksCache,
|
|
704
|
+
verifyJwt,
|
|
705
|
+
signJwt,
|
|
706
|
+
importPkcs8,
|
|
707
|
+
stateSettings,
|
|
708
|
+
savePending,
|
|
709
|
+
takePending,
|
|
710
|
+
envValue,
|
|
711
|
+
profileFromClaims,
|
|
712
|
+
OAuth2Strategy,
|
|
713
|
+
discover,
|
|
714
|
+
clearDiscoveryCache,
|
|
715
|
+
OIDCStrategy
|
|
716
|
+
};
|
|
717
|
+
//# sourceMappingURL=chunk-TSKPVGEC.js.map
|