@multiplatform.one/keycloak 7.83.1 → 7.83.2
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/cjs/oauth/index.cjs +454 -0
- package/dist/cjs/one.cjs +82 -0
- package/dist/esm/oauth/index.mjs +431 -0
- package/dist/esm/one.mjs +61 -0
- package/package.json +21 -16
- /package/dist/esm/{index.js → index.mjs} +0 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/oauth/index.ts
|
|
21
|
+
var oauth_exports = {};
|
|
22
|
+
__export(oauth_exports, {
|
|
23
|
+
OAuthError: () => OAuthError,
|
|
24
|
+
assertSecureIssuer: () => assertSecureIssuer,
|
|
25
|
+
base64UrlDecode: () => base64UrlDecode,
|
|
26
|
+
base64UrlEncode: () => base64UrlEncode,
|
|
27
|
+
buildAuthorizationUrl: () => buildAuthorizationUrl,
|
|
28
|
+
buildLogoutUrl: () => buildLogoutUrl,
|
|
29
|
+
callbackPageFailure: () => callbackPageFailure,
|
|
30
|
+
callbackPageSuccess: () => callbackPageSuccess,
|
|
31
|
+
createNonce: () => createNonce,
|
|
32
|
+
createPkcePair: () => createPkcePair,
|
|
33
|
+
createStateToken: () => createStateToken,
|
|
34
|
+
decodeJwt: () => decodeJwt,
|
|
35
|
+
defaultScopes: () => defaultScopes,
|
|
36
|
+
endSession: () => endSession,
|
|
37
|
+
escapeHtml: () => escapeHtml,
|
|
38
|
+
exchangeAuthorizationCode: () => exchangeAuthorizationCode,
|
|
39
|
+
isCallbackForState: () => isCallbackForState,
|
|
40
|
+
keycloakEndpoints: () => keycloakEndpoints,
|
|
41
|
+
parseAuthorizationCallback: () => parseAuthorizationCallback,
|
|
42
|
+
refreshAccessToken: () => refreshAccessToken,
|
|
43
|
+
renderCallbackPage: () => renderCallbackPage,
|
|
44
|
+
runLoopbackLogin: () => runLoopbackLogin,
|
|
45
|
+
validateIdToken: () => validateIdToken
|
|
46
|
+
});
|
|
47
|
+
module.exports = __toCommonJS(oauth_exports);
|
|
48
|
+
|
|
49
|
+
// src/oauth/form.ts
|
|
50
|
+
function encodeForm(params) {
|
|
51
|
+
return Object.entries(params).map(([key, value]) => `${encodeFormComponent(key)}=${encodeFormComponent(value)}`).join("&");
|
|
52
|
+
}
|
|
53
|
+
function encodeFormComponent(value) {
|
|
54
|
+
return encodeURIComponent(value).replace(/%20/g, "+").replace(/[!'()~]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/oauth/authorizationUrl.ts
|
|
58
|
+
var defaultScopes = ["openid", "profile", "email"];
|
|
59
|
+
function buildAuthorizationUrl({
|
|
60
|
+
endpoints,
|
|
61
|
+
clientId,
|
|
62
|
+
redirectUri,
|
|
63
|
+
state,
|
|
64
|
+
codeChallenge,
|
|
65
|
+
nonce,
|
|
66
|
+
scopes = defaultScopes,
|
|
67
|
+
prompt,
|
|
68
|
+
loginHint
|
|
69
|
+
}) {
|
|
70
|
+
return `${endpoints.authorization}?${encodeForm({
|
|
71
|
+
client_id: clientId,
|
|
72
|
+
redirect_uri: redirectUri,
|
|
73
|
+
response_type: "code",
|
|
74
|
+
scope: scopes.join(" "),
|
|
75
|
+
state,
|
|
76
|
+
nonce,
|
|
77
|
+
code_challenge: codeChallenge,
|
|
78
|
+
code_challenge_method: "S256",
|
|
79
|
+
...prompt ? { prompt } : {},
|
|
80
|
+
...loginHint ? { login_hint: loginHint } : {}
|
|
81
|
+
})}`;
|
|
82
|
+
}
|
|
83
|
+
function buildLogoutUrl({
|
|
84
|
+
endpoints,
|
|
85
|
+
clientId,
|
|
86
|
+
idToken,
|
|
87
|
+
postLogoutRedirectUri
|
|
88
|
+
}) {
|
|
89
|
+
return `${endpoints.endSession}?${encodeForm({
|
|
90
|
+
client_id: clientId,
|
|
91
|
+
...idToken ? { id_token_hint: idToken } : {},
|
|
92
|
+
...postLogoutRedirectUri ? { post_logout_redirect_uri: postLogoutRedirectUri } : {}
|
|
93
|
+
})}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/oauth/errors.ts
|
|
97
|
+
var OAuthError = class extends Error {
|
|
98
|
+
constructor(code, message, description) {
|
|
99
|
+
super(message ?? description ?? code);
|
|
100
|
+
this.name = "OAuthError";
|
|
101
|
+
this.code = code;
|
|
102
|
+
this.description = description;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// src/oauth/callback.ts
|
|
107
|
+
function isCallbackForState(query, expectedState) {
|
|
108
|
+
return !!expectedState && query.state === expectedState;
|
|
109
|
+
}
|
|
110
|
+
function parseAuthorizationCallback(query, expectedState) {
|
|
111
|
+
if (query.error) {
|
|
112
|
+
throw new OAuthError(query.error, void 0, query.error_description);
|
|
113
|
+
}
|
|
114
|
+
if (!query.state) {
|
|
115
|
+
throw new OAuthError("invalid_state", "authorization response had no state parameter");
|
|
116
|
+
}
|
|
117
|
+
if (query.state !== expectedState) {
|
|
118
|
+
throw new OAuthError("invalid_state", "authorization response state did not match the request");
|
|
119
|
+
}
|
|
120
|
+
if (!query.code) {
|
|
121
|
+
throw new OAuthError("invalid_request", "authorization response had no code parameter");
|
|
122
|
+
}
|
|
123
|
+
return query.code;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/oauth/callbackPage.ts
|
|
127
|
+
function escapeHtml(value) {
|
|
128
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
129
|
+
}
|
|
130
|
+
var callbackPageSuccess = {
|
|
131
|
+
heading: "Signed in",
|
|
132
|
+
detail: "You can close this tab and return to the app."
|
|
133
|
+
};
|
|
134
|
+
var callbackPageFailure = {
|
|
135
|
+
heading: "Sign-in failed",
|
|
136
|
+
detail: "Return to the app for the reason. You can close this tab."
|
|
137
|
+
};
|
|
138
|
+
function renderCallbackPage({ heading, detail }) {
|
|
139
|
+
return `<!doctype html><meta charset="utf-8"><title>${escapeHtml(heading)}</title><body style="font-family:system-ui,sans-serif;text-align:center;margin-top:4rem"><h1>${escapeHtml(heading)}</h1><p>${escapeHtml(detail)}</p>`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// src/oauth/endpoints.ts
|
|
143
|
+
var originPattern = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^/?#]*)/;
|
|
144
|
+
var ipv4LoopbackPattern = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
|
145
|
+
function hostOf(authority) {
|
|
146
|
+
const afterUserinfo = authority.slice(authority.lastIndexOf("@") + 1);
|
|
147
|
+
if (afterUserinfo.startsWith("[")) {
|
|
148
|
+
return afterUserinfo.slice(1, afterUserinfo.indexOf("]")).toLowerCase();
|
|
149
|
+
}
|
|
150
|
+
const colon = afterUserinfo.indexOf(":");
|
|
151
|
+
return (colon === -1 ? afterUserinfo : afterUserinfo.slice(0, colon)).toLowerCase();
|
|
152
|
+
}
|
|
153
|
+
function isLoopbackHost(host) {
|
|
154
|
+
return host === "localhost" || host === "::1" || ipv4LoopbackPattern.test(host);
|
|
155
|
+
}
|
|
156
|
+
function assertSecureIssuer({ url, allowInsecureLoopbackHttp }) {
|
|
157
|
+
const match = originPattern.exec(url);
|
|
158
|
+
if (!match) {
|
|
159
|
+
throw new OAuthError(
|
|
160
|
+
"insecure_issuer",
|
|
161
|
+
`keycloak url must be an absolute http(s) url, got ${JSON.stringify(url)}`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const scheme = match[1].toLowerCase();
|
|
165
|
+
if (scheme === "https") return;
|
|
166
|
+
if (scheme !== "http") {
|
|
167
|
+
throw new OAuthError("insecure_issuer", `keycloak url must use https, got ${scheme}://`);
|
|
168
|
+
}
|
|
169
|
+
const host = hostOf(match[2]);
|
|
170
|
+
if (!isLoopbackHost(host)) {
|
|
171
|
+
throw new OAuthError(
|
|
172
|
+
"insecure_issuer",
|
|
173
|
+
`keycloak url must use https \u2014 refusing plaintext http to ${host}`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (!allowInsecureLoopbackHttp) {
|
|
177
|
+
throw new OAuthError(
|
|
178
|
+
"insecure_issuer",
|
|
179
|
+
`keycloak url must use https \u2014 plaintext http to the loopback host ${host} needs an explicit allowInsecureLoopbackHttp`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function keycloakEndpoints(location) {
|
|
184
|
+
assertSecureIssuer(location);
|
|
185
|
+
const issuer = `${location.url.replace(/\/+$/, "")}/realms/${encodeURIComponent(location.realm)}`;
|
|
186
|
+
const base = `${issuer}/protocol/openid-connect`;
|
|
187
|
+
return {
|
|
188
|
+
issuer,
|
|
189
|
+
authorization: `${base}/auth`,
|
|
190
|
+
token: `${base}/token`,
|
|
191
|
+
endSession: `${base}/logout`,
|
|
192
|
+
userinfo: `${base}/userinfo`,
|
|
193
|
+
revocation: `${base}/revoke`
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/oauth/jwt.ts
|
|
198
|
+
var base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
199
|
+
var base64UrlValues = {};
|
|
200
|
+
for (let i = 0; i < base64UrlAlphabet.length; i++) base64UrlValues[base64UrlAlphabet[i]] = i;
|
|
201
|
+
function base64UrlDecode(value) {
|
|
202
|
+
const input = value.replace(/=+$/, "");
|
|
203
|
+
if (input.length % 4 === 1) {
|
|
204
|
+
throw new OAuthError("invalid_token", "base64url input has an impossible length");
|
|
205
|
+
}
|
|
206
|
+
const bytes = new Uint8Array(Math.floor(input.length * 3 / 4));
|
|
207
|
+
let out = 0;
|
|
208
|
+
for (let i = 0; i < input.length; i += 4) {
|
|
209
|
+
const remaining = input.length - i;
|
|
210
|
+
let chunk = 0;
|
|
211
|
+
for (let j = 0; j < 4; j++) {
|
|
212
|
+
const sextet = j < remaining ? base64UrlValues[input[i + j]] : 0;
|
|
213
|
+
if (sextet === void 0) {
|
|
214
|
+
throw new OAuthError(
|
|
215
|
+
"invalid_token",
|
|
216
|
+
"base64url input has a character outside the alphabet"
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
chunk = chunk << 6 | sextet;
|
|
220
|
+
}
|
|
221
|
+
bytes[out++] = chunk >> 16 & 255;
|
|
222
|
+
if (remaining > 2) bytes[out++] = chunk >> 8 & 255;
|
|
223
|
+
if (remaining > 3) bytes[out++] = chunk & 255;
|
|
224
|
+
}
|
|
225
|
+
return bytes;
|
|
226
|
+
}
|
|
227
|
+
function decodeJsonSegment(segment, what) {
|
|
228
|
+
let parsed;
|
|
229
|
+
try {
|
|
230
|
+
parsed = JSON.parse(new TextDecoder().decode(base64UrlDecode(segment)));
|
|
231
|
+
} catch {
|
|
232
|
+
throw new OAuthError("invalid_token", `jwt ${what} is not base64url-encoded json`);
|
|
233
|
+
}
|
|
234
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
235
|
+
throw new OAuthError("invalid_token", `jwt ${what} is not a json object`);
|
|
236
|
+
}
|
|
237
|
+
return parsed;
|
|
238
|
+
}
|
|
239
|
+
function decodeJwt(token) {
|
|
240
|
+
const parts = token.split(".");
|
|
241
|
+
if (parts.length !== 3 || !parts[0] || !parts[1]) {
|
|
242
|
+
throw new OAuthError("invalid_token", "jwt is not three base64url segments");
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
header: decodeJsonSegment(parts[0], "header"),
|
|
246
|
+
payload: decodeJsonSegment(parts[1], "payload"),
|
|
247
|
+
signature: parts[2]
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/oauth/idToken.ts
|
|
252
|
+
var defaultClockToleranceSeconds = 60;
|
|
253
|
+
function reject(detail) {
|
|
254
|
+
throw new OAuthError("invalid_id_token", `id token rejected: ${detail}`);
|
|
255
|
+
}
|
|
256
|
+
function numericClaim(claims, name) {
|
|
257
|
+
const value = claims[name];
|
|
258
|
+
if (value === void 0) return void 0;
|
|
259
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
260
|
+
reject(`${name} is not a number`);
|
|
261
|
+
}
|
|
262
|
+
return value;
|
|
263
|
+
}
|
|
264
|
+
function validateIdToken(idToken, validation) {
|
|
265
|
+
const { header, payload } = decodeJwt(idToken);
|
|
266
|
+
const claims = payload;
|
|
267
|
+
const now = validation.now ?? Math.floor(Date.now() / 1e3);
|
|
268
|
+
const tolerance = validation.clockToleranceSeconds ?? defaultClockToleranceSeconds;
|
|
269
|
+
const alg = header.alg;
|
|
270
|
+
if (typeof alg !== "string" || !alg) reject("header has no alg");
|
|
271
|
+
if (alg.toLowerCase() === "none") reject("header declares alg=none");
|
|
272
|
+
if (claims.iss !== validation.issuer) {
|
|
273
|
+
reject(`iss ${JSON.stringify(claims.iss)} is not ${JSON.stringify(validation.issuer)}`);
|
|
274
|
+
}
|
|
275
|
+
const audiences = typeof claims.aud === "string" ? [claims.aud] : Array.isArray(claims.aud) ? claims.aud : [];
|
|
276
|
+
if (!audiences.length) reject("aud is missing");
|
|
277
|
+
if (!audiences.includes(validation.clientId)) {
|
|
278
|
+
reject(`aud ${JSON.stringify(claims.aud)} does not include ${validation.clientId}`);
|
|
279
|
+
}
|
|
280
|
+
if (claims.azp === void 0) {
|
|
281
|
+
if (audiences.length > 1) reject("aud has multiple values but azp is missing");
|
|
282
|
+
} else if (claims.azp !== validation.clientId) {
|
|
283
|
+
reject(`azp ${JSON.stringify(claims.azp)} is not ${validation.clientId}`);
|
|
284
|
+
}
|
|
285
|
+
const exp = numericClaim(claims, "exp");
|
|
286
|
+
if (exp === void 0) reject("exp is missing");
|
|
287
|
+
if (now >= exp + tolerance) reject("exp is in the past");
|
|
288
|
+
const iat = numericClaim(claims, "iat");
|
|
289
|
+
if (iat === void 0) reject("iat is missing");
|
|
290
|
+
if (iat > now + tolerance) reject("iat is in the future");
|
|
291
|
+
const nbf = numericClaim(claims, "nbf");
|
|
292
|
+
if (nbf !== void 0 && now < nbf - tolerance) reject("nbf is in the future");
|
|
293
|
+
if (validation.nonce !== void 0 && claims.nonce !== validation.nonce) {
|
|
294
|
+
reject("nonce does not match the authorization request");
|
|
295
|
+
}
|
|
296
|
+
if (typeof claims.sub !== "string" || !claims.sub) reject("sub is missing");
|
|
297
|
+
return claims;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// src/oauth/pkce.ts
|
|
301
|
+
var base64UrlAlphabet2 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
302
|
+
function base64UrlEncode(bytes) {
|
|
303
|
+
let out = "";
|
|
304
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
305
|
+
const remaining = bytes.length - i;
|
|
306
|
+
const chunk = bytes[i] << 16 | (remaining > 1 ? bytes[i + 1] : 0) << 8 | (remaining > 2 ? bytes[i + 2] : 0);
|
|
307
|
+
out += base64UrlAlphabet2[chunk >> 18 & 63];
|
|
308
|
+
out += base64UrlAlphabet2[chunk >> 12 & 63];
|
|
309
|
+
if (remaining > 1) out += base64UrlAlphabet2[chunk >> 6 & 63];
|
|
310
|
+
if (remaining > 2) out += base64UrlAlphabet2[chunk & 63];
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
function createPkcePair(crypto) {
|
|
315
|
+
const verifier = base64UrlEncode(crypto.randomBytes(32));
|
|
316
|
+
const challenge = base64UrlEncode(crypto.sha256(new TextEncoder().encode(verifier)));
|
|
317
|
+
return { verifier, challenge, method: "S256" };
|
|
318
|
+
}
|
|
319
|
+
function createStateToken(crypto) {
|
|
320
|
+
return base64UrlEncode(crypto.randomBytes(16));
|
|
321
|
+
}
|
|
322
|
+
function createNonce(crypto) {
|
|
323
|
+
return base64UrlEncode(crypto.randomBytes(16));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// src/oauth/tokenExchange.ts
|
|
327
|
+
var formHeaders = {
|
|
328
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
329
|
+
Accept: "application/json"
|
|
330
|
+
};
|
|
331
|
+
async function postForm(fetchImpl, endpoint, form) {
|
|
332
|
+
const response = await fetchImpl(endpoint, {
|
|
333
|
+
method: "POST",
|
|
334
|
+
headers: formHeaders,
|
|
335
|
+
body: encodeForm(form)
|
|
336
|
+
});
|
|
337
|
+
let payload;
|
|
338
|
+
try {
|
|
339
|
+
payload = await response.json();
|
|
340
|
+
} catch {
|
|
341
|
+
throw new OAuthError(
|
|
342
|
+
"invalid_response",
|
|
343
|
+
`token endpoint returned ${response.status} with a non-JSON body`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
if (payload.error) {
|
|
347
|
+
throw new OAuthError(payload.error, void 0, payload.error_description);
|
|
348
|
+
}
|
|
349
|
+
if (!response.ok) {
|
|
350
|
+
throw new OAuthError("invalid_response", `token endpoint returned ${response.status}`);
|
|
351
|
+
}
|
|
352
|
+
return payload;
|
|
353
|
+
}
|
|
354
|
+
function toTokenSet(payload, validation) {
|
|
355
|
+
if (!payload.access_token) {
|
|
356
|
+
throw new OAuthError("invalid_response", "token response had no access_token");
|
|
357
|
+
}
|
|
358
|
+
const claims = payload.id_token ? validateIdToken(payload.id_token, {
|
|
359
|
+
issuer: validation.endpoints.issuer,
|
|
360
|
+
clientId: validation.clientId,
|
|
361
|
+
nonce: validation.nonce,
|
|
362
|
+
now: validation.now
|
|
363
|
+
}) : void 0;
|
|
364
|
+
return {
|
|
365
|
+
accessToken: payload.access_token,
|
|
366
|
+
idToken: payload.id_token,
|
|
367
|
+
idTokenClaims: claims,
|
|
368
|
+
refreshToken: payload.refresh_token,
|
|
369
|
+
expiresIn: payload.expires_in,
|
|
370
|
+
refreshExpiresIn: payload.refresh_expires_in,
|
|
371
|
+
tokenType: payload.token_type,
|
|
372
|
+
scope: payload.scope
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
async function exchangeAuthorizationCode(fetchImpl, { endpoints, clientId, code, codeVerifier, redirectUri, nonce, now }) {
|
|
376
|
+
if (!nonce) {
|
|
377
|
+
throw new OAuthError(
|
|
378
|
+
"invalid_request",
|
|
379
|
+
"nonce is required on the authorization-code grant \u2014 it is the only thing binding the id token to this login"
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
return toTokenSet(
|
|
383
|
+
await postForm(fetchImpl, endpoints.token, {
|
|
384
|
+
grant_type: "authorization_code",
|
|
385
|
+
client_id: clientId,
|
|
386
|
+
code,
|
|
387
|
+
code_verifier: codeVerifier,
|
|
388
|
+
redirect_uri: redirectUri
|
|
389
|
+
}),
|
|
390
|
+
{ endpoints, clientId, nonce, now }
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
async function refreshAccessToken(fetchImpl, { endpoints, clientId, refreshToken, now }) {
|
|
394
|
+
return toTokenSet(
|
|
395
|
+
await postForm(fetchImpl, endpoints.token, {
|
|
396
|
+
grant_type: "refresh_token",
|
|
397
|
+
client_id: clientId,
|
|
398
|
+
refresh_token: refreshToken
|
|
399
|
+
}),
|
|
400
|
+
// No nonce on the refresh grant: there is no fresh authorization request
|
|
401
|
+
// to bind to. Every other claim is still checked.
|
|
402
|
+
{ endpoints, clientId, now }
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
async function endSession(fetchImpl, { endpoints, clientId, refreshToken }) {
|
|
406
|
+
const response = await fetchImpl(endpoints.endSession, {
|
|
407
|
+
method: "POST",
|
|
408
|
+
headers: formHeaders,
|
|
409
|
+
body: encodeForm({ client_id: clientId, refresh_token: refreshToken })
|
|
410
|
+
});
|
|
411
|
+
if (!response.ok) {
|
|
412
|
+
throw new OAuthError("logout_failed", `end-session endpoint returned ${response.status}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// src/oauth/loopbackFlow.ts
|
|
417
|
+
async function runLoopbackLogin(transport, config) {
|
|
418
|
+
const endpoints = keycloakEndpoints(config);
|
|
419
|
+
const pkce = createPkcePair(transport.crypto);
|
|
420
|
+
const state = createStateToken(transport.crypto);
|
|
421
|
+
const nonce = createNonce(transport.crypto);
|
|
422
|
+
const scopes = config.scopes ?? defaultScopes;
|
|
423
|
+
const listener = await transport.listen(state);
|
|
424
|
+
try {
|
|
425
|
+
await transport.openBrowser(
|
|
426
|
+
buildAuthorizationUrl({
|
|
427
|
+
endpoints,
|
|
428
|
+
clientId: config.clientId,
|
|
429
|
+
redirectUri: listener.redirectUri,
|
|
430
|
+
state,
|
|
431
|
+
codeChallenge: pkce.challenge,
|
|
432
|
+
nonce,
|
|
433
|
+
scopes,
|
|
434
|
+
prompt: config.prompt,
|
|
435
|
+
loginHint: config.loginHint
|
|
436
|
+
})
|
|
437
|
+
);
|
|
438
|
+
const code = parseAuthorizationCallback(await listener.waitForCallback(), state);
|
|
439
|
+
const tokens = await exchangeAuthorizationCode(transport.fetch, {
|
|
440
|
+
endpoints,
|
|
441
|
+
clientId: config.clientId,
|
|
442
|
+
code,
|
|
443
|
+
codeVerifier: pkce.verifier,
|
|
444
|
+
redirectUri: listener.redirectUri,
|
|
445
|
+
nonce
|
|
446
|
+
});
|
|
447
|
+
if (scopes.includes("openid") && !tokens.idToken) {
|
|
448
|
+
throw new OAuthError("invalid_response", "openid was requested but no id_token came back");
|
|
449
|
+
}
|
|
450
|
+
return tokens;
|
|
451
|
+
} finally {
|
|
452
|
+
listener.close();
|
|
453
|
+
}
|
|
454
|
+
}
|
package/dist/cjs/one.cjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/one.ts
|
|
21
|
+
var one_exports = {};
|
|
22
|
+
__export(one_exports, {
|
|
23
|
+
createAuth: () => createAuth
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(one_exports);
|
|
26
|
+
var import_better_auth = require("better-auth");
|
|
27
|
+
var import_plugins = require("better-auth/plugins");
|
|
28
|
+
var import_expo = require("@better-auth/expo");
|
|
29
|
+
var _auth;
|
|
30
|
+
function createAuth(config) {
|
|
31
|
+
if (_auth) return _auth;
|
|
32
|
+
const baseUrl = config.baseUrl || (typeof process !== "undefined" ? process.env?.BASE_URL || `http://localhost:${process.env?.ONE_PORT || "3000"}` : "http://localhost:3000");
|
|
33
|
+
const keycloakBaseUrl = config.keycloak?.baseUrl || `${baseUrl}/auth`;
|
|
34
|
+
const realm = config.keycloak?.realm || "master";
|
|
35
|
+
const issuer = `${keycloakBaseUrl}/realms/${realm}`;
|
|
36
|
+
const envTrustedOrigins = (typeof process !== "undefined" && process.env?.KEYCLOAK_TRUSTED_ORIGINS || "").split(",").map((origin) => origin.trim()).filter(Boolean);
|
|
37
|
+
_auth = (0, import_better_auth.betterAuth)({
|
|
38
|
+
basePath: "/api/auth",
|
|
39
|
+
baseURL: baseUrl,
|
|
40
|
+
secret: config.secret || "",
|
|
41
|
+
trustedOrigins: [
|
|
42
|
+
.../* @__PURE__ */ new Set([
|
|
43
|
+
baseUrl,
|
|
44
|
+
keycloakBaseUrl,
|
|
45
|
+
...config.expoScheme ? [`${config.expoScheme}://`] : [],
|
|
46
|
+
...envTrustedOrigins,
|
|
47
|
+
...config.trustedOrigins || []
|
|
48
|
+
])
|
|
49
|
+
],
|
|
50
|
+
advanced: {
|
|
51
|
+
// better-auth skips origin checks entirely when NODE_ENV=test; pin the
|
|
52
|
+
// check on so the trustedOrigins contract is enforced (and testable)
|
|
53
|
+
// regardless of environment.
|
|
54
|
+
disableOriginCheck: false
|
|
55
|
+
},
|
|
56
|
+
session: {
|
|
57
|
+
cookieCache: {
|
|
58
|
+
enabled: true,
|
|
59
|
+
maxAge: 7 * 24 * 60 * 60,
|
|
60
|
+
strategy: "jwe",
|
|
61
|
+
refreshCache: true
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
account: {
|
|
65
|
+
storeStateStrategy: "cookie",
|
|
66
|
+
storeAccountCookie: true
|
|
67
|
+
},
|
|
68
|
+
plugins: [
|
|
69
|
+
(0, import_expo.expo)(),
|
|
70
|
+
(0, import_plugins.genericOAuth)({
|
|
71
|
+
config: [
|
|
72
|
+
(0, import_plugins.keycloak)({
|
|
73
|
+
clientId: config.keycloak.clientId || "",
|
|
74
|
+
clientSecret: config.keycloak.clientSecret || "",
|
|
75
|
+
issuer
|
|
76
|
+
})
|
|
77
|
+
]
|
|
78
|
+
})
|
|
79
|
+
]
|
|
80
|
+
});
|
|
81
|
+
return _auth;
|
|
82
|
+
}
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// src/oauth/form.ts
|
|
2
|
+
function encodeForm(params) {
|
|
3
|
+
return Object.entries(params).map(([key, value]) => `${encodeFormComponent(key)}=${encodeFormComponent(value)}`).join("&");
|
|
4
|
+
}
|
|
5
|
+
function encodeFormComponent(value) {
|
|
6
|
+
return encodeURIComponent(value).replace(/%20/g, "+").replace(/[!'()~]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// src/oauth/authorizationUrl.ts
|
|
10
|
+
var defaultScopes = ["openid", "profile", "email"];
|
|
11
|
+
function buildAuthorizationUrl({
|
|
12
|
+
endpoints,
|
|
13
|
+
clientId,
|
|
14
|
+
redirectUri,
|
|
15
|
+
state,
|
|
16
|
+
codeChallenge,
|
|
17
|
+
nonce,
|
|
18
|
+
scopes = defaultScopes,
|
|
19
|
+
prompt,
|
|
20
|
+
loginHint
|
|
21
|
+
}) {
|
|
22
|
+
return `${endpoints.authorization}?${encodeForm({
|
|
23
|
+
client_id: clientId,
|
|
24
|
+
redirect_uri: redirectUri,
|
|
25
|
+
response_type: "code",
|
|
26
|
+
scope: scopes.join(" "),
|
|
27
|
+
state,
|
|
28
|
+
nonce,
|
|
29
|
+
code_challenge: codeChallenge,
|
|
30
|
+
code_challenge_method: "S256",
|
|
31
|
+
...prompt ? { prompt } : {},
|
|
32
|
+
...loginHint ? { login_hint: loginHint } : {}
|
|
33
|
+
})}`;
|
|
34
|
+
}
|
|
35
|
+
function buildLogoutUrl({
|
|
36
|
+
endpoints,
|
|
37
|
+
clientId,
|
|
38
|
+
idToken,
|
|
39
|
+
postLogoutRedirectUri
|
|
40
|
+
}) {
|
|
41
|
+
return `${endpoints.endSession}?${encodeForm({
|
|
42
|
+
client_id: clientId,
|
|
43
|
+
...idToken ? { id_token_hint: idToken } : {},
|
|
44
|
+
...postLogoutRedirectUri ? { post_logout_redirect_uri: postLogoutRedirectUri } : {}
|
|
45
|
+
})}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/oauth/errors.ts
|
|
49
|
+
var OAuthError = class extends Error {
|
|
50
|
+
constructor(code, message, description) {
|
|
51
|
+
super(message ?? description ?? code);
|
|
52
|
+
this.name = "OAuthError";
|
|
53
|
+
this.code = code;
|
|
54
|
+
this.description = description;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/oauth/callback.ts
|
|
59
|
+
function isCallbackForState(query, expectedState) {
|
|
60
|
+
return !!expectedState && query.state === expectedState;
|
|
61
|
+
}
|
|
62
|
+
function parseAuthorizationCallback(query, expectedState) {
|
|
63
|
+
if (query.error) {
|
|
64
|
+
throw new OAuthError(query.error, void 0, query.error_description);
|
|
65
|
+
}
|
|
66
|
+
if (!query.state) {
|
|
67
|
+
throw new OAuthError("invalid_state", "authorization response had no state parameter");
|
|
68
|
+
}
|
|
69
|
+
if (query.state !== expectedState) {
|
|
70
|
+
throw new OAuthError("invalid_state", "authorization response state did not match the request");
|
|
71
|
+
}
|
|
72
|
+
if (!query.code) {
|
|
73
|
+
throw new OAuthError("invalid_request", "authorization response had no code parameter");
|
|
74
|
+
}
|
|
75
|
+
return query.code;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/oauth/callbackPage.ts
|
|
79
|
+
function escapeHtml(value) {
|
|
80
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
81
|
+
}
|
|
82
|
+
var callbackPageSuccess = {
|
|
83
|
+
heading: "Signed in",
|
|
84
|
+
detail: "You can close this tab and return to the app."
|
|
85
|
+
};
|
|
86
|
+
var callbackPageFailure = {
|
|
87
|
+
heading: "Sign-in failed",
|
|
88
|
+
detail: "Return to the app for the reason. You can close this tab."
|
|
89
|
+
};
|
|
90
|
+
function renderCallbackPage({ heading, detail }) {
|
|
91
|
+
return `<!doctype html><meta charset="utf-8"><title>${escapeHtml(heading)}</title><body style="font-family:system-ui,sans-serif;text-align:center;margin-top:4rem"><h1>${escapeHtml(heading)}</h1><p>${escapeHtml(detail)}</p>`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/oauth/endpoints.ts
|
|
95
|
+
var originPattern = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^/?#]*)/;
|
|
96
|
+
var ipv4LoopbackPattern = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
|
97
|
+
function hostOf(authority) {
|
|
98
|
+
const afterUserinfo = authority.slice(authority.lastIndexOf("@") + 1);
|
|
99
|
+
if (afterUserinfo.startsWith("[")) {
|
|
100
|
+
return afterUserinfo.slice(1, afterUserinfo.indexOf("]")).toLowerCase();
|
|
101
|
+
}
|
|
102
|
+
const colon = afterUserinfo.indexOf(":");
|
|
103
|
+
return (colon === -1 ? afterUserinfo : afterUserinfo.slice(0, colon)).toLowerCase();
|
|
104
|
+
}
|
|
105
|
+
function isLoopbackHost(host) {
|
|
106
|
+
return host === "localhost" || host === "::1" || ipv4LoopbackPattern.test(host);
|
|
107
|
+
}
|
|
108
|
+
function assertSecureIssuer({ url, allowInsecureLoopbackHttp }) {
|
|
109
|
+
const match = originPattern.exec(url);
|
|
110
|
+
if (!match) {
|
|
111
|
+
throw new OAuthError(
|
|
112
|
+
"insecure_issuer",
|
|
113
|
+
`keycloak url must be an absolute http(s) url, got ${JSON.stringify(url)}`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const scheme = match[1].toLowerCase();
|
|
117
|
+
if (scheme === "https") return;
|
|
118
|
+
if (scheme !== "http") {
|
|
119
|
+
throw new OAuthError("insecure_issuer", `keycloak url must use https, got ${scheme}://`);
|
|
120
|
+
}
|
|
121
|
+
const host = hostOf(match[2]);
|
|
122
|
+
if (!isLoopbackHost(host)) {
|
|
123
|
+
throw new OAuthError(
|
|
124
|
+
"insecure_issuer",
|
|
125
|
+
`keycloak url must use https \u2014 refusing plaintext http to ${host}`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (!allowInsecureLoopbackHttp) {
|
|
129
|
+
throw new OAuthError(
|
|
130
|
+
"insecure_issuer",
|
|
131
|
+
`keycloak url must use https \u2014 plaintext http to the loopback host ${host} needs an explicit allowInsecureLoopbackHttp`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function keycloakEndpoints(location) {
|
|
136
|
+
assertSecureIssuer(location);
|
|
137
|
+
const issuer = `${location.url.replace(/\/+$/, "")}/realms/${encodeURIComponent(location.realm)}`;
|
|
138
|
+
const base = `${issuer}/protocol/openid-connect`;
|
|
139
|
+
return {
|
|
140
|
+
issuer,
|
|
141
|
+
authorization: `${base}/auth`,
|
|
142
|
+
token: `${base}/token`,
|
|
143
|
+
endSession: `${base}/logout`,
|
|
144
|
+
userinfo: `${base}/userinfo`,
|
|
145
|
+
revocation: `${base}/revoke`
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/oauth/jwt.ts
|
|
150
|
+
var base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
151
|
+
var base64UrlValues = {};
|
|
152
|
+
for (let i = 0; i < base64UrlAlphabet.length; i++) base64UrlValues[base64UrlAlphabet[i]] = i;
|
|
153
|
+
function base64UrlDecode(value) {
|
|
154
|
+
const input = value.replace(/=+$/, "");
|
|
155
|
+
if (input.length % 4 === 1) {
|
|
156
|
+
throw new OAuthError("invalid_token", "base64url input has an impossible length");
|
|
157
|
+
}
|
|
158
|
+
const bytes = new Uint8Array(Math.floor(input.length * 3 / 4));
|
|
159
|
+
let out = 0;
|
|
160
|
+
for (let i = 0; i < input.length; i += 4) {
|
|
161
|
+
const remaining = input.length - i;
|
|
162
|
+
let chunk = 0;
|
|
163
|
+
for (let j = 0; j < 4; j++) {
|
|
164
|
+
const sextet = j < remaining ? base64UrlValues[input[i + j]] : 0;
|
|
165
|
+
if (sextet === void 0) {
|
|
166
|
+
throw new OAuthError(
|
|
167
|
+
"invalid_token",
|
|
168
|
+
"base64url input has a character outside the alphabet"
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
chunk = chunk << 6 | sextet;
|
|
172
|
+
}
|
|
173
|
+
bytes[out++] = chunk >> 16 & 255;
|
|
174
|
+
if (remaining > 2) bytes[out++] = chunk >> 8 & 255;
|
|
175
|
+
if (remaining > 3) bytes[out++] = chunk & 255;
|
|
176
|
+
}
|
|
177
|
+
return bytes;
|
|
178
|
+
}
|
|
179
|
+
function decodeJsonSegment(segment, what) {
|
|
180
|
+
let parsed;
|
|
181
|
+
try {
|
|
182
|
+
parsed = JSON.parse(new TextDecoder().decode(base64UrlDecode(segment)));
|
|
183
|
+
} catch {
|
|
184
|
+
throw new OAuthError("invalid_token", `jwt ${what} is not base64url-encoded json`);
|
|
185
|
+
}
|
|
186
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
187
|
+
throw new OAuthError("invalid_token", `jwt ${what} is not a json object`);
|
|
188
|
+
}
|
|
189
|
+
return parsed;
|
|
190
|
+
}
|
|
191
|
+
function decodeJwt(token) {
|
|
192
|
+
const parts = token.split(".");
|
|
193
|
+
if (parts.length !== 3 || !parts[0] || !parts[1]) {
|
|
194
|
+
throw new OAuthError("invalid_token", "jwt is not three base64url segments");
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
header: decodeJsonSegment(parts[0], "header"),
|
|
198
|
+
payload: decodeJsonSegment(parts[1], "payload"),
|
|
199
|
+
signature: parts[2]
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/oauth/idToken.ts
|
|
204
|
+
var defaultClockToleranceSeconds = 60;
|
|
205
|
+
function reject(detail) {
|
|
206
|
+
throw new OAuthError("invalid_id_token", `id token rejected: ${detail}`);
|
|
207
|
+
}
|
|
208
|
+
function numericClaim(claims, name) {
|
|
209
|
+
const value = claims[name];
|
|
210
|
+
if (value === void 0) return void 0;
|
|
211
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
212
|
+
reject(`${name} is not a number`);
|
|
213
|
+
}
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
function validateIdToken(idToken, validation) {
|
|
217
|
+
const { header, payload } = decodeJwt(idToken);
|
|
218
|
+
const claims = payload;
|
|
219
|
+
const now = validation.now ?? Math.floor(Date.now() / 1e3);
|
|
220
|
+
const tolerance = validation.clockToleranceSeconds ?? defaultClockToleranceSeconds;
|
|
221
|
+
const alg = header.alg;
|
|
222
|
+
if (typeof alg !== "string" || !alg) reject("header has no alg");
|
|
223
|
+
if (alg.toLowerCase() === "none") reject("header declares alg=none");
|
|
224
|
+
if (claims.iss !== validation.issuer) {
|
|
225
|
+
reject(`iss ${JSON.stringify(claims.iss)} is not ${JSON.stringify(validation.issuer)}`);
|
|
226
|
+
}
|
|
227
|
+
const audiences = typeof claims.aud === "string" ? [claims.aud] : Array.isArray(claims.aud) ? claims.aud : [];
|
|
228
|
+
if (!audiences.length) reject("aud is missing");
|
|
229
|
+
if (!audiences.includes(validation.clientId)) {
|
|
230
|
+
reject(`aud ${JSON.stringify(claims.aud)} does not include ${validation.clientId}`);
|
|
231
|
+
}
|
|
232
|
+
if (claims.azp === void 0) {
|
|
233
|
+
if (audiences.length > 1) reject("aud has multiple values but azp is missing");
|
|
234
|
+
} else if (claims.azp !== validation.clientId) {
|
|
235
|
+
reject(`azp ${JSON.stringify(claims.azp)} is not ${validation.clientId}`);
|
|
236
|
+
}
|
|
237
|
+
const exp = numericClaim(claims, "exp");
|
|
238
|
+
if (exp === void 0) reject("exp is missing");
|
|
239
|
+
if (now >= exp + tolerance) reject("exp is in the past");
|
|
240
|
+
const iat = numericClaim(claims, "iat");
|
|
241
|
+
if (iat === void 0) reject("iat is missing");
|
|
242
|
+
if (iat > now + tolerance) reject("iat is in the future");
|
|
243
|
+
const nbf = numericClaim(claims, "nbf");
|
|
244
|
+
if (nbf !== void 0 && now < nbf - tolerance) reject("nbf is in the future");
|
|
245
|
+
if (validation.nonce !== void 0 && claims.nonce !== validation.nonce) {
|
|
246
|
+
reject("nonce does not match the authorization request");
|
|
247
|
+
}
|
|
248
|
+
if (typeof claims.sub !== "string" || !claims.sub) reject("sub is missing");
|
|
249
|
+
return claims;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/oauth/pkce.ts
|
|
253
|
+
var base64UrlAlphabet2 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
254
|
+
function base64UrlEncode(bytes) {
|
|
255
|
+
let out = "";
|
|
256
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
257
|
+
const remaining = bytes.length - i;
|
|
258
|
+
const chunk = bytes[i] << 16 | (remaining > 1 ? bytes[i + 1] : 0) << 8 | (remaining > 2 ? bytes[i + 2] : 0);
|
|
259
|
+
out += base64UrlAlphabet2[chunk >> 18 & 63];
|
|
260
|
+
out += base64UrlAlphabet2[chunk >> 12 & 63];
|
|
261
|
+
if (remaining > 1) out += base64UrlAlphabet2[chunk >> 6 & 63];
|
|
262
|
+
if (remaining > 2) out += base64UrlAlphabet2[chunk & 63];
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
function createPkcePair(crypto) {
|
|
267
|
+
const verifier = base64UrlEncode(crypto.randomBytes(32));
|
|
268
|
+
const challenge = base64UrlEncode(crypto.sha256(new TextEncoder().encode(verifier)));
|
|
269
|
+
return { verifier, challenge, method: "S256" };
|
|
270
|
+
}
|
|
271
|
+
function createStateToken(crypto) {
|
|
272
|
+
return base64UrlEncode(crypto.randomBytes(16));
|
|
273
|
+
}
|
|
274
|
+
function createNonce(crypto) {
|
|
275
|
+
return base64UrlEncode(crypto.randomBytes(16));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/oauth/tokenExchange.ts
|
|
279
|
+
var formHeaders = {
|
|
280
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
281
|
+
Accept: "application/json"
|
|
282
|
+
};
|
|
283
|
+
async function postForm(fetchImpl, endpoint, form) {
|
|
284
|
+
const response = await fetchImpl(endpoint, {
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: formHeaders,
|
|
287
|
+
body: encodeForm(form)
|
|
288
|
+
});
|
|
289
|
+
let payload;
|
|
290
|
+
try {
|
|
291
|
+
payload = await response.json();
|
|
292
|
+
} catch {
|
|
293
|
+
throw new OAuthError(
|
|
294
|
+
"invalid_response",
|
|
295
|
+
`token endpoint returned ${response.status} with a non-JSON body`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
if (payload.error) {
|
|
299
|
+
throw new OAuthError(payload.error, void 0, payload.error_description);
|
|
300
|
+
}
|
|
301
|
+
if (!response.ok) {
|
|
302
|
+
throw new OAuthError("invalid_response", `token endpoint returned ${response.status}`);
|
|
303
|
+
}
|
|
304
|
+
return payload;
|
|
305
|
+
}
|
|
306
|
+
function toTokenSet(payload, validation) {
|
|
307
|
+
if (!payload.access_token) {
|
|
308
|
+
throw new OAuthError("invalid_response", "token response had no access_token");
|
|
309
|
+
}
|
|
310
|
+
const claims = payload.id_token ? validateIdToken(payload.id_token, {
|
|
311
|
+
issuer: validation.endpoints.issuer,
|
|
312
|
+
clientId: validation.clientId,
|
|
313
|
+
nonce: validation.nonce,
|
|
314
|
+
now: validation.now
|
|
315
|
+
}) : void 0;
|
|
316
|
+
return {
|
|
317
|
+
accessToken: payload.access_token,
|
|
318
|
+
idToken: payload.id_token,
|
|
319
|
+
idTokenClaims: claims,
|
|
320
|
+
refreshToken: payload.refresh_token,
|
|
321
|
+
expiresIn: payload.expires_in,
|
|
322
|
+
refreshExpiresIn: payload.refresh_expires_in,
|
|
323
|
+
tokenType: payload.token_type,
|
|
324
|
+
scope: payload.scope
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
async function exchangeAuthorizationCode(fetchImpl, { endpoints, clientId, code, codeVerifier, redirectUri, nonce, now }) {
|
|
328
|
+
if (!nonce) {
|
|
329
|
+
throw new OAuthError(
|
|
330
|
+
"invalid_request",
|
|
331
|
+
"nonce is required on the authorization-code grant \u2014 it is the only thing binding the id token to this login"
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return toTokenSet(
|
|
335
|
+
await postForm(fetchImpl, endpoints.token, {
|
|
336
|
+
grant_type: "authorization_code",
|
|
337
|
+
client_id: clientId,
|
|
338
|
+
code,
|
|
339
|
+
code_verifier: codeVerifier,
|
|
340
|
+
redirect_uri: redirectUri
|
|
341
|
+
}),
|
|
342
|
+
{ endpoints, clientId, nonce, now }
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
async function refreshAccessToken(fetchImpl, { endpoints, clientId, refreshToken, now }) {
|
|
346
|
+
return toTokenSet(
|
|
347
|
+
await postForm(fetchImpl, endpoints.token, {
|
|
348
|
+
grant_type: "refresh_token",
|
|
349
|
+
client_id: clientId,
|
|
350
|
+
refresh_token: refreshToken
|
|
351
|
+
}),
|
|
352
|
+
// No nonce on the refresh grant: there is no fresh authorization request
|
|
353
|
+
// to bind to. Every other claim is still checked.
|
|
354
|
+
{ endpoints, clientId, now }
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
async function endSession(fetchImpl, { endpoints, clientId, refreshToken }) {
|
|
358
|
+
const response = await fetchImpl(endpoints.endSession, {
|
|
359
|
+
method: "POST",
|
|
360
|
+
headers: formHeaders,
|
|
361
|
+
body: encodeForm({ client_id: clientId, refresh_token: refreshToken })
|
|
362
|
+
});
|
|
363
|
+
if (!response.ok) {
|
|
364
|
+
throw new OAuthError("logout_failed", `end-session endpoint returned ${response.status}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// src/oauth/loopbackFlow.ts
|
|
369
|
+
async function runLoopbackLogin(transport, config) {
|
|
370
|
+
const endpoints = keycloakEndpoints(config);
|
|
371
|
+
const pkce = createPkcePair(transport.crypto);
|
|
372
|
+
const state = createStateToken(transport.crypto);
|
|
373
|
+
const nonce = createNonce(transport.crypto);
|
|
374
|
+
const scopes = config.scopes ?? defaultScopes;
|
|
375
|
+
const listener = await transport.listen(state);
|
|
376
|
+
try {
|
|
377
|
+
await transport.openBrowser(
|
|
378
|
+
buildAuthorizationUrl({
|
|
379
|
+
endpoints,
|
|
380
|
+
clientId: config.clientId,
|
|
381
|
+
redirectUri: listener.redirectUri,
|
|
382
|
+
state,
|
|
383
|
+
codeChallenge: pkce.challenge,
|
|
384
|
+
nonce,
|
|
385
|
+
scopes,
|
|
386
|
+
prompt: config.prompt,
|
|
387
|
+
loginHint: config.loginHint
|
|
388
|
+
})
|
|
389
|
+
);
|
|
390
|
+
const code = parseAuthorizationCallback(await listener.waitForCallback(), state);
|
|
391
|
+
const tokens = await exchangeAuthorizationCode(transport.fetch, {
|
|
392
|
+
endpoints,
|
|
393
|
+
clientId: config.clientId,
|
|
394
|
+
code,
|
|
395
|
+
codeVerifier: pkce.verifier,
|
|
396
|
+
redirectUri: listener.redirectUri,
|
|
397
|
+
nonce
|
|
398
|
+
});
|
|
399
|
+
if (scopes.includes("openid") && !tokens.idToken) {
|
|
400
|
+
throw new OAuthError("invalid_response", "openid was requested but no id_token came back");
|
|
401
|
+
}
|
|
402
|
+
return tokens;
|
|
403
|
+
} finally {
|
|
404
|
+
listener.close();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
export {
|
|
408
|
+
OAuthError,
|
|
409
|
+
assertSecureIssuer,
|
|
410
|
+
base64UrlDecode,
|
|
411
|
+
base64UrlEncode,
|
|
412
|
+
buildAuthorizationUrl,
|
|
413
|
+
buildLogoutUrl,
|
|
414
|
+
callbackPageFailure,
|
|
415
|
+
callbackPageSuccess,
|
|
416
|
+
createNonce,
|
|
417
|
+
createPkcePair,
|
|
418
|
+
createStateToken,
|
|
419
|
+
decodeJwt,
|
|
420
|
+
defaultScopes,
|
|
421
|
+
endSession,
|
|
422
|
+
escapeHtml,
|
|
423
|
+
exchangeAuthorizationCode,
|
|
424
|
+
isCallbackForState,
|
|
425
|
+
keycloakEndpoints,
|
|
426
|
+
parseAuthorizationCallback,
|
|
427
|
+
refreshAccessToken,
|
|
428
|
+
renderCallbackPage,
|
|
429
|
+
runLoopbackLogin,
|
|
430
|
+
validateIdToken
|
|
431
|
+
};
|
package/dist/esm/one.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// src/one.ts
|
|
2
|
+
import { betterAuth } from "better-auth";
|
|
3
|
+
import { genericOAuth, keycloak } from "better-auth/plugins";
|
|
4
|
+
import { expo } from "@better-auth/expo";
|
|
5
|
+
var _auth;
|
|
6
|
+
function createAuth(config) {
|
|
7
|
+
if (_auth) return _auth;
|
|
8
|
+
const baseUrl = config.baseUrl || (typeof process !== "undefined" ? process.env?.BASE_URL || `http://localhost:${process.env?.ONE_PORT || "3000"}` : "http://localhost:3000");
|
|
9
|
+
const keycloakBaseUrl = config.keycloak?.baseUrl || `${baseUrl}/auth`;
|
|
10
|
+
const realm = config.keycloak?.realm || "master";
|
|
11
|
+
const issuer = `${keycloakBaseUrl}/realms/${realm}`;
|
|
12
|
+
const envTrustedOrigins = (typeof process !== "undefined" && process.env?.KEYCLOAK_TRUSTED_ORIGINS || "").split(",").map((origin) => origin.trim()).filter(Boolean);
|
|
13
|
+
_auth = betterAuth({
|
|
14
|
+
basePath: "/api/auth",
|
|
15
|
+
baseURL: baseUrl,
|
|
16
|
+
secret: config.secret || "",
|
|
17
|
+
trustedOrigins: [
|
|
18
|
+
.../* @__PURE__ */ new Set([
|
|
19
|
+
baseUrl,
|
|
20
|
+
keycloakBaseUrl,
|
|
21
|
+
...config.expoScheme ? [`${config.expoScheme}://`] : [],
|
|
22
|
+
...envTrustedOrigins,
|
|
23
|
+
...config.trustedOrigins || []
|
|
24
|
+
])
|
|
25
|
+
],
|
|
26
|
+
advanced: {
|
|
27
|
+
// better-auth skips origin checks entirely when NODE_ENV=test; pin the
|
|
28
|
+
// check on so the trustedOrigins contract is enforced (and testable)
|
|
29
|
+
// regardless of environment.
|
|
30
|
+
disableOriginCheck: false
|
|
31
|
+
},
|
|
32
|
+
session: {
|
|
33
|
+
cookieCache: {
|
|
34
|
+
enabled: true,
|
|
35
|
+
maxAge: 7 * 24 * 60 * 60,
|
|
36
|
+
strategy: "jwe",
|
|
37
|
+
refreshCache: true
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
account: {
|
|
41
|
+
storeStateStrategy: "cookie",
|
|
42
|
+
storeAccountCookie: true
|
|
43
|
+
},
|
|
44
|
+
plugins: [
|
|
45
|
+
expo(),
|
|
46
|
+
genericOAuth({
|
|
47
|
+
config: [
|
|
48
|
+
keycloak({
|
|
49
|
+
clientId: config.keycloak.clientId || "",
|
|
50
|
+
clientSecret: config.keycloak.clientSecret || "",
|
|
51
|
+
issuer
|
|
52
|
+
})
|
|
53
|
+
]
|
|
54
|
+
})
|
|
55
|
+
]
|
|
56
|
+
});
|
|
57
|
+
return _auth;
|
|
58
|
+
}
|
|
59
|
+
export {
|
|
60
|
+
createAuth
|
|
61
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/keycloak",
|
|
3
|
-
"version": "7.83.
|
|
3
|
+
"version": "7.83.2",
|
|
4
4
|
"description": "keycloak client for multiplatform.one ecosystem",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"auth",
|
|
@@ -31,25 +31,30 @@
|
|
|
31
31
|
"exports": {
|
|
32
32
|
"./package.json": "./package.json",
|
|
33
33
|
".": {
|
|
34
|
+
"source": "./src/index.ts",
|
|
34
35
|
"react-native": {
|
|
35
36
|
"types": "./types/index.d.ts",
|
|
36
37
|
"import": "./dist/esm/index.native.js",
|
|
37
38
|
"require": "./dist/cjs/index.native.cjs"
|
|
38
39
|
},
|
|
39
40
|
"types": "./types/index.d.ts",
|
|
40
|
-
"import": "./
|
|
41
|
-
"require": "./
|
|
42
|
-
"default": "./
|
|
41
|
+
"import": "./dist/esm/index.mjs",
|
|
42
|
+
"require": "./dist/cjs/index.cjs",
|
|
43
|
+
"default": "./dist/esm/index.mjs"
|
|
43
44
|
},
|
|
44
45
|
"./one": {
|
|
46
|
+
"source": "./src/one.ts",
|
|
45
47
|
"types": "./types/one.d.ts",
|
|
46
|
-
"import": "./
|
|
47
|
-
"require": "./
|
|
48
|
+
"import": "./dist/esm/one.mjs",
|
|
49
|
+
"require": "./dist/cjs/one.cjs",
|
|
50
|
+
"default": "./dist/esm/one.mjs"
|
|
48
51
|
},
|
|
49
52
|
"./oauth": {
|
|
53
|
+
"source": "./src/oauth/index.ts",
|
|
50
54
|
"types": "./types/oauth/index.d.ts",
|
|
51
|
-
"import": "./
|
|
52
|
-
"require": "./
|
|
55
|
+
"import": "./dist/esm/oauth/index.mjs",
|
|
56
|
+
"require": "./dist/cjs/oauth/index.cjs",
|
|
57
|
+
"default": "./dist/esm/oauth/index.mjs"
|
|
53
58
|
}
|
|
54
59
|
},
|
|
55
60
|
"publishConfig": {
|
|
@@ -57,16 +62,16 @@
|
|
|
57
62
|
},
|
|
58
63
|
"dependencies": {
|
|
59
64
|
"@better-auth/expo": "~1.6.33",
|
|
60
|
-
"@multiplatform.one/keycloak-js": "^7.83.
|
|
61
|
-
"@multiplatform.one/store": "^7.83.
|
|
65
|
+
"@multiplatform.one/keycloak-js": "^7.83.2",
|
|
66
|
+
"@multiplatform.one/store": "^7.83.2",
|
|
62
67
|
"better-auth": "~1.6.33",
|
|
63
68
|
"jwt-decode": "^4.0.0"
|
|
64
69
|
},
|
|
65
70
|
"devDependencies": {
|
|
66
|
-
"@multiplatform.one/config": "7.83.
|
|
67
|
-
"@multiplatform.one/logger": "7.83.
|
|
68
|
-
"@multiplatform.one/platform": "7.83.
|
|
69
|
-
"@multiplatform.one/test-utils": "7.83.
|
|
71
|
+
"@multiplatform.one/config": "7.83.2",
|
|
72
|
+
"@multiplatform.one/logger": "7.83.2",
|
|
73
|
+
"@multiplatform.one/platform": "7.83.2",
|
|
74
|
+
"@multiplatform.one/test-utils": "7.83.2",
|
|
70
75
|
"@tamagui/build": "2.7.6",
|
|
71
76
|
"@testing-library/jest-dom": "^7.0.1",
|
|
72
77
|
"@testing-library/react": "^16.3.3",
|
|
@@ -80,8 +85,8 @@
|
|
|
80
85
|
"vitest": "^4.1.5"
|
|
81
86
|
},
|
|
82
87
|
"peerDependencies": {
|
|
83
|
-
"@multiplatform.one/logger": "^7.83.
|
|
84
|
-
"@multiplatform.one/platform": "^7.83.
|
|
88
|
+
"@multiplatform.one/logger": "^7.83.2",
|
|
89
|
+
"@multiplatform.one/platform": "^7.83.2",
|
|
85
90
|
"react": "^19.1.0",
|
|
86
91
|
"react-native": ">=0.83.0"
|
|
87
92
|
},
|
|
File without changes
|