@happyvertical/auth 0.74.8
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/AGENT.md +33 -0
- package/LICENSE +7 -0
- package/README.md +73 -0
- package/dist/chunks/cognito-dmypylFX.js +128 -0
- package/dist/chunks/cognito-dmypylFX.js.map +1 -0
- package/dist/chunks/decode_jwt-D2OK1b8a.js +1395 -0
- package/dist/chunks/decode_jwt-D2OK1b8a.js.map +1 -0
- package/dist/chunks/github-NSZp5tVm.js +413 -0
- package/dist/chunks/github-NSZp5tVm.js.map +1 -0
- package/dist/chunks/google-HXk2ctYR.js +483 -0
- package/dist/chunks/google-HXk2ctYR.js.map +1 -0
- package/dist/chunks/index-BpsMhFXS.js +151 -0
- package/dist/chunks/index-BpsMhFXS.js.map +1 -0
- package/dist/chunks/kanidm-hkw-YPVF.js +747 -0
- package/dist/chunks/kanidm-hkw-YPVF.js.map +1 -0
- package/dist/chunks/keycloak-t6JEUeOz.js +871 -0
- package/dist/chunks/keycloak-t6JEUeOz.js.map +1 -0
- package/dist/cli/claude-context.d.ts +3 -0
- package/dist/cli/claude-context.d.ts.map +1 -0
- package/dist/cli/claude-context.js +21 -0
- package/dist/cli/claude-context.js.map +1 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +499 -0
- package/dist/index.js.map +1 -0
- package/dist/shared/errors.d.ts +227 -0
- package/dist/shared/errors.d.ts.map +1 -0
- package/dist/shared/factory.d.ts +85 -0
- package/dist/shared/factory.d.ts.map +1 -0
- package/dist/shared/providers/cognito.d.ts +38 -0
- package/dist/shared/providers/cognito.d.ts.map +1 -0
- package/dist/shared/providers/github.d.ts +65 -0
- package/dist/shared/providers/github.d.ts.map +1 -0
- package/dist/shared/providers/google.d.ts +58 -0
- package/dist/shared/providers/google.d.ts.map +1 -0
- package/dist/shared/providers/kanidm.d.ts +78 -0
- package/dist/shared/providers/kanidm.d.ts.map +1 -0
- package/dist/shared/providers/keycloak.d.ts +67 -0
- package/dist/shared/providers/keycloak.d.ts.map +1 -0
- package/dist/shared/providers/nostr/index.d.ts +47 -0
- package/dist/shared/providers/nostr/index.d.ts.map +1 -0
- package/dist/shared/types.d.ts +812 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/metadata.json +32 -0
- package/package.json +60 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
import { ConfigurationError, NetworkError, InvalidCredentialsError, AccessDeniedError, InvalidGrantError, InvalidClientError, UserNotFoundError, ProviderError, UserAlreadyExistsError, MfaRequiredError, InvalidNonceError, TokenExpiredError, InvalidTokenError, NotImplementedError } from "../index.js";
|
|
2
|
+
import { c as createRemoteJWKSet, d as decodeJwt, j as jwtVerify, J as JWTExpired, a as JWTClaimValidationFailed, b as JWSSignatureVerificationFailed } from "./decode_jwt-D2OK1b8a.js";
|
|
3
|
+
function generateRandomString(length = 32) {
|
|
4
|
+
const array = new Uint8Array(length);
|
|
5
|
+
crypto.getRandomValues(array);
|
|
6
|
+
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
|
|
7
|
+
""
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
async function generatePKCE() {
|
|
11
|
+
const verifier = generateRandomString(32);
|
|
12
|
+
const encoder = new TextEncoder();
|
|
13
|
+
const data = encoder.encode(verifier);
|
|
14
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
15
|
+
const challenge = btoa(String.fromCharCode(...new Uint8Array(hash))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
16
|
+
return { verifier, challenge };
|
|
17
|
+
}
|
|
18
|
+
class KeycloakProvider {
|
|
19
|
+
options;
|
|
20
|
+
discoveryDocument = null;
|
|
21
|
+
jwks = null;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
if (!options.serverUrl) {
|
|
24
|
+
throw new ConfigurationError("serverUrl is required", "keycloak");
|
|
25
|
+
}
|
|
26
|
+
if (!options.realm) {
|
|
27
|
+
throw new ConfigurationError("realm is required", "keycloak");
|
|
28
|
+
}
|
|
29
|
+
if (!options.clientId) {
|
|
30
|
+
throw new ConfigurationError("clientId is required", "keycloak");
|
|
31
|
+
}
|
|
32
|
+
this.options = {
|
|
33
|
+
usePKCE: true,
|
|
34
|
+
verifySsl: true,
|
|
35
|
+
scopes: ["openid", "profile", "email"],
|
|
36
|
+
timeout: 3e4,
|
|
37
|
+
maxRetries: 3,
|
|
38
|
+
...options
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// INTERNAL HELPERS
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
/**
|
|
45
|
+
* Get the base URL for the realm.
|
|
46
|
+
*/
|
|
47
|
+
getRealmUrl() {
|
|
48
|
+
return `${this.options.serverUrl}/realms/${this.options.realm}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Get the admin API base URL.
|
|
52
|
+
*/
|
|
53
|
+
getAdminUrl() {
|
|
54
|
+
return `${this.options.serverUrl}/admin/realms/${this.options.realm}`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Make an HTTP request with error handling.
|
|
58
|
+
*/
|
|
59
|
+
async request(url, options = {}, adminToken) {
|
|
60
|
+
const headers = {
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
...this.options.headers,
|
|
63
|
+
...options.headers
|
|
64
|
+
};
|
|
65
|
+
if (adminToken) {
|
|
66
|
+
headers["Authorization"] = `Bearer ${adminToken}`;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch(url, {
|
|
70
|
+
...options,
|
|
71
|
+
headers,
|
|
72
|
+
signal: AbortSignal.timeout(this.options.timeout || 3e4)
|
|
73
|
+
});
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const errorBody = await response.text().catch(() => "");
|
|
76
|
+
let errorData = {};
|
|
77
|
+
try {
|
|
78
|
+
errorData = JSON.parse(errorBody);
|
|
79
|
+
} catch {
|
|
80
|
+
}
|
|
81
|
+
this.handleHttpError(response.status, errorData, errorBody);
|
|
82
|
+
}
|
|
83
|
+
const text = await response.text();
|
|
84
|
+
if (!text) return {};
|
|
85
|
+
return JSON.parse(text);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
88
|
+
throw new NetworkError("Request timed out", "keycloak", error);
|
|
89
|
+
}
|
|
90
|
+
if (error instanceof InvalidCredentialsError || error instanceof AccessDeniedError || error instanceof InvalidGrantError || error instanceof InvalidClientError || error instanceof UserNotFoundError || error instanceof ProviderError) {
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
throw new NetworkError(
|
|
94
|
+
`Network error: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
95
|
+
"keycloak",
|
|
96
|
+
error instanceof Error ? error : void 0
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Handle HTTP error responses.
|
|
102
|
+
*/
|
|
103
|
+
handleHttpError(status, data, rawBody) {
|
|
104
|
+
const error = data.error;
|
|
105
|
+
const errorDescription = data.errorMessage || data.error_description || rawBody;
|
|
106
|
+
switch (status) {
|
|
107
|
+
case 400:
|
|
108
|
+
if (error === "invalid_grant") {
|
|
109
|
+
throw new InvalidGrantError(errorDescription, "keycloak");
|
|
110
|
+
}
|
|
111
|
+
if (error === "invalid_client") {
|
|
112
|
+
throw new InvalidClientError("keycloak");
|
|
113
|
+
}
|
|
114
|
+
throw new ProviderError(`Bad request: ${errorDescription}`, "keycloak");
|
|
115
|
+
case 401:
|
|
116
|
+
throw new InvalidCredentialsError("keycloak");
|
|
117
|
+
case 403:
|
|
118
|
+
throw new AccessDeniedError(errorDescription, "keycloak");
|
|
119
|
+
case 404:
|
|
120
|
+
throw new UserNotFoundError(void 0, "keycloak");
|
|
121
|
+
case 409:
|
|
122
|
+
throw new UserAlreadyExistsError(void 0, "keycloak");
|
|
123
|
+
default:
|
|
124
|
+
throw new ProviderError(
|
|
125
|
+
`Keycloak error (${status}): ${errorDescription}`,
|
|
126
|
+
"keycloak"
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Fetch and cache the OIDC discovery document.
|
|
132
|
+
*/
|
|
133
|
+
async fetchDiscoveryDocument() {
|
|
134
|
+
if (this.discoveryDocument) {
|
|
135
|
+
return this.discoveryDocument;
|
|
136
|
+
}
|
|
137
|
+
const url = `${this.getRealmUrl()}/.well-known/openid-configuration`;
|
|
138
|
+
this.discoveryDocument = await this.request(url);
|
|
139
|
+
return this.discoveryDocument;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Get JWKS for token validation.
|
|
143
|
+
*/
|
|
144
|
+
async getJWKS() {
|
|
145
|
+
if (this.jwks) {
|
|
146
|
+
return this.jwks;
|
|
147
|
+
}
|
|
148
|
+
const discovery = await this.fetchDiscoveryDocument();
|
|
149
|
+
this.jwks = createRemoteJWKSet(new URL(discovery.jwks_uri));
|
|
150
|
+
return this.jwks;
|
|
151
|
+
}
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// AUTHENTICATION FLOWS
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
async getAuthorizationUrl(options) {
|
|
156
|
+
const discovery = await this.fetchDiscoveryDocument();
|
|
157
|
+
const state = options?.state || generateRandomString();
|
|
158
|
+
const nonce = options?.nonce || generateRandomString();
|
|
159
|
+
const scopes = options?.scopes || this.options.scopes || ["openid", "profile", "email"];
|
|
160
|
+
const redirectUri = options?.redirectUri || this.options.redirectUri;
|
|
161
|
+
if (!redirectUri) {
|
|
162
|
+
throw new ConfigurationError("redirectUri is required", "keycloak");
|
|
163
|
+
}
|
|
164
|
+
const params = new URLSearchParams({
|
|
165
|
+
client_id: this.options.clientId,
|
|
166
|
+
redirect_uri: redirectUri,
|
|
167
|
+
response_type: "code",
|
|
168
|
+
scope: scopes.join(" "),
|
|
169
|
+
state,
|
|
170
|
+
nonce
|
|
171
|
+
});
|
|
172
|
+
if (options?.prompt) {
|
|
173
|
+
params.set("prompt", options.prompt);
|
|
174
|
+
}
|
|
175
|
+
if (options?.loginHint) {
|
|
176
|
+
params.set("login_hint", options.loginHint);
|
|
177
|
+
}
|
|
178
|
+
if (options?.extraParams) {
|
|
179
|
+
for (const [key, value] of Object.entries(options.extraParams)) {
|
|
180
|
+
params.set(key, value);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
let codeVerifier;
|
|
184
|
+
if (this.options.usePKCE) {
|
|
185
|
+
const pkce = await generatePKCE();
|
|
186
|
+
codeVerifier = pkce.verifier;
|
|
187
|
+
params.set("code_challenge", pkce.challenge);
|
|
188
|
+
params.set("code_challenge_method", "S256");
|
|
189
|
+
}
|
|
190
|
+
const url = `${discovery.authorization_endpoint}?${params.toString()}`;
|
|
191
|
+
return {
|
|
192
|
+
url,
|
|
193
|
+
state,
|
|
194
|
+
nonce,
|
|
195
|
+
codeVerifier
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
async exchangeCode(params) {
|
|
199
|
+
const discovery = await this.fetchDiscoveryDocument();
|
|
200
|
+
const redirectUri = params.redirectUri || this.options.redirectUri;
|
|
201
|
+
if (!redirectUri) {
|
|
202
|
+
throw new ConfigurationError("redirectUri is required", "keycloak");
|
|
203
|
+
}
|
|
204
|
+
const body = new URLSearchParams({
|
|
205
|
+
grant_type: "authorization_code",
|
|
206
|
+
client_id: this.options.clientId,
|
|
207
|
+
code: params.code,
|
|
208
|
+
redirect_uri: redirectUri
|
|
209
|
+
});
|
|
210
|
+
if (this.options.clientSecret) {
|
|
211
|
+
body.set("client_secret", this.options.clientSecret);
|
|
212
|
+
}
|
|
213
|
+
if (params.codeVerifier) {
|
|
214
|
+
body.set("code_verifier", params.codeVerifier);
|
|
215
|
+
}
|
|
216
|
+
const response = await this.request(discovery.token_endpoint, {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
219
|
+
body: body.toString()
|
|
220
|
+
});
|
|
221
|
+
let userId = "";
|
|
222
|
+
if (response.id_token) {
|
|
223
|
+
const payload = decodeJwt(response.id_token);
|
|
224
|
+
userId = payload.sub || "";
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
accessToken: response.access_token,
|
|
228
|
+
tokenType: response.token_type || "Bearer",
|
|
229
|
+
expiresIn: response.expires_in,
|
|
230
|
+
refreshToken: response.refresh_token,
|
|
231
|
+
idToken: response.id_token,
|
|
232
|
+
scope: response.scope,
|
|
233
|
+
userId
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
async authenticate(credentials) {
|
|
237
|
+
const discovery = await this.fetchDiscoveryDocument();
|
|
238
|
+
const grantType = credentials.grantType || "password";
|
|
239
|
+
const scopes = credentials.scopes || this.options.scopes || ["openid", "profile", "email"];
|
|
240
|
+
const body = new URLSearchParams({
|
|
241
|
+
grant_type: grantType,
|
|
242
|
+
client_id: this.options.clientId,
|
|
243
|
+
scope: scopes.join(" ")
|
|
244
|
+
});
|
|
245
|
+
if (grantType === "client_credentials") {
|
|
246
|
+
if (!this.options.clientSecret) {
|
|
247
|
+
throw new InvalidCredentialsError("keycloak", {
|
|
248
|
+
reason: "Client secret is required for client_credentials grant"
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
body.set("client_secret", this.options.clientSecret);
|
|
252
|
+
} else {
|
|
253
|
+
if (!credentials.username || !credentials.password) {
|
|
254
|
+
throw new InvalidCredentialsError("keycloak", {
|
|
255
|
+
reason: "Username and password are required"
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
body.set("username", credentials.username);
|
|
259
|
+
body.set("password", credentials.password);
|
|
260
|
+
if (this.options.clientSecret) {
|
|
261
|
+
body.set("client_secret", this.options.clientSecret);
|
|
262
|
+
}
|
|
263
|
+
if (credentials.mfaCode) {
|
|
264
|
+
body.set("totp", credentials.mfaCode);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const response = await this.request(discovery.token_endpoint, {
|
|
269
|
+
method: "POST",
|
|
270
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
271
|
+
body: body.toString()
|
|
272
|
+
});
|
|
273
|
+
let userId = "";
|
|
274
|
+
if (response.id_token) {
|
|
275
|
+
const payload = decodeJwt(response.id_token);
|
|
276
|
+
userId = payload.sub || "";
|
|
277
|
+
} else if (response.access_token) {
|
|
278
|
+
const payload = decodeJwt(response.access_token);
|
|
279
|
+
userId = payload.sub || "";
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
accessToken: response.access_token,
|
|
283
|
+
tokenType: response.token_type || "Bearer",
|
|
284
|
+
expiresIn: response.expires_in,
|
|
285
|
+
refreshToken: response.refresh_token,
|
|
286
|
+
idToken: response.id_token,
|
|
287
|
+
scope: response.scope,
|
|
288
|
+
userId
|
|
289
|
+
};
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (error instanceof ProviderError && error.message.includes("invalid_grant") && error.message.includes("totp")) {
|
|
292
|
+
throw new MfaRequiredError("keycloak", ["totp"]);
|
|
293
|
+
}
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async refresh(refreshToken) {
|
|
298
|
+
const discovery = await this.fetchDiscoveryDocument();
|
|
299
|
+
const body = new URLSearchParams({
|
|
300
|
+
grant_type: "refresh_token",
|
|
301
|
+
client_id: this.options.clientId,
|
|
302
|
+
refresh_token: refreshToken
|
|
303
|
+
});
|
|
304
|
+
if (this.options.clientSecret) {
|
|
305
|
+
body.set("client_secret", this.options.clientSecret);
|
|
306
|
+
}
|
|
307
|
+
const response = await this.request(discovery.token_endpoint, {
|
|
308
|
+
method: "POST",
|
|
309
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
310
|
+
body: body.toString()
|
|
311
|
+
});
|
|
312
|
+
let userId = "";
|
|
313
|
+
if (response.access_token) {
|
|
314
|
+
const payload = decodeJwt(response.access_token);
|
|
315
|
+
userId = payload.sub || "";
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
accessToken: response.access_token,
|
|
319
|
+
tokenType: response.token_type || "Bearer",
|
|
320
|
+
expiresIn: response.expires_in,
|
|
321
|
+
refreshToken: response.refresh_token || refreshToken,
|
|
322
|
+
idToken: response.id_token,
|
|
323
|
+
scope: response.scope,
|
|
324
|
+
userId
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
async logout(options) {
|
|
328
|
+
const discovery = await this.fetchDiscoveryDocument();
|
|
329
|
+
if (!discovery.end_session_endpoint) {
|
|
330
|
+
if (options?.refreshToken && discovery.revocation_endpoint) {
|
|
331
|
+
await this.request(discovery.revocation_endpoint, {
|
|
332
|
+
method: "POST",
|
|
333
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
334
|
+
body: new URLSearchParams({
|
|
335
|
+
client_id: this.options.clientId,
|
|
336
|
+
token: options.refreshToken,
|
|
337
|
+
token_type_hint: "refresh_token"
|
|
338
|
+
}).toString()
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const params = new URLSearchParams({
|
|
344
|
+
client_id: this.options.clientId
|
|
345
|
+
});
|
|
346
|
+
if (options?.token) {
|
|
347
|
+
params.set("id_token_hint", options.token);
|
|
348
|
+
}
|
|
349
|
+
if (options?.postLogoutRedirectUri) {
|
|
350
|
+
params.set("post_logout_redirect_uri", options.postLogoutRedirectUri);
|
|
351
|
+
}
|
|
352
|
+
if (options?.refreshToken && discovery.revocation_endpoint) {
|
|
353
|
+
const revokeParams = new URLSearchParams({
|
|
354
|
+
client_id: this.options.clientId,
|
|
355
|
+
token: options.refreshToken,
|
|
356
|
+
token_type_hint: "refresh_token"
|
|
357
|
+
});
|
|
358
|
+
if (this.options.clientSecret) {
|
|
359
|
+
revokeParams.set("client_secret", this.options.clientSecret);
|
|
360
|
+
}
|
|
361
|
+
await this.request(discovery.revocation_endpoint, {
|
|
362
|
+
method: "POST",
|
|
363
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
364
|
+
body: revokeParams.toString()
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// TOKEN OPERATIONS
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
async validateToken(token, options) {
|
|
372
|
+
try {
|
|
373
|
+
const jwks = await this.getJWKS();
|
|
374
|
+
const discovery = await this.fetchDiscoveryDocument();
|
|
375
|
+
const verifyOptions = {
|
|
376
|
+
issuer: options?.issuer || discovery.issuer,
|
|
377
|
+
clockTolerance: options?.clockTolerance || 0
|
|
378
|
+
};
|
|
379
|
+
if (options?.audience) {
|
|
380
|
+
verifyOptions.audience = options.audience;
|
|
381
|
+
}
|
|
382
|
+
const { payload } = await jwtVerify(token, jwks, verifyOptions);
|
|
383
|
+
if (options?.nonce && payload.nonce !== options.nonce) {
|
|
384
|
+
throw new InvalidNonceError("keycloak");
|
|
385
|
+
}
|
|
386
|
+
const roles = [];
|
|
387
|
+
if (payload.realm_access && typeof payload.realm_access === "object") {
|
|
388
|
+
const realmAccess = payload.realm_access;
|
|
389
|
+
if (realmAccess.roles) {
|
|
390
|
+
roles.push(...realmAccess.roles);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (payload.resource_access && typeof payload.resource_access === "object") {
|
|
394
|
+
const resourceAccess = payload.resource_access;
|
|
395
|
+
for (const client of Object.values(resourceAccess)) {
|
|
396
|
+
if (client.roles) {
|
|
397
|
+
roles.push(...client.roles);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
sub: payload.sub || "",
|
|
403
|
+
iss: payload.iss || "",
|
|
404
|
+
aud: payload.aud || "",
|
|
405
|
+
exp: payload.exp || 0,
|
|
406
|
+
iat: payload.iat || 0,
|
|
407
|
+
nbf: payload.nbf,
|
|
408
|
+
azp: payload.azp,
|
|
409
|
+
email: payload.email,
|
|
410
|
+
email_verified: payload.email_verified,
|
|
411
|
+
preferred_username: payload.preferred_username,
|
|
412
|
+
name: payload.name,
|
|
413
|
+
roles,
|
|
414
|
+
...payload
|
|
415
|
+
};
|
|
416
|
+
} catch (error) {
|
|
417
|
+
if (error instanceof JWTExpired) {
|
|
418
|
+
throw new TokenExpiredError("keycloak");
|
|
419
|
+
}
|
|
420
|
+
if (error instanceof JWTClaimValidationFailed) {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
if (error instanceof JWSSignatureVerificationFailed) {
|
|
424
|
+
throw new InvalidTokenError("Invalid token signature", "keycloak");
|
|
425
|
+
}
|
|
426
|
+
if (error instanceof InvalidNonceError) {
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
decodeToken(token) {
|
|
433
|
+
try {
|
|
434
|
+
const parts = token.split(".");
|
|
435
|
+
if (parts.length !== 3) {
|
|
436
|
+
throw new InvalidTokenError("Invalid JWT format", "keycloak");
|
|
437
|
+
}
|
|
438
|
+
const header = JSON.parse(atob(parts[0]));
|
|
439
|
+
const payload = decodeJwt(token);
|
|
440
|
+
return {
|
|
441
|
+
header: {
|
|
442
|
+
alg: header.alg,
|
|
443
|
+
typ: header.typ,
|
|
444
|
+
kid: header.kid
|
|
445
|
+
},
|
|
446
|
+
payload,
|
|
447
|
+
signature: parts[2]
|
|
448
|
+
};
|
|
449
|
+
} catch {
|
|
450
|
+
throw new InvalidTokenError("Failed to decode token", "keycloak");
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
async introspectToken(token) {
|
|
454
|
+
const discovery = await this.fetchDiscoveryDocument();
|
|
455
|
+
if (!discovery.introspection_endpoint) {
|
|
456
|
+
const claims = await this.validateToken(token);
|
|
457
|
+
return {
|
|
458
|
+
active: claims !== null,
|
|
459
|
+
claims: claims || void 0
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
const body = new URLSearchParams({
|
|
463
|
+
client_id: this.options.clientId,
|
|
464
|
+
token
|
|
465
|
+
});
|
|
466
|
+
if (this.options.clientSecret) {
|
|
467
|
+
body.set("client_secret", this.options.clientSecret);
|
|
468
|
+
}
|
|
469
|
+
const response = await this.request(discovery.introspection_endpoint, {
|
|
470
|
+
method: "POST",
|
|
471
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
472
|
+
body: body.toString()
|
|
473
|
+
});
|
|
474
|
+
if (!response.active) {
|
|
475
|
+
return { active: false };
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
active: true,
|
|
479
|
+
claims: {
|
|
480
|
+
sub: response.sub || "",
|
|
481
|
+
iss: response.iss || "",
|
|
482
|
+
aud: response.aud || "",
|
|
483
|
+
exp: response.exp || 0,
|
|
484
|
+
iat: response.iat || 0,
|
|
485
|
+
...response
|
|
486
|
+
},
|
|
487
|
+
tokenType: response.token_type,
|
|
488
|
+
clientId: response.client_id,
|
|
489
|
+
scope: response.scope
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
// USER OPERATIONS
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
async getProfile(tokenOrSession) {
|
|
496
|
+
const discovery = await this.fetchDiscoveryDocument();
|
|
497
|
+
const response = await this.request(discovery.userinfo_endpoint, {
|
|
498
|
+
method: "GET",
|
|
499
|
+
headers: { Authorization: `Bearer ${tokenOrSession}` }
|
|
500
|
+
});
|
|
501
|
+
return {
|
|
502
|
+
id: response.sub,
|
|
503
|
+
username: response.preferred_username,
|
|
504
|
+
email: response.email,
|
|
505
|
+
emailVerified: response.email_verified,
|
|
506
|
+
firstName: response.given_name,
|
|
507
|
+
lastName: response.family_name,
|
|
508
|
+
displayName: response.name,
|
|
509
|
+
picture: response.picture
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
async updateProfile(tokenOrSession, profile) {
|
|
513
|
+
const payload = decodeJwt(tokenOrSession);
|
|
514
|
+
const userId = payload.sub;
|
|
515
|
+
if (!userId) {
|
|
516
|
+
throw new InvalidTokenError("Token does not contain user ID", "keycloak");
|
|
517
|
+
}
|
|
518
|
+
const accountUrl = `${this.getRealmUrl()}/account`;
|
|
519
|
+
const updateData = {};
|
|
520
|
+
if (profile.firstName !== void 0)
|
|
521
|
+
updateData.firstName = profile.firstName;
|
|
522
|
+
if (profile.lastName !== void 0) updateData.lastName = profile.lastName;
|
|
523
|
+
if (profile.email !== void 0) updateData.email = profile.email;
|
|
524
|
+
await this.request(accountUrl, {
|
|
525
|
+
method: "POST",
|
|
526
|
+
headers: { Authorization: `Bearer ${tokenOrSession}` },
|
|
527
|
+
body: JSON.stringify(updateData)
|
|
528
|
+
});
|
|
529
|
+
return this.getProfile(tokenOrSession);
|
|
530
|
+
}
|
|
531
|
+
async getUser(userId, adminToken) {
|
|
532
|
+
if (!adminToken) {
|
|
533
|
+
throw new AccessDeniedError("Admin token required", "keycloak");
|
|
534
|
+
}
|
|
535
|
+
const response = await this.request(
|
|
536
|
+
`${this.getAdminUrl()}/users/${userId}`,
|
|
537
|
+
{ method: "GET" },
|
|
538
|
+
adminToken
|
|
539
|
+
);
|
|
540
|
+
return this.mapKeycloakUser(response);
|
|
541
|
+
}
|
|
542
|
+
async createUser(user, adminToken) {
|
|
543
|
+
const keycloakUser = {
|
|
544
|
+
username: user.username,
|
|
545
|
+
email: user.email,
|
|
546
|
+
firstName: user.firstName,
|
|
547
|
+
lastName: user.lastName,
|
|
548
|
+
enabled: user.enabled ?? true,
|
|
549
|
+
emailVerified: user.emailVerified ?? false,
|
|
550
|
+
attributes: user.attributes
|
|
551
|
+
};
|
|
552
|
+
if (user.password) {
|
|
553
|
+
keycloakUser.credentials = [
|
|
554
|
+
{
|
|
555
|
+
type: "password",
|
|
556
|
+
value: user.password,
|
|
557
|
+
temporary: false
|
|
558
|
+
}
|
|
559
|
+
];
|
|
560
|
+
}
|
|
561
|
+
const response = await fetch(`${this.getAdminUrl()}/users`, {
|
|
562
|
+
method: "POST",
|
|
563
|
+
headers: {
|
|
564
|
+
"Content-Type": "application/json",
|
|
565
|
+
Authorization: `Bearer ${adminToken}`
|
|
566
|
+
},
|
|
567
|
+
body: JSON.stringify(keycloakUser)
|
|
568
|
+
});
|
|
569
|
+
if (!response.ok) {
|
|
570
|
+
const errorBody = await response.text();
|
|
571
|
+
if (response.status === 409) {
|
|
572
|
+
throw new UserAlreadyExistsError(user.username, "keycloak");
|
|
573
|
+
}
|
|
574
|
+
throw new ProviderError(
|
|
575
|
+
`Failed to create user: ${errorBody}`,
|
|
576
|
+
"keycloak"
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
const location = response.headers.get("Location");
|
|
580
|
+
if (!location) {
|
|
581
|
+
throw new ProviderError("Failed to get created user ID", "keycloak");
|
|
582
|
+
}
|
|
583
|
+
const userId = location.split("/").pop();
|
|
584
|
+
if (user.roles?.length) {
|
|
585
|
+
await this.assignRoles(userId, user.roles, adminToken);
|
|
586
|
+
}
|
|
587
|
+
if (user.groups?.length) {
|
|
588
|
+
for (const groupName of user.groups) {
|
|
589
|
+
await this.addUserToGroup(userId, groupName, adminToken);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return this.getUser(userId, adminToken);
|
|
593
|
+
}
|
|
594
|
+
async updateUser(userId, updates, adminToken) {
|
|
595
|
+
const keycloakUser = {};
|
|
596
|
+
if (updates.username !== void 0)
|
|
597
|
+
keycloakUser.username = updates.username;
|
|
598
|
+
if (updates.email !== void 0) keycloakUser.email = updates.email;
|
|
599
|
+
if (updates.firstName !== void 0)
|
|
600
|
+
keycloakUser.firstName = updates.firstName;
|
|
601
|
+
if (updates.lastName !== void 0)
|
|
602
|
+
keycloakUser.lastName = updates.lastName;
|
|
603
|
+
if (updates.enabled !== void 0) keycloakUser.enabled = updates.enabled;
|
|
604
|
+
if (updates.emailVerified !== void 0)
|
|
605
|
+
keycloakUser.emailVerified = updates.emailVerified;
|
|
606
|
+
if (updates.attributes !== void 0) {
|
|
607
|
+
keycloakUser.attributes = updates.attributes;
|
|
608
|
+
}
|
|
609
|
+
await this.request(
|
|
610
|
+
`${this.getAdminUrl()}/users/${userId}`,
|
|
611
|
+
{
|
|
612
|
+
method: "PUT",
|
|
613
|
+
body: JSON.stringify(keycloakUser)
|
|
614
|
+
},
|
|
615
|
+
adminToken
|
|
616
|
+
);
|
|
617
|
+
if (updates.password) {
|
|
618
|
+
await this.request(
|
|
619
|
+
`${this.getAdminUrl()}/users/${userId}/reset-password`,
|
|
620
|
+
{
|
|
621
|
+
method: "PUT",
|
|
622
|
+
body: JSON.stringify({
|
|
623
|
+
type: "password",
|
|
624
|
+
value: updates.password,
|
|
625
|
+
temporary: false
|
|
626
|
+
})
|
|
627
|
+
},
|
|
628
|
+
adminToken
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
return this.getUser(userId, adminToken);
|
|
632
|
+
}
|
|
633
|
+
async deleteUser(userId, adminToken) {
|
|
634
|
+
await this.request(
|
|
635
|
+
`${this.getAdminUrl()}/users/${userId}`,
|
|
636
|
+
{ method: "DELETE" },
|
|
637
|
+
adminToken
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
async listUsers(query, adminToken) {
|
|
641
|
+
if (!adminToken) {
|
|
642
|
+
throw new AccessDeniedError("Admin token required", "keycloak");
|
|
643
|
+
}
|
|
644
|
+
const params = new URLSearchParams();
|
|
645
|
+
if (query.search) params.set("search", query.search);
|
|
646
|
+
if (query.email) params.set("email", query.email);
|
|
647
|
+
if (query.username) params.set("username", query.username);
|
|
648
|
+
if (query.enabled !== void 0)
|
|
649
|
+
params.set("enabled", String(query.enabled));
|
|
650
|
+
if (query.limit) params.set("max", String(query.limit));
|
|
651
|
+
if (query.offset) params.set("first", String(query.offset));
|
|
652
|
+
const users = await this.request(
|
|
653
|
+
`${this.getAdminUrl()}/users?${params.toString()}`,
|
|
654
|
+
{ method: "GET" },
|
|
655
|
+
adminToken
|
|
656
|
+
);
|
|
657
|
+
const countResponse = await this.request(
|
|
658
|
+
`${this.getAdminUrl()}/users/count?${params.toString()}`,
|
|
659
|
+
{ method: "GET" },
|
|
660
|
+
adminToken
|
|
661
|
+
);
|
|
662
|
+
return {
|
|
663
|
+
users: users.map((u) => this.mapKeycloakUser(u)),
|
|
664
|
+
total: countResponse,
|
|
665
|
+
limit: query.limit || 100,
|
|
666
|
+
offset: query.offset || 0
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
async requestPasswordReset(email) {
|
|
670
|
+
throw new NotImplementedError("requestPasswordReset", "keycloak", {
|
|
671
|
+
reason: "Requires admin token or use Keycloak login page for self-service reset"
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
async resetPassword(token, newPassword) {
|
|
675
|
+
throw new NotImplementedError("resetPassword", "keycloak", {
|
|
676
|
+
reason: "Password reset is handled by Keycloak login flow"
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
// ---------------------------------------------------------------------------
|
|
680
|
+
// SESSION OPERATIONS
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
async listSessions(userId, adminToken) {
|
|
683
|
+
if (!adminToken) {
|
|
684
|
+
throw new AccessDeniedError("Admin token required", "keycloak");
|
|
685
|
+
}
|
|
686
|
+
const sessions = await this.request(
|
|
687
|
+
`${this.getAdminUrl()}/users/${userId}/sessions`,
|
|
688
|
+
{ method: "GET" },
|
|
689
|
+
adminToken
|
|
690
|
+
);
|
|
691
|
+
return sessions.map((s) => ({
|
|
692
|
+
id: s.id,
|
|
693
|
+
userId: s.userId,
|
|
694
|
+
clientId: s.clients ? Object.keys(s.clients).join(", ") : void 0,
|
|
695
|
+
startedAt: new Date(s.start),
|
|
696
|
+
lastAccessedAt: new Date(s.lastAccess),
|
|
697
|
+
ipAddress: s.ipAddress,
|
|
698
|
+
userAgent: s.userAgent
|
|
699
|
+
}));
|
|
700
|
+
}
|
|
701
|
+
async revokeSession(sessionId, adminToken) {
|
|
702
|
+
if (!adminToken) {
|
|
703
|
+
throw new AccessDeniedError("Admin token required", "keycloak");
|
|
704
|
+
}
|
|
705
|
+
await this.request(
|
|
706
|
+
`${this.getAdminUrl()}/sessions/${sessionId}`,
|
|
707
|
+
{ method: "DELETE" },
|
|
708
|
+
adminToken
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
async revokeAllSessions(userId, adminToken) {
|
|
712
|
+
if (!adminToken) {
|
|
713
|
+
throw new AccessDeniedError("Admin token required", "keycloak");
|
|
714
|
+
}
|
|
715
|
+
await this.request(
|
|
716
|
+
`${this.getAdminUrl()}/users/${userId}/logout`,
|
|
717
|
+
{ method: "POST" },
|
|
718
|
+
adminToken
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
// ---------------------------------------------------------------------------
|
|
722
|
+
// AUTHORIZATION
|
|
723
|
+
// ---------------------------------------------------------------------------
|
|
724
|
+
async hasRole(tokenOrUserId, role) {
|
|
725
|
+
const roles = await this.getRoles(tokenOrUserId);
|
|
726
|
+
return roles.includes(role);
|
|
727
|
+
}
|
|
728
|
+
async hasPermission(tokenOrUserId, permission, resource) {
|
|
729
|
+
const roles = await this.getRoles(tokenOrUserId);
|
|
730
|
+
const permissionRole = resource ? `${resource}:${permission}` : permission;
|
|
731
|
+
return roles.includes(permissionRole) || roles.includes(permission);
|
|
732
|
+
}
|
|
733
|
+
async getRoles(tokenOrUserId, adminToken) {
|
|
734
|
+
try {
|
|
735
|
+
const claims = await this.validateToken(tokenOrUserId);
|
|
736
|
+
if (claims) {
|
|
737
|
+
return claims.roles || [];
|
|
738
|
+
}
|
|
739
|
+
} catch {
|
|
740
|
+
}
|
|
741
|
+
if (adminToken) {
|
|
742
|
+
try {
|
|
743
|
+
const roleMappings = await this.request(
|
|
744
|
+
`${this.getAdminUrl()}/users/${tokenOrUserId}/role-mappings`,
|
|
745
|
+
{ method: "GET" },
|
|
746
|
+
adminToken
|
|
747
|
+
);
|
|
748
|
+
return roleMappings.realmMappings?.map((r) => r.name) || [];
|
|
749
|
+
} catch {
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return [];
|
|
753
|
+
}
|
|
754
|
+
async assignRole(userId, role, adminToken) {
|
|
755
|
+
const roles = await this.request(
|
|
756
|
+
`${this.getAdminUrl()}/roles`,
|
|
757
|
+
{ method: "GET" },
|
|
758
|
+
adminToken
|
|
759
|
+
);
|
|
760
|
+
const roleObj = roles.find((r) => r.name === role);
|
|
761
|
+
if (!roleObj) {
|
|
762
|
+
throw new ProviderError(`Role not found: ${role}`, "keycloak");
|
|
763
|
+
}
|
|
764
|
+
await this.request(
|
|
765
|
+
`${this.getAdminUrl()}/users/${userId}/role-mappings/realm`,
|
|
766
|
+
{
|
|
767
|
+
method: "POST",
|
|
768
|
+
body: JSON.stringify([roleObj])
|
|
769
|
+
},
|
|
770
|
+
adminToken
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
async removeRole(userId, role, adminToken) {
|
|
774
|
+
const roles = await this.request(
|
|
775
|
+
`${this.getAdminUrl()}/roles`,
|
|
776
|
+
{ method: "GET" },
|
|
777
|
+
adminToken
|
|
778
|
+
);
|
|
779
|
+
const roleObj = roles.find((r) => r.name === role);
|
|
780
|
+
if (!roleObj) {
|
|
781
|
+
throw new ProviderError(`Role not found: ${role}`, "keycloak");
|
|
782
|
+
}
|
|
783
|
+
await this.request(
|
|
784
|
+
`${this.getAdminUrl()}/users/${userId}/role-mappings/realm`,
|
|
785
|
+
{
|
|
786
|
+
method: "DELETE",
|
|
787
|
+
body: JSON.stringify([roleObj])
|
|
788
|
+
},
|
|
789
|
+
adminToken
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
// PROVIDER INFORMATION
|
|
794
|
+
// ---------------------------------------------------------------------------
|
|
795
|
+
async getCapabilities() {
|
|
796
|
+
return {
|
|
797
|
+
authorizationCode: true,
|
|
798
|
+
passwordGrant: true,
|
|
799
|
+
clientCredentials: true,
|
|
800
|
+
tokenRefresh: true,
|
|
801
|
+
oidc: true,
|
|
802
|
+
userManagement: true,
|
|
803
|
+
sessionManagement: true,
|
|
804
|
+
rbac: true,
|
|
805
|
+
passwordReset: true,
|
|
806
|
+
mfa: true,
|
|
807
|
+
socialLogin: true,
|
|
808
|
+
federation: true,
|
|
809
|
+
decentralized: false
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
async getDiscoveryDocument() {
|
|
813
|
+
return this.fetchDiscoveryDocument();
|
|
814
|
+
}
|
|
815
|
+
// ---------------------------------------------------------------------------
|
|
816
|
+
// PRIVATE HELPERS
|
|
817
|
+
// ---------------------------------------------------------------------------
|
|
818
|
+
mapKeycloakUser(user) {
|
|
819
|
+
return {
|
|
820
|
+
id: user.id,
|
|
821
|
+
username: user.username,
|
|
822
|
+
email: user.email,
|
|
823
|
+
emailVerified: user.emailVerified,
|
|
824
|
+
firstName: user.firstName,
|
|
825
|
+
lastName: user.lastName,
|
|
826
|
+
displayName: user.firstName && user.lastName ? `${user.firstName} ${user.lastName}` : user.username,
|
|
827
|
+
enabled: user.enabled,
|
|
828
|
+
createdAt: user.createdTimestamp ? new Date(user.createdTimestamp) : void 0,
|
|
829
|
+
attributes: user.attributes,
|
|
830
|
+
groups: user.groups
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
async assignRoles(userId, roleNames, adminToken) {
|
|
834
|
+
const allRoles = await this.request(
|
|
835
|
+
`${this.getAdminUrl()}/roles`,
|
|
836
|
+
{ method: "GET" },
|
|
837
|
+
adminToken
|
|
838
|
+
);
|
|
839
|
+
const rolesToAssign = allRoles.filter((r) => roleNames.includes(r.name));
|
|
840
|
+
if (rolesToAssign.length > 0) {
|
|
841
|
+
await this.request(
|
|
842
|
+
`${this.getAdminUrl()}/users/${userId}/role-mappings/realm`,
|
|
843
|
+
{
|
|
844
|
+
method: "POST",
|
|
845
|
+
body: JSON.stringify(rolesToAssign)
|
|
846
|
+
},
|
|
847
|
+
adminToken
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
async addUserToGroup(userId, groupName, adminToken) {
|
|
852
|
+
const groups = await this.request(
|
|
853
|
+
`${this.getAdminUrl()}/groups?search=${encodeURIComponent(groupName)}`,
|
|
854
|
+
{ method: "GET" },
|
|
855
|
+
adminToken
|
|
856
|
+
);
|
|
857
|
+
const group = groups.find((g) => g.name === groupName);
|
|
858
|
+
if (!group) {
|
|
859
|
+
throw new ProviderError(`Group not found: ${groupName}`, "keycloak");
|
|
860
|
+
}
|
|
861
|
+
await this.request(
|
|
862
|
+
`${this.getAdminUrl()}/users/${userId}/groups/${group.id}`,
|
|
863
|
+
{ method: "PUT" },
|
|
864
|
+
adminToken
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
export {
|
|
869
|
+
KeycloakProvider
|
|
870
|
+
};
|
|
871
|
+
//# sourceMappingURL=keycloak-t6JEUeOz.js.map
|