@multiplatform.one/keycloak-js 6.0.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 +201 -0
- package/README.md +29 -0
- package/dist/keycloak-authz.d.ts +143 -0
- package/dist/keycloak-authz.js +296 -0
- package/dist/keycloak.d.ts +692 -0
- package/dist/keycloak.js +2192 -0
- package/package.json +43 -0
- package/patches/README.md +34 -0
- package/patches/timeout-overflow.patch +11 -0
package/dist/keycloak.js
ADDED
|
@@ -0,0 +1,2192 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @import {Acr, KeycloakAccountOptions, KeycloakAdapter, KeycloakConfig, KeycloakError, KeycloakFlow, KeycloakInitOptions, KeycloakLoginOptions, KeycloakLogoutOptions, KeycloakPkceMethod, KeycloakProfile, KeycloakRegisterOptions, KeycloakResourceAccess, KeycloakResponseMode, KeycloakResponseType, KeycloakRoles, KeycloakTokenParsed, OpenIdProviderMetadata} from "./keycloak.ts"
|
|
4
|
+
*/
|
|
5
|
+
/*
|
|
6
|
+
* Copyright 2016 Red Hat, Inc. and/or its affiliates
|
|
7
|
+
* and other contributors as indicated by the @author tags.
|
|
8
|
+
*
|
|
9
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
10
|
+
* you may not use this file except in compliance with the License.
|
|
11
|
+
* You may obtain a copy of the License at
|
|
12
|
+
*
|
|
13
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
14
|
+
*
|
|
15
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
16
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
17
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
18
|
+
* See the License for the specific language governing permissions and
|
|
19
|
+
* limitations under the License.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const CONTENT_TYPE_JSON = 'application/json'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} Endpoints
|
|
26
|
+
* @property {() => string} authorize
|
|
27
|
+
* @property {() => string} token
|
|
28
|
+
* @property {() => string} logout
|
|
29
|
+
* @property {() => string} checkSessionIframe
|
|
30
|
+
* @property {() => string=} thirdPartyCookiesIframe
|
|
31
|
+
* @property {() => string} register
|
|
32
|
+
* @property {() => string} userinfo
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {Object} LoginIframe
|
|
37
|
+
* @property {boolean} enable
|
|
38
|
+
* @property {((error: Error | null, value?: boolean) => void)[]} callbackList
|
|
39
|
+
* @property {number} interval
|
|
40
|
+
* @property {HTMLIFrameElement=} iframe
|
|
41
|
+
* @property {string=} iframeOrigin
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export default class Keycloak {
|
|
45
|
+
/** @type {Pick<PromiseWithResolvers<boolean>, 'resolve' | 'reject'>[]} */
|
|
46
|
+
#refreshQueue = []
|
|
47
|
+
/** @type {KeycloakAdapter} */
|
|
48
|
+
#adapter
|
|
49
|
+
/** @type {boolean} */
|
|
50
|
+
#useNonce = true
|
|
51
|
+
/** @type {CallbackStorage} */
|
|
52
|
+
#callbackStorage
|
|
53
|
+
#logInfo = this.#createLogger(console.info)
|
|
54
|
+
#logWarn = this.#createLogger(console.warn)
|
|
55
|
+
/** @type {LoginIframe} */
|
|
56
|
+
#loginIframe = {
|
|
57
|
+
enable: true,
|
|
58
|
+
callbackList: [],
|
|
59
|
+
interval: 5
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @type {KeycloakConfig} config */
|
|
63
|
+
#config
|
|
64
|
+
didInitialize = false
|
|
65
|
+
authenticated = false
|
|
66
|
+
loginRequired = false
|
|
67
|
+
/** @type {KeycloakResponseMode} */
|
|
68
|
+
responseMode = 'fragment'
|
|
69
|
+
/** @type {KeycloakResponseType} */
|
|
70
|
+
responseType = 'code'
|
|
71
|
+
/** @type {KeycloakFlow} */
|
|
72
|
+
flow = 'standard'
|
|
73
|
+
/** @type {number?} */
|
|
74
|
+
timeSkew = null
|
|
75
|
+
/** @type {string=} */
|
|
76
|
+
redirectUri
|
|
77
|
+
/** @type {string=} */
|
|
78
|
+
silentCheckSsoRedirectUri
|
|
79
|
+
/** @type {boolean} */
|
|
80
|
+
silentCheckSsoFallback = true
|
|
81
|
+
/** @type {KeycloakPkceMethod} */
|
|
82
|
+
pkceMethod = 'S256'
|
|
83
|
+
enableLogging = false
|
|
84
|
+
/** @type {'GET' | 'POST'} */
|
|
85
|
+
logoutMethod = 'GET'
|
|
86
|
+
/** @type {string=} */
|
|
87
|
+
scope
|
|
88
|
+
messageReceiveTimeout = 10000
|
|
89
|
+
/** @type {string=} */
|
|
90
|
+
idToken
|
|
91
|
+
/** @type {KeycloakTokenParsed=} */
|
|
92
|
+
idTokenParsed
|
|
93
|
+
/** @type {string=} */
|
|
94
|
+
token
|
|
95
|
+
/** @type {KeycloakTokenParsed=} */
|
|
96
|
+
tokenParsed
|
|
97
|
+
/** @type {string=} */
|
|
98
|
+
refreshToken
|
|
99
|
+
/** @type {KeycloakTokenParsed=} */
|
|
100
|
+
refreshTokenParsed
|
|
101
|
+
/** @type {string=} */
|
|
102
|
+
clientId
|
|
103
|
+
/** @type {string=} */
|
|
104
|
+
sessionId
|
|
105
|
+
/** @type {string=} */
|
|
106
|
+
subject
|
|
107
|
+
/** @type {string=} */
|
|
108
|
+
authServerUrl
|
|
109
|
+
/** @type {string=} */
|
|
110
|
+
realm
|
|
111
|
+
/** @type {KeycloakRoles=} */
|
|
112
|
+
realmAccess
|
|
113
|
+
/** @type {KeycloakResourceAccess=} */
|
|
114
|
+
resourceAccess
|
|
115
|
+
/** @type {KeycloakProfile=} */
|
|
116
|
+
profile
|
|
117
|
+
/** @type {{}=} */
|
|
118
|
+
userInfo
|
|
119
|
+
/** @type {Endpoints} */
|
|
120
|
+
endpoints
|
|
121
|
+
/** @type {number=} */
|
|
122
|
+
tokenTimeoutHandle
|
|
123
|
+
/** @type {() => void=} */
|
|
124
|
+
onAuthSuccess
|
|
125
|
+
/** @type {(errorData?: KeycloakError) => void=} */
|
|
126
|
+
onAuthError
|
|
127
|
+
/** @type {() => void=} */
|
|
128
|
+
onAuthRefreshSuccess
|
|
129
|
+
/** @type {() => void=} */
|
|
130
|
+
onAuthRefreshError
|
|
131
|
+
/** @type {() => void=} */
|
|
132
|
+
onTokenExpired
|
|
133
|
+
/** @type {() => void=} */
|
|
134
|
+
onAuthLogout
|
|
135
|
+
/** @type {(authenticated: boolean) => void=} */
|
|
136
|
+
onReady
|
|
137
|
+
/** @type {(status: 'success' | 'cancelled' | 'error', action: string) => void=} */
|
|
138
|
+
onActionUpdate
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {KeycloakConfig} config
|
|
142
|
+
*/
|
|
143
|
+
constructor (config) {
|
|
144
|
+
if (typeof config !== 'string' && !isObject(config)) {
|
|
145
|
+
throw new Error("The 'Keycloak' constructor must be provided with a configuration object, or a URL to a JSON configuration file.")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isObject(config)) {
|
|
149
|
+
const requiredProperties = 'oidcProvider' in config
|
|
150
|
+
? ['clientId']
|
|
151
|
+
: ['url', 'realm', 'clientId']
|
|
152
|
+
|
|
153
|
+
for (const property of requiredProperties) {
|
|
154
|
+
if (!(property in config)) {
|
|
155
|
+
throw new Error(`The configuration object is missing the required '${property}' property.`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!globalThis.isSecureContext) {
|
|
161
|
+
this.#logWarn(
|
|
162
|
+
"[KEYCLOAK] Keycloak JS must be used in a 'secure context' to function properly as it relies on browser APIs that are otherwise not available.\n" +
|
|
163
|
+
'Continuing to run your application insecurely will lead to unexpected behavior and breakage.\n\n' +
|
|
164
|
+
'For more information see: https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts'
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this.#config = config
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {KeycloakInitOptions} initOptions
|
|
173
|
+
* @returns {Promise<boolean>}
|
|
174
|
+
*/
|
|
175
|
+
init = async (initOptions = {}) => {
|
|
176
|
+
if (this.didInitialize) {
|
|
177
|
+
throw new Error("A 'Keycloak' instance can only be initialized once.")
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this.didInitialize = true
|
|
181
|
+
this.#callbackStorage = createCallbackStorage()
|
|
182
|
+
|
|
183
|
+
const adapters = ['default', 'cordova', 'cordova-native']
|
|
184
|
+
|
|
185
|
+
if (typeof initOptions.adapter === 'string' && adapters.includes(initOptions.adapter)) {
|
|
186
|
+
this.#adapter = this.#loadAdapter(initOptions.adapter)
|
|
187
|
+
} else if (typeof initOptions.adapter === 'object') {
|
|
188
|
+
this.#adapter = initOptions.adapter
|
|
189
|
+
} else if ('Cordova' in window || 'cordova' in window) {
|
|
190
|
+
this.#adapter = this.#loadAdapter('cordova')
|
|
191
|
+
} else {
|
|
192
|
+
this.#adapter = this.#loadAdapter('default')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (typeof initOptions.useNonce !== 'undefined') {
|
|
196
|
+
this.#useNonce = initOptions.useNonce
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (typeof initOptions.checkLoginIframe !== 'undefined') {
|
|
200
|
+
this.#loginIframe.enable = initOptions.checkLoginIframe
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (initOptions.checkLoginIframeInterval) {
|
|
204
|
+
this.#loginIframe.interval = initOptions.checkLoginIframeInterval
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (initOptions.onLoad === 'login-required') {
|
|
208
|
+
this.loginRequired = true
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (initOptions.responseMode) {
|
|
212
|
+
if (initOptions.responseMode === 'query' || initOptions.responseMode === 'fragment') {
|
|
213
|
+
this.responseMode = initOptions.responseMode
|
|
214
|
+
} else {
|
|
215
|
+
throw new Error('Invalid value for responseMode')
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (initOptions.flow) {
|
|
220
|
+
switch (initOptions.flow) {
|
|
221
|
+
case 'standard':
|
|
222
|
+
this.responseType = 'code'
|
|
223
|
+
break
|
|
224
|
+
case 'implicit':
|
|
225
|
+
this.responseType = 'id_token token'
|
|
226
|
+
break
|
|
227
|
+
case 'hybrid':
|
|
228
|
+
this.responseType = 'code id_token token'
|
|
229
|
+
break
|
|
230
|
+
default:
|
|
231
|
+
throw new Error('Invalid value for flow')
|
|
232
|
+
}
|
|
233
|
+
this.flow = initOptions.flow
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (typeof initOptions.timeSkew === 'number') {
|
|
237
|
+
this.timeSkew = initOptions.timeSkew
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (initOptions.redirectUri) {
|
|
241
|
+
this.redirectUri = initOptions.redirectUri
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (initOptions.silentCheckSsoRedirectUri) {
|
|
245
|
+
this.silentCheckSsoRedirectUri = initOptions.silentCheckSsoRedirectUri
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (typeof initOptions.silentCheckSsoFallback === 'boolean') {
|
|
249
|
+
this.silentCheckSsoFallback = initOptions.silentCheckSsoFallback
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (typeof initOptions.pkceMethod !== 'undefined') {
|
|
253
|
+
if (initOptions.pkceMethod !== 'S256' && initOptions.pkceMethod !== false) {
|
|
254
|
+
throw new TypeError(`Invalid value for pkceMethod', expected 'S256' or false but got ${initOptions.pkceMethod}.`)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
this.pkceMethod = initOptions.pkceMethod
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (typeof initOptions.enableLogging === 'boolean') {
|
|
261
|
+
this.enableLogging = initOptions.enableLogging
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (initOptions.logoutMethod === 'POST') {
|
|
265
|
+
this.logoutMethod = 'POST'
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (typeof initOptions.scope === 'string') {
|
|
269
|
+
this.scope = initOptions.scope
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (typeof initOptions.messageReceiveTimeout === 'number' && initOptions.messageReceiveTimeout > 0) {
|
|
273
|
+
this.messageReceiveTimeout = initOptions.messageReceiveTimeout
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
await this.#loadConfig()
|
|
277
|
+
await this.#check3pCookiesSupported()
|
|
278
|
+
await this.#processInit(initOptions)
|
|
279
|
+
|
|
280
|
+
this.onReady?.(this.authenticated)
|
|
281
|
+
|
|
282
|
+
return this.authenticated
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* @param {"default" | "cordova" | "cordova-native"} type
|
|
287
|
+
* @returns {KeycloakAdapter}
|
|
288
|
+
*/
|
|
289
|
+
#loadAdapter (type) {
|
|
290
|
+
if (type === 'default') {
|
|
291
|
+
return this.#loadDefaultAdapter()
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (type === 'cordova') {
|
|
295
|
+
this.#loginIframe.enable = false
|
|
296
|
+
return this.#loadCordovaAdapter()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (type === 'cordova-native') {
|
|
300
|
+
this.#loginIframe.enable = false
|
|
301
|
+
return this.#loadCordovaNativeAdapter()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
throw new Error('invalid adapter type: ' + type)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @returns {KeycloakAdapter}
|
|
309
|
+
*/
|
|
310
|
+
#loadDefaultAdapter () {
|
|
311
|
+
/** @type {KeycloakAdapter['redirectUri']}{} */
|
|
312
|
+
const redirectUri = (options) => {
|
|
313
|
+
return options?.redirectUri || this.redirectUri || globalThis.location.href
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
login: async (options) => {
|
|
318
|
+
window.location.assign(await this.createLoginUrl(options))
|
|
319
|
+
return await new Promise(() => {})
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
logout: async (options) => {
|
|
323
|
+
const logoutMethod = options?.logoutMethod ?? this.logoutMethod
|
|
324
|
+
|
|
325
|
+
if (logoutMethod === 'GET') {
|
|
326
|
+
window.location.replace(this.createLogoutUrl(options))
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Create form to send POST request.
|
|
331
|
+
const form = document.createElement('form')
|
|
332
|
+
|
|
333
|
+
form.setAttribute('method', 'POST')
|
|
334
|
+
form.setAttribute('action', this.createLogoutUrl(options))
|
|
335
|
+
form.style.display = 'none'
|
|
336
|
+
|
|
337
|
+
// Add data to form as hidden input fields.
|
|
338
|
+
const data = {
|
|
339
|
+
id_token_hint: this.idToken,
|
|
340
|
+
client_id: this.clientId,
|
|
341
|
+
post_logout_redirect_uri: redirectUri(options)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
for (const [name, value] of Object.entries(data)) {
|
|
345
|
+
const input = document.createElement('input')
|
|
346
|
+
|
|
347
|
+
input.setAttribute('type', 'hidden')
|
|
348
|
+
input.setAttribute('name', name)
|
|
349
|
+
input.setAttribute('value', /** @type {string} */ (value))
|
|
350
|
+
|
|
351
|
+
form.appendChild(input)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Append form to page and submit it to perform logout and redirect.
|
|
355
|
+
document.body.appendChild(form)
|
|
356
|
+
form.submit()
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
register: async (options) => {
|
|
360
|
+
window.location.assign(await this.createRegisterUrl(options))
|
|
361
|
+
return await new Promise(() => {})
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
accountManagement: async () => {
|
|
365
|
+
const accountUrl = this.createAccountUrl()
|
|
366
|
+
if (typeof accountUrl !== 'undefined') {
|
|
367
|
+
window.location.href = accountUrl
|
|
368
|
+
} else {
|
|
369
|
+
throw new Error('Not supported by the OIDC server')
|
|
370
|
+
}
|
|
371
|
+
return await new Promise(() => {})
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
redirectUri
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* @returns {KeycloakAdapter}
|
|
380
|
+
*/
|
|
381
|
+
#loadCordovaAdapter () {
|
|
382
|
+
/**
|
|
383
|
+
* @param {string} loginUrl
|
|
384
|
+
* @param {string} target
|
|
385
|
+
* @param {string} options
|
|
386
|
+
* @returns {WindowProxy | null}
|
|
387
|
+
*/
|
|
388
|
+
const cordovaOpenWindowWrapper = (loginUrl, target, options) => {
|
|
389
|
+
if (window.cordova && window.cordova.InAppBrowser) {
|
|
390
|
+
// Use inappbrowser for IOS and Android if available
|
|
391
|
+
return window.cordova.InAppBrowser.open(loginUrl, target, options)
|
|
392
|
+
} else {
|
|
393
|
+
return window.open(loginUrl, target, options)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const shallowCloneCordovaOptions = (userOptions) => {
|
|
398
|
+
if (userOptions && userOptions.cordovaOptions) {
|
|
399
|
+
return Object.keys(userOptions.cordovaOptions).reduce((options, optionName) => {
|
|
400
|
+
options[optionName] = userOptions.cordovaOptions[optionName]
|
|
401
|
+
return options
|
|
402
|
+
}, {})
|
|
403
|
+
} else {
|
|
404
|
+
return {}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const formatCordovaOptions = (cordovaOptions) => {
|
|
409
|
+
return Object.keys(cordovaOptions).reduce((options, optionName) => {
|
|
410
|
+
options.push(optionName + '=' + cordovaOptions[optionName])
|
|
411
|
+
return options
|
|
412
|
+
}, []).join(',')
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const createCordovaOptions = (userOptions) => {
|
|
416
|
+
const cordovaOptions = shallowCloneCordovaOptions(userOptions)
|
|
417
|
+
cordovaOptions.location = 'no'
|
|
418
|
+
if (userOptions && userOptions.prompt === 'none') {
|
|
419
|
+
cordovaOptions.hidden = 'yes'
|
|
420
|
+
}
|
|
421
|
+
return formatCordovaOptions(cordovaOptions)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const getCordovaRedirectUri = () => {
|
|
425
|
+
return this.redirectUri || 'http://localhost'
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
login: async (options) => {
|
|
430
|
+
const cordovaOptions = createCordovaOptions(options)
|
|
431
|
+
const loginUrl = await this.createLoginUrl(options)
|
|
432
|
+
const ref = cordovaOpenWindowWrapper(loginUrl, '_blank', cordovaOptions)
|
|
433
|
+
let completed = false
|
|
434
|
+
let closed = false
|
|
435
|
+
|
|
436
|
+
function closeBrowser () {
|
|
437
|
+
closed = true
|
|
438
|
+
ref.close()
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
return await new Promise((resolve, reject) => {
|
|
442
|
+
ref.addEventListener('loadstart', async (event) => {
|
|
443
|
+
if (event.url.indexOf(getCordovaRedirectUri()) === 0) {
|
|
444
|
+
const callback = this.#parseCallback(event.url)
|
|
445
|
+
completed = true
|
|
446
|
+
closeBrowser()
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
await this.#processCallback(callback)
|
|
450
|
+
resolve()
|
|
451
|
+
} catch (error) {
|
|
452
|
+
reject(error)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
ref.addEventListener('loaderror', async (event) => {
|
|
458
|
+
if (!completed) {
|
|
459
|
+
if (event.url.indexOf(getCordovaRedirectUri()) === 0) {
|
|
460
|
+
const callback = this.#parseCallback(event.url)
|
|
461
|
+
completed = true
|
|
462
|
+
closeBrowser()
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
await this.#processCallback(callback)
|
|
466
|
+
resolve()
|
|
467
|
+
} catch (error) {
|
|
468
|
+
reject(error)
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
reject(new Error('Unable to process login.'))
|
|
472
|
+
closeBrowser()
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
ref.addEventListener('exit', function (event) {
|
|
478
|
+
if (!closed) {
|
|
479
|
+
reject(new Error('User closed the login window.'))
|
|
480
|
+
}
|
|
481
|
+
})
|
|
482
|
+
})
|
|
483
|
+
},
|
|
484
|
+
|
|
485
|
+
logout: async (options) => {
|
|
486
|
+
const logoutUrl = this.createLogoutUrl(options)
|
|
487
|
+
const ref = cordovaOpenWindowWrapper(logoutUrl, '_blank', 'location=no,hidden=yes,clearcache=yes')
|
|
488
|
+
let error = false
|
|
489
|
+
|
|
490
|
+
ref.addEventListener('loadstart', (event) => {
|
|
491
|
+
if (event.url.indexOf(getCordovaRedirectUri()) === 0) {
|
|
492
|
+
ref.close()
|
|
493
|
+
}
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
ref.addEventListener('loaderror', (event) => {
|
|
497
|
+
if (event.url.indexOf(getCordovaRedirectUri()) === 0) {
|
|
498
|
+
ref.close()
|
|
499
|
+
} else {
|
|
500
|
+
error = true
|
|
501
|
+
ref.close()
|
|
502
|
+
}
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
await new Promise((resolve, reject) => {
|
|
506
|
+
ref.addEventListener('exit', () => {
|
|
507
|
+
if (error) {
|
|
508
|
+
reject(new Error('User closed the login window.'))
|
|
509
|
+
} else {
|
|
510
|
+
this.clearToken()
|
|
511
|
+
resolve()
|
|
512
|
+
}
|
|
513
|
+
})
|
|
514
|
+
})
|
|
515
|
+
},
|
|
516
|
+
|
|
517
|
+
register: async (options) => {
|
|
518
|
+
const registerUrl = await this.createRegisterUrl()
|
|
519
|
+
const cordovaOptions = createCordovaOptions(options)
|
|
520
|
+
const ref = cordovaOpenWindowWrapper(registerUrl, '_blank', cordovaOptions)
|
|
521
|
+
|
|
522
|
+
/** @type {Promise<void>} */
|
|
523
|
+
const promise = new Promise((resolve, reject) => {
|
|
524
|
+
ref.addEventListener('loadstart', async (event) => {
|
|
525
|
+
if (event.url.indexOf(getCordovaRedirectUri()) === 0) {
|
|
526
|
+
ref.close()
|
|
527
|
+
const oauth = this.#parseCallback(event.url)
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
await this.#processCallback(oauth)
|
|
531
|
+
resolve()
|
|
532
|
+
} catch (error) {
|
|
533
|
+
reject(error)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
})
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
await promise
|
|
540
|
+
},
|
|
541
|
+
|
|
542
|
+
accountManagement: async () => {
|
|
543
|
+
const accountUrl = this.createAccountUrl()
|
|
544
|
+
if (typeof accountUrl !== 'undefined') {
|
|
545
|
+
const ref = cordovaOpenWindowWrapper(accountUrl, '_blank', 'location=no')
|
|
546
|
+
ref.addEventListener('loadstart', function (event) {
|
|
547
|
+
if (event.url.indexOf(getCordovaRedirectUri()) === 0) {
|
|
548
|
+
ref.close()
|
|
549
|
+
}
|
|
550
|
+
})
|
|
551
|
+
} else {
|
|
552
|
+
throw new Error('Not supported by the OIDC server')
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
|
|
556
|
+
redirectUri: () => {
|
|
557
|
+
return getCordovaRedirectUri()
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* @returns {KeycloakAdapter}
|
|
564
|
+
*/
|
|
565
|
+
#loadCordovaNativeAdapter () {
|
|
566
|
+
/* global universalLinks */
|
|
567
|
+
return {
|
|
568
|
+
login: async (options) => {
|
|
569
|
+
const loginUrl = await this.createLoginUrl(options)
|
|
570
|
+
|
|
571
|
+
await new Promise((resolve, reject) => {
|
|
572
|
+
universalLinks.subscribe('keycloak', async (event) => {
|
|
573
|
+
universalLinks.unsubscribe('keycloak')
|
|
574
|
+
window.cordova.plugins.browsertab.close()
|
|
575
|
+
const oauth = this.#parseCallback(event.url)
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
await this.#processCallback(oauth)
|
|
579
|
+
resolve()
|
|
580
|
+
} catch (error) {
|
|
581
|
+
reject(error)
|
|
582
|
+
}
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
window.cordova.plugins.browsertab.openUrl(loginUrl)
|
|
586
|
+
})
|
|
587
|
+
},
|
|
588
|
+
|
|
589
|
+
logout: async (options) => {
|
|
590
|
+
const logoutUrl = this.createLogoutUrl(options)
|
|
591
|
+
|
|
592
|
+
await new Promise((resolve) => {
|
|
593
|
+
universalLinks.subscribe('keycloak', () => {
|
|
594
|
+
universalLinks.unsubscribe('keycloak')
|
|
595
|
+
window.cordova.plugins.browsertab.close()
|
|
596
|
+
this.clearToken()
|
|
597
|
+
resolve()
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
window.cordova.plugins.browsertab.openUrl(logoutUrl)
|
|
601
|
+
})
|
|
602
|
+
},
|
|
603
|
+
|
|
604
|
+
register: async (options) => {
|
|
605
|
+
const registerUrl = await this.createRegisterUrl(options)
|
|
606
|
+
|
|
607
|
+
await new Promise((resolve, reject) => {
|
|
608
|
+
universalLinks.subscribe('keycloak', async (event) => {
|
|
609
|
+
universalLinks.unsubscribe('keycloak')
|
|
610
|
+
window.cordova.plugins.browsertab.close()
|
|
611
|
+
const oauth = this.#parseCallback(event.url)
|
|
612
|
+
try {
|
|
613
|
+
await this.#processCallback(oauth)
|
|
614
|
+
resolve()
|
|
615
|
+
} catch (error) {
|
|
616
|
+
reject(error)
|
|
617
|
+
}
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
window.cordova.plugins.browsertab.openUrl(registerUrl)
|
|
621
|
+
})
|
|
622
|
+
},
|
|
623
|
+
|
|
624
|
+
accountManagement: async () => {
|
|
625
|
+
const accountUrl = this.createAccountUrl()
|
|
626
|
+
if (typeof accountUrl !== 'undefined') {
|
|
627
|
+
window.cordova.plugins.browsertab.openUrl(accountUrl)
|
|
628
|
+
} else {
|
|
629
|
+
throw new Error('Not supported by the OIDC server')
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
|
|
633
|
+
redirectUri: (options) => {
|
|
634
|
+
if (options && options.redirectUri) {
|
|
635
|
+
return options.redirectUri
|
|
636
|
+
} else if (this.redirectUri) {
|
|
637
|
+
return this.redirectUri
|
|
638
|
+
} else {
|
|
639
|
+
return 'http://localhost'
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* @returns {Promise<void>}
|
|
647
|
+
*/
|
|
648
|
+
async #loadConfig () {
|
|
649
|
+
if (typeof this.#config === 'string') {
|
|
650
|
+
const jsonConfig = await fetchJsonConfig(this.#config)
|
|
651
|
+
this.authServerUrl = jsonConfig['auth-server-url']
|
|
652
|
+
this.realm = jsonConfig.realm
|
|
653
|
+
this.clientId = jsonConfig.resource
|
|
654
|
+
this.#setupEndpoints()
|
|
655
|
+
} else {
|
|
656
|
+
this.clientId = this.#config.clientId
|
|
657
|
+
|
|
658
|
+
if ('oidcProvider' in this.#config) {
|
|
659
|
+
await this.#loadOidcConfig(this.#config.oidcProvider)
|
|
660
|
+
} else {
|
|
661
|
+
this.authServerUrl = this.#config.url
|
|
662
|
+
this.realm = this.#config.realm
|
|
663
|
+
this.#setupEndpoints()
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* @returns {void}
|
|
670
|
+
*/
|
|
671
|
+
#setupEndpoints () {
|
|
672
|
+
this.endpoints = {
|
|
673
|
+
authorize: () => {
|
|
674
|
+
return this.#getRealmUrl() + '/protocol/openid-connect/auth'
|
|
675
|
+
},
|
|
676
|
+
token: () => {
|
|
677
|
+
return this.#getRealmUrl() + '/protocol/openid-connect/token'
|
|
678
|
+
},
|
|
679
|
+
logout: () => {
|
|
680
|
+
return this.#getRealmUrl() + '/protocol/openid-connect/logout'
|
|
681
|
+
},
|
|
682
|
+
checkSessionIframe: () => {
|
|
683
|
+
return this.#getRealmUrl() + '/protocol/openid-connect/login-status-iframe.html'
|
|
684
|
+
},
|
|
685
|
+
thirdPartyCookiesIframe: () => {
|
|
686
|
+
return this.#getRealmUrl() + '/protocol/openid-connect/3p-cookies/step1.html'
|
|
687
|
+
},
|
|
688
|
+
register: () => {
|
|
689
|
+
return this.#getRealmUrl() + '/protocol/openid-connect/registrations'
|
|
690
|
+
},
|
|
691
|
+
userinfo: () => {
|
|
692
|
+
return this.#getRealmUrl() + '/protocol/openid-connect/userinfo'
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* @param {string | OpenIdProviderMetadata} oidcProvider
|
|
699
|
+
* @returns {Promise<void>}
|
|
700
|
+
*/
|
|
701
|
+
async #loadOidcConfig (oidcProvider) {
|
|
702
|
+
if (typeof oidcProvider === 'string') {
|
|
703
|
+
const url = `${stripTrailingSlash(oidcProvider)}/.well-known/openid-configuration`
|
|
704
|
+
const openIdConfig = await fetchOpenIdConfig(url)
|
|
705
|
+
this.#setupOidcEndpoints(openIdConfig)
|
|
706
|
+
} else {
|
|
707
|
+
this.#setupOidcEndpoints(oidcProvider)
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* @param {OpenIdProviderMetadata} config
|
|
713
|
+
* @returns {void}
|
|
714
|
+
*/
|
|
715
|
+
#setupOidcEndpoints (config) {
|
|
716
|
+
this.endpoints = {
|
|
717
|
+
authorize () {
|
|
718
|
+
return config.authorization_endpoint
|
|
719
|
+
},
|
|
720
|
+
token () {
|
|
721
|
+
return config.token_endpoint
|
|
722
|
+
},
|
|
723
|
+
logout () {
|
|
724
|
+
if (!config.end_session_endpoint) {
|
|
725
|
+
throw new Error('Not supported by the OIDC server')
|
|
726
|
+
}
|
|
727
|
+
return config.end_session_endpoint
|
|
728
|
+
},
|
|
729
|
+
checkSessionIframe () {
|
|
730
|
+
if (!config.check_session_iframe) {
|
|
731
|
+
throw new Error('Not supported by the OIDC server')
|
|
732
|
+
}
|
|
733
|
+
return config.check_session_iframe
|
|
734
|
+
},
|
|
735
|
+
register () {
|
|
736
|
+
throw new Error('Redirection to "Register user" page not supported in standard OIDC mode')
|
|
737
|
+
},
|
|
738
|
+
userinfo () {
|
|
739
|
+
if (!config.userinfo_endpoint) {
|
|
740
|
+
throw new Error('Not supported by the OIDC server')
|
|
741
|
+
}
|
|
742
|
+
return config.userinfo_endpoint
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* @returns {Promise<void>}
|
|
749
|
+
*/
|
|
750
|
+
async #check3pCookiesSupported () {
|
|
751
|
+
if ((!this.#loginIframe.enable && !this.silentCheckSsoRedirectUri) || typeof this.endpoints.thirdPartyCookiesIframe !== 'function') {
|
|
752
|
+
return
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const iframe = document.createElement('iframe')
|
|
756
|
+
iframe.setAttribute('src', this.endpoints.thirdPartyCookiesIframe())
|
|
757
|
+
iframe.setAttribute('sandbox', 'allow-storage-access-by-user-activation allow-scripts allow-same-origin')
|
|
758
|
+
iframe.setAttribute('title', 'keycloak-3p-check-iframe')
|
|
759
|
+
iframe.style.display = 'none'
|
|
760
|
+
document.body.appendChild(iframe)
|
|
761
|
+
|
|
762
|
+
/** @type {Promise<void>} */
|
|
763
|
+
const promise = new Promise((resolve) => {
|
|
764
|
+
/**
|
|
765
|
+
* @param {MessageEvent} event
|
|
766
|
+
*/
|
|
767
|
+
const messageCallback = (event) => {
|
|
768
|
+
if (iframe.contentWindow !== event.source) {
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (event.data !== 'supported' && event.data !== 'unsupported') {
|
|
773
|
+
return
|
|
774
|
+
} else if (event.data === 'unsupported') {
|
|
775
|
+
this.#logWarn(
|
|
776
|
+
'[KEYCLOAK] Your browser is blocking access to 3rd-party cookies, this means:\n\n' +
|
|
777
|
+
' - It is not possible to retrieve tokens without redirecting to the Keycloak server (a.k.a. no support for silent authentication).\n' +
|
|
778
|
+
' - It is not possible to automatically detect changes to the session status (such as the user logging out in another tab).\n\n' +
|
|
779
|
+
'For more information see: https://www.keycloak.org/securing-apps/javascript-adapter#_modern_browsers'
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
this.#loginIframe.enable = false
|
|
783
|
+
if (this.silentCheckSsoFallback) {
|
|
784
|
+
this.silentCheckSsoRedirectUri = undefined
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
document.body.removeChild(iframe)
|
|
789
|
+
window.removeEventListener('message', messageCallback)
|
|
790
|
+
resolve()
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
window.addEventListener('message', messageCallback, false)
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
return await applyTimeoutToPromise(promise, this.messageReceiveTimeout, 'Timeout when waiting for 3rd party check iframe message.')
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* @param {KeycloakInitOptions} initOptions
|
|
801
|
+
* @returns {Promise<void>}
|
|
802
|
+
*/
|
|
803
|
+
async #processInit (initOptions) {
|
|
804
|
+
const callback = this.#parseCallback(window.location.href)
|
|
805
|
+
|
|
806
|
+
if (callback?.newUrl) {
|
|
807
|
+
window.history.replaceState(window.history.state, '', callback.newUrl)
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (callback && callback.valid) {
|
|
811
|
+
await this.#setupCheckLoginIframe()
|
|
812
|
+
await this.#processCallback(callback)
|
|
813
|
+
return
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** @param {boolean} prompt */
|
|
817
|
+
const doLogin = async (prompt) => {
|
|
818
|
+
/** @type {KeycloakLoginOptions} */
|
|
819
|
+
const options = {}
|
|
820
|
+
|
|
821
|
+
if (!prompt) {
|
|
822
|
+
options.prompt = 'none'
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (initOptions.locale) {
|
|
826
|
+
options.locale = initOptions.locale
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
await this.login(options)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const onLoad = async () => {
|
|
833
|
+
switch (initOptions.onLoad) {
|
|
834
|
+
case 'check-sso':
|
|
835
|
+
if (this.#loginIframe.enable) {
|
|
836
|
+
await this.#setupCheckLoginIframe()
|
|
837
|
+
const unchanged = await this.#checkLoginIframe()
|
|
838
|
+
|
|
839
|
+
if (!unchanged) {
|
|
840
|
+
this.silentCheckSsoRedirectUri ? await this.#checkSsoSilently() : await doLogin(false)
|
|
841
|
+
}
|
|
842
|
+
} else {
|
|
843
|
+
this.silentCheckSsoRedirectUri ? await this.#checkSsoSilently() : await doLogin(false)
|
|
844
|
+
}
|
|
845
|
+
break
|
|
846
|
+
case 'login-required':
|
|
847
|
+
await doLogin(true)
|
|
848
|
+
break
|
|
849
|
+
default:
|
|
850
|
+
throw new Error('Invalid value for onLoad')
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (initOptions.token && initOptions.refreshToken) {
|
|
855
|
+
this.#setToken(initOptions.token, initOptions.refreshToken, initOptions.idToken)
|
|
856
|
+
|
|
857
|
+
if (this.#loginIframe.enable) {
|
|
858
|
+
await this.#setupCheckLoginIframe()
|
|
859
|
+
const unchanged = await this.#checkLoginIframe()
|
|
860
|
+
|
|
861
|
+
if (unchanged) {
|
|
862
|
+
this.onAuthSuccess?.()
|
|
863
|
+
this.#scheduleCheckIframe()
|
|
864
|
+
}
|
|
865
|
+
} else {
|
|
866
|
+
try {
|
|
867
|
+
await this.updateToken(-1)
|
|
868
|
+
this.onAuthSuccess?.()
|
|
869
|
+
} catch (error) {
|
|
870
|
+
this.onAuthError?.()
|
|
871
|
+
if (initOptions.onLoad) {
|
|
872
|
+
await onLoad()
|
|
873
|
+
} else {
|
|
874
|
+
throw error
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
} else if (initOptions.onLoad) {
|
|
879
|
+
await onLoad()
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* @returns {Promise<void>}
|
|
885
|
+
*/
|
|
886
|
+
async #setupCheckLoginIframe () {
|
|
887
|
+
if (!this.#loginIframe.enable || this.#loginIframe.iframe) {
|
|
888
|
+
return
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const iframe = document.createElement('iframe')
|
|
892
|
+
this.#loginIframe.iframe = iframe
|
|
893
|
+
iframe.setAttribute('src', this.endpoints.checkSessionIframe())
|
|
894
|
+
iframe.setAttribute('sandbox', 'allow-storage-access-by-user-activation allow-scripts allow-same-origin')
|
|
895
|
+
iframe.setAttribute('title', 'keycloak-session-iframe')
|
|
896
|
+
iframe.style.display = 'none'
|
|
897
|
+
document.body.appendChild(iframe)
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* @param {MessageEvent} event
|
|
901
|
+
*/
|
|
902
|
+
const messageCallback = (event) => {
|
|
903
|
+
if (event.origin !== this.#loginIframe.iframeOrigin || this.#loginIframe.iframe?.contentWindow !== event.source) {
|
|
904
|
+
return
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (!(event.data === 'unchanged' || event.data === 'changed' || event.data === 'error')) {
|
|
908
|
+
return
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (event.data !== 'unchanged') {
|
|
912
|
+
this.clearToken()
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const callbacks = this.#loginIframe.callbackList
|
|
916
|
+
this.#loginIframe.callbackList = []
|
|
917
|
+
|
|
918
|
+
for (const callback of callbacks.reverse()) {
|
|
919
|
+
if (event.data === 'error') {
|
|
920
|
+
callback(new Error('Error while checking login iframe'))
|
|
921
|
+
} else {
|
|
922
|
+
callback(null, event.data === 'unchanged')
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
window.addEventListener('message', messageCallback, false)
|
|
928
|
+
|
|
929
|
+
/** @type {Promise<void>} */
|
|
930
|
+
const promise = new Promise((resolve) => {
|
|
931
|
+
iframe.addEventListener('load', () => {
|
|
932
|
+
const authUrl = this.endpoints.authorize()
|
|
933
|
+
if (authUrl.startsWith('/')) {
|
|
934
|
+
this.#loginIframe.iframeOrigin = globalThis.location.origin
|
|
935
|
+
} else {
|
|
936
|
+
this.#loginIframe.iframeOrigin = new URL(authUrl).origin
|
|
937
|
+
}
|
|
938
|
+
resolve()
|
|
939
|
+
})
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
await promise
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* @returns {Promise<boolean | undefined>}
|
|
947
|
+
*/
|
|
948
|
+
async #checkLoginIframe () {
|
|
949
|
+
if (!this.#loginIframe.iframe || !this.#loginIframe.iframeOrigin) {
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const message = `${this.clientId} ${(this.sessionId ? this.sessionId : '')}`
|
|
954
|
+
const origin = this.#loginIframe.iframeOrigin
|
|
955
|
+
|
|
956
|
+
/** @type {Promise<boolean>} */
|
|
957
|
+
const promise = new Promise((resolve, reject) => {
|
|
958
|
+
/** @type {(error: Error | null, value?: boolean) => void} */
|
|
959
|
+
const callback = (error, result) => error ? reject(error) : resolve(/** @type {boolean} */ (result))
|
|
960
|
+
|
|
961
|
+
this.#loginIframe.callbackList.push(callback)
|
|
962
|
+
|
|
963
|
+
if (this.#loginIframe.callbackList.length === 1) {
|
|
964
|
+
this.#loginIframe.iframe?.contentWindow?.postMessage(message, origin)
|
|
965
|
+
}
|
|
966
|
+
})
|
|
967
|
+
|
|
968
|
+
return await promise
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* @returns {Promise<void>}
|
|
973
|
+
*/
|
|
974
|
+
async #checkSsoSilently () {
|
|
975
|
+
const iframe = document.createElement('iframe')
|
|
976
|
+
const src = await this.createLoginUrl({ prompt: 'none', redirectUri: this.silentCheckSsoRedirectUri })
|
|
977
|
+
iframe.setAttribute('src', src)
|
|
978
|
+
iframe.setAttribute('sandbox', 'allow-storage-access-by-user-activation allow-scripts allow-same-origin')
|
|
979
|
+
iframe.setAttribute('title', 'keycloak-silent-check-sso')
|
|
980
|
+
iframe.style.display = 'none'
|
|
981
|
+
document.body.appendChild(iframe)
|
|
982
|
+
|
|
983
|
+
return await new Promise((resolve, reject) => {
|
|
984
|
+
/**
|
|
985
|
+
* @param {MessageEvent} event
|
|
986
|
+
*/
|
|
987
|
+
const messageCallback = async (event) => {
|
|
988
|
+
if (event.origin !== window.location.origin || iframe.contentWindow !== event.source) {
|
|
989
|
+
return
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const oauth = this.#parseCallback(event.data)
|
|
993
|
+
|
|
994
|
+
try {
|
|
995
|
+
await this.#processCallback(oauth)
|
|
996
|
+
resolve()
|
|
997
|
+
} catch (error) {
|
|
998
|
+
reject(error)
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
document.body.removeChild(iframe)
|
|
1002
|
+
window.removeEventListener('message', messageCallback)
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
window.addEventListener('message', messageCallback)
|
|
1006
|
+
})
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* @param {string} url
|
|
1011
|
+
*/
|
|
1012
|
+
#parseCallback (url) {
|
|
1013
|
+
const oauth = this.#parseCallbackUrl(url)
|
|
1014
|
+
if (!oauth) {
|
|
1015
|
+
return
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const oauthState = this.#callbackStorage.get(oauth.state)
|
|
1019
|
+
|
|
1020
|
+
if (oauthState) {
|
|
1021
|
+
oauth.valid = true
|
|
1022
|
+
oauth.redirectUri = oauthState.redirectUri
|
|
1023
|
+
oauth.storedNonce = oauthState.nonce
|
|
1024
|
+
oauth.prompt = oauthState.prompt
|
|
1025
|
+
oauth.pkceCodeVerifier = oauthState.pkceCodeVerifier
|
|
1026
|
+
oauth.loginOptions = oauthState.loginOptions
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
return oauth
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* @param {string} urlString
|
|
1034
|
+
*/
|
|
1035
|
+
#parseCallbackUrl (urlString) {
|
|
1036
|
+
let supportedParams = []
|
|
1037
|
+
switch (this.flow) {
|
|
1038
|
+
case 'standard':
|
|
1039
|
+
supportedParams = ['code', 'state', 'session_state', 'kc_action_status', 'kc_action', 'iss']
|
|
1040
|
+
break
|
|
1041
|
+
case 'implicit':
|
|
1042
|
+
supportedParams = ['access_token', 'token_type', 'id_token', 'state', 'session_state', 'expires_in', 'kc_action_status', 'kc_action', 'iss']
|
|
1043
|
+
break
|
|
1044
|
+
case 'hybrid':
|
|
1045
|
+
supportedParams = ['access_token', 'token_type', 'id_token', 'code', 'state', 'session_state', 'expires_in', 'kc_action_status', 'kc_action', 'iss']
|
|
1046
|
+
break
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
supportedParams.push('error')
|
|
1050
|
+
supportedParams.push('error_description')
|
|
1051
|
+
supportedParams.push('error_uri')
|
|
1052
|
+
|
|
1053
|
+
const url = new URL(urlString)
|
|
1054
|
+
let newUrl = ''
|
|
1055
|
+
let parsed
|
|
1056
|
+
|
|
1057
|
+
if (this.responseMode === 'query' && url.searchParams.size > 0) {
|
|
1058
|
+
parsed = this.#parseCallbackParams(url.search, supportedParams)
|
|
1059
|
+
url.search = parsed.paramsString
|
|
1060
|
+
newUrl = url.toString()
|
|
1061
|
+
} else if (this.responseMode === 'fragment' && url.hash.length > 0) {
|
|
1062
|
+
parsed = this.#parseCallbackParams(url.hash.substring(1), supportedParams)
|
|
1063
|
+
url.hash = parsed.paramsString
|
|
1064
|
+
newUrl = url.toString()
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (parsed?.oauthParams) {
|
|
1068
|
+
if (this.flow === 'standard' || this.flow === 'hybrid') {
|
|
1069
|
+
if ((parsed.oauthParams.code || parsed.oauthParams.error) && parsed.oauthParams.state) {
|
|
1070
|
+
parsed.oauthParams.newUrl = newUrl
|
|
1071
|
+
return parsed.oauthParams
|
|
1072
|
+
}
|
|
1073
|
+
} else if (this.flow === 'implicit') {
|
|
1074
|
+
if ((parsed.oauthParams.access_token || parsed.oauthParams.error) && parsed.oauthParams.state) {
|
|
1075
|
+
parsed.oauthParams.newUrl = newUrl
|
|
1076
|
+
return parsed.oauthParams
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* @typedef {Object} ParsedCallbackParams
|
|
1084
|
+
* @property {string} paramsString
|
|
1085
|
+
* @property {Record<string, string | undefined>} oauthParams
|
|
1086
|
+
*/
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* @param {string} paramsString
|
|
1090
|
+
* @param {string[]} supportedParams
|
|
1091
|
+
* @returns {ParsedCallbackParams}
|
|
1092
|
+
*/
|
|
1093
|
+
#parseCallbackParams (paramsString, supportedParams) {
|
|
1094
|
+
const params = paramsString.split('&')
|
|
1095
|
+
/** @type {Record<string, string>} */
|
|
1096
|
+
const oauthParams = {}
|
|
1097
|
+
let result = ''
|
|
1098
|
+
|
|
1099
|
+
for (const param of params.reverse()) {
|
|
1100
|
+
const entry = new URLSearchParams(param).entries().next().value
|
|
1101
|
+
|
|
1102
|
+
if (!entry) {
|
|
1103
|
+
result = '&' + result
|
|
1104
|
+
continue
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const [key, value] = entry
|
|
1108
|
+
|
|
1109
|
+
if (supportedParams.includes(key) && !(key in oauthParams)) {
|
|
1110
|
+
oauthParams[key] = value
|
|
1111
|
+
} else {
|
|
1112
|
+
result = result.length === 0 ? param : param + '&' + result
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
return {
|
|
1117
|
+
paramsString: result,
|
|
1118
|
+
oauthParams
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
async #processCallback (oauth) {
|
|
1123
|
+
const { code, error, prompt } = oauth
|
|
1124
|
+
let timeLocal = new Date().getTime()
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* @param {string} accessToken
|
|
1128
|
+
* @param {string=} refreshToken
|
|
1129
|
+
* @param {string=} idToken
|
|
1130
|
+
*/
|
|
1131
|
+
const authSuccess = (accessToken, refreshToken, idToken) => {
|
|
1132
|
+
timeLocal = (timeLocal + new Date().getTime()) / 2
|
|
1133
|
+
|
|
1134
|
+
this.#setToken(accessToken, refreshToken, idToken, timeLocal)
|
|
1135
|
+
|
|
1136
|
+
if (this.#useNonce && (this.idTokenParsed && this.idTokenParsed.nonce !== oauth.storedNonce)) {
|
|
1137
|
+
this.#logInfo('[KEYCLOAK] Invalid nonce, clearing token')
|
|
1138
|
+
this.clearToken()
|
|
1139
|
+
throw new Error('Invalid nonce.')
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
if (oauth.kc_action_status) {
|
|
1144
|
+
this.onActionUpdate && this.onActionUpdate(oauth.kc_action_status, oauth.kc_action)
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
if (error) {
|
|
1148
|
+
if (prompt !== 'none') {
|
|
1149
|
+
if (oauth.error_description && oauth.error_description === 'authentication_expired') {
|
|
1150
|
+
await this.login(oauth.loginOptions)
|
|
1151
|
+
} else {
|
|
1152
|
+
const errorData = { error, error_description: oauth.error_description }
|
|
1153
|
+
this.onAuthError?.(errorData)
|
|
1154
|
+
throw errorData
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
return
|
|
1158
|
+
} else if ((this.flow !== 'standard') && (oauth.access_token || oauth.id_token)) {
|
|
1159
|
+
authSuccess(oauth.access_token, undefined, oauth.id_token)
|
|
1160
|
+
this.onAuthSuccess?.()
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
if ((this.flow !== 'implicit') && code) {
|
|
1164
|
+
try {
|
|
1165
|
+
const response = await fetchAccessToken(this.endpoints.token(), code, /** @type {string} */ (this.clientId), oauth.redirectUri, oauth.pkceCodeVerifier)
|
|
1166
|
+
authSuccess(response.access_token, response.refresh_token, response.id_token)
|
|
1167
|
+
|
|
1168
|
+
if (this.flow === 'standard') {
|
|
1169
|
+
this.onAuthSuccess?.()
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
this.#scheduleCheckIframe()
|
|
1173
|
+
} catch (error) {
|
|
1174
|
+
this.onAuthError?.()
|
|
1175
|
+
throw error
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
async #scheduleCheckIframe () {
|
|
1181
|
+
if (this.#loginIframe.enable && this.token) {
|
|
1182
|
+
await waitForTimeout(this.#loginIframe.interval * 1000)
|
|
1183
|
+
const unchanged = await this.#checkLoginIframe()
|
|
1184
|
+
|
|
1185
|
+
if (unchanged) {
|
|
1186
|
+
await this.#scheduleCheckIframe()
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* @param {KeycloakLoginOptions} [options]
|
|
1193
|
+
* @returns {Promise<void>}
|
|
1194
|
+
*/
|
|
1195
|
+
login = (options) => {
|
|
1196
|
+
return this.#adapter.login(options)
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* @param {KeycloakLoginOptions} [options]
|
|
1201
|
+
* @returns {Promise<string>}
|
|
1202
|
+
*/
|
|
1203
|
+
createLoginUrl = async (options) => {
|
|
1204
|
+
const state = createUUID()
|
|
1205
|
+
const nonce = createUUID()
|
|
1206
|
+
const redirectUri = this.#adapter.redirectUri(options)
|
|
1207
|
+
/** @type {CallbackState} */
|
|
1208
|
+
const callbackState = {
|
|
1209
|
+
state,
|
|
1210
|
+
nonce,
|
|
1211
|
+
redirectUri,
|
|
1212
|
+
loginOptions: options
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (options?.prompt) {
|
|
1216
|
+
callbackState.prompt = options.prompt
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const url = options?.action === 'register'
|
|
1220
|
+
? this.endpoints.register()
|
|
1221
|
+
: this.endpoints.authorize()
|
|
1222
|
+
|
|
1223
|
+
let scope = options?.scope || this.scope
|
|
1224
|
+
const scopeValues = scope ? scope.split(' ') : []
|
|
1225
|
+
|
|
1226
|
+
// Ensure the 'openid' scope is always included.
|
|
1227
|
+
if (!scopeValues.includes('openid')) {
|
|
1228
|
+
scopeValues.unshift('openid')
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
scope = scopeValues.join(' ')
|
|
1232
|
+
|
|
1233
|
+
const params = new URLSearchParams([
|
|
1234
|
+
['client_id', /** @type {string} */ (this.clientId)],
|
|
1235
|
+
['redirect_uri', redirectUri],
|
|
1236
|
+
['state', state],
|
|
1237
|
+
['response_mode', this.responseMode],
|
|
1238
|
+
['response_type', this.responseType],
|
|
1239
|
+
['scope', scope]
|
|
1240
|
+
])
|
|
1241
|
+
|
|
1242
|
+
if (this.#useNonce) {
|
|
1243
|
+
params.append('nonce', nonce)
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (options?.prompt) {
|
|
1247
|
+
params.append('prompt', options.prompt)
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (typeof options?.maxAge === 'number') {
|
|
1251
|
+
params.append('max_age', options.maxAge.toString())
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
if (options?.loginHint) {
|
|
1255
|
+
params.append('login_hint', options.loginHint)
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
if (options?.idpHint) {
|
|
1259
|
+
params.append('kc_idp_hint', options.idpHint)
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
if (options?.action && options.action !== 'register') {
|
|
1263
|
+
params.append('kc_action', options.action)
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
if (options?.locale) {
|
|
1267
|
+
params.append('ui_locales', options.locale)
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (options?.acr) {
|
|
1271
|
+
params.append('claims', buildClaimsParameter(options.acr))
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
if (options?.acrValues) {
|
|
1275
|
+
params.append('acr_values', options.acrValues)
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
if (this.pkceMethod) {
|
|
1279
|
+
try {
|
|
1280
|
+
const codeVerifier = generateCodeVerifier(96)
|
|
1281
|
+
const pkceChallenge = await generatePkceChallenge(this.pkceMethod, codeVerifier)
|
|
1282
|
+
|
|
1283
|
+
callbackState.pkceCodeVerifier = codeVerifier
|
|
1284
|
+
|
|
1285
|
+
params.append('code_challenge', pkceChallenge)
|
|
1286
|
+
params.append('code_challenge_method', this.pkceMethod)
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
throw new Error('Failed to generate PKCE challenge.', { cause: error })
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
this.#callbackStorage.add(callbackState)
|
|
1293
|
+
|
|
1294
|
+
return `${url}?${params.toString()}`
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* @param {KeycloakLogoutOptions} [options]
|
|
1299
|
+
* @returns {Promise<void>}
|
|
1300
|
+
*/
|
|
1301
|
+
logout = (options) => {
|
|
1302
|
+
return this.#adapter.logout(options)
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* @param {KeycloakLogoutOptions} [options]
|
|
1307
|
+
* @returns {string}
|
|
1308
|
+
*/
|
|
1309
|
+
createLogoutUrl = (options) => {
|
|
1310
|
+
const logoutMethod = options?.logoutMethod ?? this.logoutMethod
|
|
1311
|
+
const url = this.endpoints.logout()
|
|
1312
|
+
|
|
1313
|
+
if (logoutMethod === 'POST') {
|
|
1314
|
+
return url
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const params = new URLSearchParams([
|
|
1318
|
+
['client_id', /** @type {string} */ (this.clientId)],
|
|
1319
|
+
['post_logout_redirect_uri', this.#adapter.redirectUri(options)]
|
|
1320
|
+
])
|
|
1321
|
+
|
|
1322
|
+
if (this.idToken) {
|
|
1323
|
+
params.append('id_token_hint', this.idToken)
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
return `${url}?${params.toString()}`
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* @param {KeycloakRegisterOptions} [options]
|
|
1331
|
+
* @returns {Promise<void>}
|
|
1332
|
+
*/
|
|
1333
|
+
register = (options) => {
|
|
1334
|
+
return this.#adapter.register(options)
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* @param {KeycloakRegisterOptions} [options]
|
|
1339
|
+
* @returns {Promise<string>}
|
|
1340
|
+
*/
|
|
1341
|
+
createRegisterUrl = (options) => {
|
|
1342
|
+
return this.createLoginUrl({ ...options, action: 'register' })
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* @param {KeycloakAccountOptions} [options]
|
|
1347
|
+
* @returns {string}
|
|
1348
|
+
*/
|
|
1349
|
+
createAccountUrl = (options) => {
|
|
1350
|
+
const url = this.#getRealmUrl()
|
|
1351
|
+
|
|
1352
|
+
if (!url) {
|
|
1353
|
+
throw new Error('Unable to create account URL, make sure the adapter is not configured using a generic OIDC provider.')
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const params = new URLSearchParams([
|
|
1357
|
+
['referrer', /** @type {string} */ (this.clientId)],
|
|
1358
|
+
['referrer_uri', this.#adapter.redirectUri(options)]
|
|
1359
|
+
])
|
|
1360
|
+
|
|
1361
|
+
return `${url}/account?${params.toString()}`
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* @returns {Promise<void>}
|
|
1366
|
+
*/
|
|
1367
|
+
accountManagement = () => {
|
|
1368
|
+
return this.#adapter.accountManagement()
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* @param {string} role
|
|
1373
|
+
* @returns {boolean}
|
|
1374
|
+
*/
|
|
1375
|
+
hasRealmRole = (role) => {
|
|
1376
|
+
const access = this.realmAccess
|
|
1377
|
+
return !!access && access.roles.indexOf(role) >= 0
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* @param {string} role
|
|
1382
|
+
* @param {string} [resource]
|
|
1383
|
+
* @returns {boolean}
|
|
1384
|
+
*/
|
|
1385
|
+
hasResourceRole = (role, resource) => {
|
|
1386
|
+
if (!this.resourceAccess) {
|
|
1387
|
+
return false
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
const access = this.resourceAccess[resource || /** @type {string} */ (this.clientId)]
|
|
1391
|
+
return !!access && access.roles.indexOf(role) >= 0
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* @returns {Promise<KeycloakProfile>}
|
|
1396
|
+
*/
|
|
1397
|
+
loadUserProfile = async () => {
|
|
1398
|
+
const realmUrl = this.#getRealmUrl()
|
|
1399
|
+
|
|
1400
|
+
if (!realmUrl) {
|
|
1401
|
+
throw new Error('Unable to load user profile, make sure the adapter is not configured using a generic OIDC provider.')
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const url = `${realmUrl}/account`
|
|
1405
|
+
/** @type {KeycloakProfile} */
|
|
1406
|
+
const profile = await fetchJSON(url, {
|
|
1407
|
+
headers: [buildAuthorizationHeader(this.token)]
|
|
1408
|
+
})
|
|
1409
|
+
|
|
1410
|
+
return (this.profile = profile)
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* @returns {Promise<{}>}
|
|
1415
|
+
*/
|
|
1416
|
+
loadUserInfo = async () => {
|
|
1417
|
+
const url = this.endpoints.userinfo()
|
|
1418
|
+
/** @type {{}} */
|
|
1419
|
+
const userInfo = await fetchJSON(url, {
|
|
1420
|
+
headers: [buildAuthorizationHeader(this.token)]
|
|
1421
|
+
})
|
|
1422
|
+
|
|
1423
|
+
return (this.userInfo = userInfo)
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* @param {number} [minValidity]
|
|
1428
|
+
* @returns {boolean}
|
|
1429
|
+
*/
|
|
1430
|
+
isTokenExpired = (minValidity) => {
|
|
1431
|
+
if (!this.tokenParsed || (!this.refreshToken && this.flow !== 'implicit')) {
|
|
1432
|
+
throw new Error('Not authenticated')
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
if (this.timeSkew == null) {
|
|
1436
|
+
this.#logInfo('[KEYCLOAK] Unable to determine if token is expired as timeskew is not set')
|
|
1437
|
+
return true
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
if (typeof this.tokenParsed.exp !== 'number') {
|
|
1441
|
+
return false
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
let expiresIn = this.tokenParsed.exp - Math.ceil(new Date().getTime() / 1000) + this.timeSkew
|
|
1445
|
+
if (minValidity) {
|
|
1446
|
+
if (isNaN(minValidity)) {
|
|
1447
|
+
throw new Error('Invalid minValidity')
|
|
1448
|
+
}
|
|
1449
|
+
expiresIn -= minValidity
|
|
1450
|
+
}
|
|
1451
|
+
return expiresIn < 0
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* @param {number} minValidity
|
|
1456
|
+
* @returns {Promise<boolean>}
|
|
1457
|
+
*/
|
|
1458
|
+
updateToken = async (minValidity) => {
|
|
1459
|
+
if (!this.refreshToken) {
|
|
1460
|
+
throw new Error('Unable to update token, no refresh token available.')
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
minValidity = minValidity || 5
|
|
1464
|
+
|
|
1465
|
+
if (this.#loginIframe.enable) {
|
|
1466
|
+
await this.#checkLoginIframe()
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
let refreshToken = false
|
|
1470
|
+
|
|
1471
|
+
if (minValidity === -1) {
|
|
1472
|
+
refreshToken = true
|
|
1473
|
+
this.#logInfo('[KEYCLOAK] Refreshing token: forced refresh')
|
|
1474
|
+
} else if (!this.tokenParsed || this.isTokenExpired(minValidity)) {
|
|
1475
|
+
refreshToken = true
|
|
1476
|
+
this.#logInfo('[KEYCLOAK] Refreshing token: token expired')
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
if (!refreshToken) {
|
|
1480
|
+
return false
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/** @type {PromiseWithResolvers<boolean>} */
|
|
1484
|
+
const { promise, resolve, reject } = Promise.withResolvers()
|
|
1485
|
+
|
|
1486
|
+
this.#refreshQueue.push({ resolve, reject })
|
|
1487
|
+
|
|
1488
|
+
if (this.#refreshQueue.length === 1) {
|
|
1489
|
+
const url = this.endpoints.token()
|
|
1490
|
+
let timeLocal = new Date().getTime()
|
|
1491
|
+
|
|
1492
|
+
try {
|
|
1493
|
+
const response = await fetchRefreshToken(url, this.refreshToken, /** @type {string} */ (this.clientId))
|
|
1494
|
+
this.#logInfo('[KEYCLOAK] Token refreshed')
|
|
1495
|
+
|
|
1496
|
+
timeLocal = (timeLocal + new Date().getTime()) / 2
|
|
1497
|
+
|
|
1498
|
+
this.#setToken(response.access_token, response.refresh_token, response.id_token, timeLocal)
|
|
1499
|
+
|
|
1500
|
+
this.onAuthRefreshSuccess?.()
|
|
1501
|
+
for (let p = this.#refreshQueue.pop(); p != null; p = this.#refreshQueue.pop()) {
|
|
1502
|
+
p.resolve(true)
|
|
1503
|
+
}
|
|
1504
|
+
} catch (error) {
|
|
1505
|
+
this.#logWarn('[KEYCLOAK] Failed to refresh token')
|
|
1506
|
+
|
|
1507
|
+
if (error instanceof NetworkError && error.response.status === 400) {
|
|
1508
|
+
this.clearToken()
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
this.onAuthRefreshError?.()
|
|
1512
|
+
for (let p = this.#refreshQueue.pop(); p != null; p = this.#refreshQueue.pop()) {
|
|
1513
|
+
p.reject(error)
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
return await promise
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
clearToken = () => {
|
|
1522
|
+
if (this.token) {
|
|
1523
|
+
this.#setToken()
|
|
1524
|
+
this.onAuthLogout?.()
|
|
1525
|
+
if (this.loginRequired) {
|
|
1526
|
+
this.login()
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* @param {string} [token]
|
|
1533
|
+
* @param {string} [refreshToken]
|
|
1534
|
+
* @param {string} [idToken]
|
|
1535
|
+
* @param {number} [timeLocal]
|
|
1536
|
+
*/
|
|
1537
|
+
#setToken (token, refreshToken, idToken, timeLocal) {
|
|
1538
|
+
if (this.tokenTimeoutHandle) {
|
|
1539
|
+
clearTimeout(this.tokenTimeoutHandle)
|
|
1540
|
+
this.tokenTimeoutHandle = undefined
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
if (refreshToken) {
|
|
1544
|
+
this.refreshToken = refreshToken
|
|
1545
|
+
this.refreshTokenParsed = decodeToken(refreshToken)
|
|
1546
|
+
} else {
|
|
1547
|
+
delete this.refreshToken
|
|
1548
|
+
delete this.refreshTokenParsed
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
if (idToken) {
|
|
1552
|
+
this.idToken = idToken
|
|
1553
|
+
this.idTokenParsed = decodeToken(idToken)
|
|
1554
|
+
} else {
|
|
1555
|
+
delete this.idToken
|
|
1556
|
+
delete this.idTokenParsed
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
if (token) {
|
|
1560
|
+
this.token = token
|
|
1561
|
+
this.tokenParsed = decodeToken(token)
|
|
1562
|
+
this.sessionId = this.tokenParsed.sid
|
|
1563
|
+
this.authenticated = true
|
|
1564
|
+
this.subject = this.tokenParsed.sub
|
|
1565
|
+
this.realmAccess = this.tokenParsed.realm_access
|
|
1566
|
+
this.resourceAccess = this.tokenParsed.resource_access
|
|
1567
|
+
|
|
1568
|
+
if (timeLocal) {
|
|
1569
|
+
this.timeSkew = Math.floor(timeLocal / 1000) - this.tokenParsed.iat
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
if (this.timeSkew !== null) {
|
|
1573
|
+
this.#logInfo('[KEYCLOAK] Estimated time difference between browser and server is ' + this.timeSkew + ' seconds')
|
|
1574
|
+
|
|
1575
|
+
if (this.onTokenExpired) {
|
|
1576
|
+
const expiresIn = (this.tokenParsed.exp - (new Date().getTime() / 1000) + this.timeSkew) * 1000
|
|
1577
|
+
this.#logInfo('[KEYCLOAK] Token expires in ' + Math.round(expiresIn / 1000) + ' s')
|
|
1578
|
+
if (expiresIn <= 0) {
|
|
1579
|
+
this.onTokenExpired()
|
|
1580
|
+
} else {
|
|
1581
|
+
this.tokenTimeoutHandle = window.setTimeout(this.onTokenExpired, Math.min(expiresIn, 2147483647))
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
} else {
|
|
1586
|
+
delete this.token
|
|
1587
|
+
delete this.tokenParsed
|
|
1588
|
+
delete this.subject
|
|
1589
|
+
delete this.realmAccess
|
|
1590
|
+
delete this.resourceAccess
|
|
1591
|
+
|
|
1592
|
+
this.authenticated = false
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* @returns {string=}
|
|
1598
|
+
*/
|
|
1599
|
+
#getRealmUrl () {
|
|
1600
|
+
if (typeof this.authServerUrl === 'undefined') {
|
|
1601
|
+
return
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
return `${stripTrailingSlash(this.authServerUrl)}/realms/${encodeURIComponent(/** @type {string} */ (this.realm))}`
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* @param {Function} fn
|
|
1609
|
+
* @returns {(message: string) => void}
|
|
1610
|
+
*/
|
|
1611
|
+
#createLogger (fn) {
|
|
1612
|
+
return (message) => {
|
|
1613
|
+
if (this.enableLogging) {
|
|
1614
|
+
fn.call(console, message)
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/**
|
|
1621
|
+
* @returns {string}
|
|
1622
|
+
*/
|
|
1623
|
+
function createUUID () {
|
|
1624
|
+
if (typeof crypto === 'undefined' || typeof crypto.randomUUID === 'undefined') {
|
|
1625
|
+
throw new Error('Web Crypto API is not available.')
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
return crypto.randomUUID()
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/**
|
|
1632
|
+
* @param {Acr} requestedAcr
|
|
1633
|
+
* @returns {string}
|
|
1634
|
+
*/
|
|
1635
|
+
function buildClaimsParameter (requestedAcr) {
|
|
1636
|
+
return JSON.stringify({
|
|
1637
|
+
id_token: {
|
|
1638
|
+
acr: requestedAcr
|
|
1639
|
+
}
|
|
1640
|
+
})
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* @param {number} len
|
|
1645
|
+
* @returns {string}
|
|
1646
|
+
*/
|
|
1647
|
+
function generateCodeVerifier (len) {
|
|
1648
|
+
return generateRandomString(len, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789')
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* @param {string} pkceMethod
|
|
1653
|
+
* @param {string} codeVerifier
|
|
1654
|
+
* @returns {Promise<string>}
|
|
1655
|
+
*/
|
|
1656
|
+
async function generatePkceChallenge (pkceMethod, codeVerifier) {
|
|
1657
|
+
if (pkceMethod !== 'S256') {
|
|
1658
|
+
throw new TypeError(`Invalid value for 'pkceMethod', expected 'S256' but got '${pkceMethod}'.`)
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// hash codeVerifier, then encode as url-safe base64 without padding
|
|
1662
|
+
const hashBytes = new Uint8Array(await sha256Digest(codeVerifier))
|
|
1663
|
+
const encodedHash = bytesToBase64(hashBytes)
|
|
1664
|
+
.replace(/\+/g, '-')
|
|
1665
|
+
.replace(/\//g, '_')
|
|
1666
|
+
.replace(/=/g, '')
|
|
1667
|
+
|
|
1668
|
+
return encodedHash
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* @param {number} len
|
|
1673
|
+
* @param {string} alphabet
|
|
1674
|
+
* @returns {string}
|
|
1675
|
+
*/
|
|
1676
|
+
function generateRandomString (len, alphabet) {
|
|
1677
|
+
const randomData = generateRandomData(len)
|
|
1678
|
+
const chars = new Array(len)
|
|
1679
|
+
for (let i = 0; i < len; i++) {
|
|
1680
|
+
chars[i] = alphabet.charCodeAt(randomData[i] % alphabet.length)
|
|
1681
|
+
}
|
|
1682
|
+
return String.fromCharCode.apply(null, chars)
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* @param {number} len
|
|
1687
|
+
* @returns {Uint8Array<ArrayBuffer>}
|
|
1688
|
+
*/
|
|
1689
|
+
function generateRandomData (len) {
|
|
1690
|
+
if (typeof crypto === 'undefined' || typeof crypto.getRandomValues === 'undefined') {
|
|
1691
|
+
throw new Error('Web Crypto API is not available.')
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
return crypto.getRandomValues(new Uint8Array(len))
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/**
|
|
1698
|
+
* Function to extend existing native Promise with timeout
|
|
1699
|
+
*
|
|
1700
|
+
* @template T
|
|
1701
|
+
* @param {Promise<T>} promise
|
|
1702
|
+
* @param {number} timeout
|
|
1703
|
+
* @param {string} errorMessage
|
|
1704
|
+
* @returns {Promise<T>}
|
|
1705
|
+
*/
|
|
1706
|
+
function applyTimeoutToPromise (promise, timeout, errorMessage) {
|
|
1707
|
+
/** @type {number} */
|
|
1708
|
+
let timeoutHandle
|
|
1709
|
+
const timeoutPromise = new Promise(function (resolve, reject) {
|
|
1710
|
+
timeoutHandle = window.setTimeout(function () {
|
|
1711
|
+
reject(new Error(errorMessage || 'Promise is not settled within timeout of ' + timeout + 'ms'))
|
|
1712
|
+
}, timeout)
|
|
1713
|
+
})
|
|
1714
|
+
|
|
1715
|
+
return Promise.race([promise, timeoutPromise]).finally(function () {
|
|
1716
|
+
clearTimeout(timeoutHandle)
|
|
1717
|
+
})
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* @returns {CallbackStorage}
|
|
1722
|
+
*/
|
|
1723
|
+
function createCallbackStorage () {
|
|
1724
|
+
try {
|
|
1725
|
+
return new LocalStorage()
|
|
1726
|
+
} catch (err) {
|
|
1727
|
+
return new CookieStorage()
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
const STORAGE_KEY_PREFIX = 'kc-callback-'
|
|
1732
|
+
|
|
1733
|
+
/**
|
|
1734
|
+
* @typedef {Object} CallbackState
|
|
1735
|
+
* @property {string} state
|
|
1736
|
+
* @property {string} nonce
|
|
1737
|
+
* @property {string} redirectUri
|
|
1738
|
+
* @property {KeycloakLoginOptions} [loginOptions]
|
|
1739
|
+
* @property {KeycloakLoginOptions['prompt']} [prompt]
|
|
1740
|
+
* @property {string} [pkceCodeVerifier]
|
|
1741
|
+
*/
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* @typedef {Object} CallbackStorage
|
|
1745
|
+
* @property {(state?: string) => CallbackState | null} get
|
|
1746
|
+
* @property {(state: CallbackState) => void} add
|
|
1747
|
+
*/
|
|
1748
|
+
|
|
1749
|
+
/**
|
|
1750
|
+
* @implements {CallbackStorage}
|
|
1751
|
+
*/
|
|
1752
|
+
class LocalStorage {
|
|
1753
|
+
constructor () {
|
|
1754
|
+
globalThis.localStorage.setItem('kc-test', 'test')
|
|
1755
|
+
globalThis.localStorage.removeItem('kc-test')
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* @param {string} [state]
|
|
1760
|
+
* @returns {CallbackState | null}
|
|
1761
|
+
*/
|
|
1762
|
+
get (state) {
|
|
1763
|
+
if (!state) {
|
|
1764
|
+
return null
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
this.#clearInvalidValues()
|
|
1768
|
+
|
|
1769
|
+
const key = STORAGE_KEY_PREFIX + state
|
|
1770
|
+
const value = globalThis.localStorage.getItem(key)
|
|
1771
|
+
|
|
1772
|
+
if (value) {
|
|
1773
|
+
globalThis.localStorage.removeItem(key)
|
|
1774
|
+
return JSON.parse(value)
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
return null
|
|
1778
|
+
};
|
|
1779
|
+
|
|
1780
|
+
/**
|
|
1781
|
+
* @param {CallbackState} state
|
|
1782
|
+
*/
|
|
1783
|
+
add (state) {
|
|
1784
|
+
this.#clearInvalidValues()
|
|
1785
|
+
|
|
1786
|
+
const key = STORAGE_KEY_PREFIX + state.state
|
|
1787
|
+
const value = JSON.stringify({
|
|
1788
|
+
...state,
|
|
1789
|
+
// Set the expiry time to 1 hour from now.
|
|
1790
|
+
expires: Date.now() + (60 * 60 * 1000)
|
|
1791
|
+
})
|
|
1792
|
+
|
|
1793
|
+
try {
|
|
1794
|
+
globalThis.localStorage.setItem(key, value)
|
|
1795
|
+
} catch (error) {
|
|
1796
|
+
// If the storage is full, clear all known values and try again.
|
|
1797
|
+
this.#clearAllValues()
|
|
1798
|
+
globalThis.localStorage.setItem(key, value)
|
|
1799
|
+
}
|
|
1800
|
+
};
|
|
1801
|
+
|
|
1802
|
+
/**
|
|
1803
|
+
* Clears all values from local storage that are no longer valid.
|
|
1804
|
+
*/
|
|
1805
|
+
#clearInvalidValues () {
|
|
1806
|
+
const currentTime = Date.now()
|
|
1807
|
+
|
|
1808
|
+
for (const [key, value] of this.#getStoredEntries()) {
|
|
1809
|
+
// Attempt to parse the expiry time from the value.
|
|
1810
|
+
const expiry = this.#parseExpiry(value)
|
|
1811
|
+
|
|
1812
|
+
// Discard the value if it is malformed or expired.
|
|
1813
|
+
if (expiry === null || expiry < currentTime) {
|
|
1814
|
+
globalThis.localStorage.removeItem(key)
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
/**
|
|
1820
|
+
* Clears all known values from local storage.
|
|
1821
|
+
*/
|
|
1822
|
+
#clearAllValues () {
|
|
1823
|
+
for (const [key] of this.#getStoredEntries()) {
|
|
1824
|
+
globalThis.localStorage.removeItem(key)
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
/**
|
|
1829
|
+
* Gets all entries stored in local storage that are known to be managed by this class.
|
|
1830
|
+
* @returns {[string, string][]} An array of key-value pairs.
|
|
1831
|
+
*/
|
|
1832
|
+
#getStoredEntries () {
|
|
1833
|
+
return Object.entries(globalThis.localStorage).filter(([key]) => key.startsWith(STORAGE_KEY_PREFIX))
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
/**
|
|
1837
|
+
* Parses the expiry time from a value stored in local storage.
|
|
1838
|
+
* @param {string} value
|
|
1839
|
+
* @returns {number | null} The expiry time in milliseconds, or `null` if the value is malformed.
|
|
1840
|
+
*/
|
|
1841
|
+
#parseExpiry (value) {
|
|
1842
|
+
let parsedValue
|
|
1843
|
+
|
|
1844
|
+
// Attempt to parse the value as JSON.
|
|
1845
|
+
try {
|
|
1846
|
+
parsedValue = JSON.parse(value)
|
|
1847
|
+
} catch (error) {
|
|
1848
|
+
return null
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// Attempt to extract the 'expires' property.
|
|
1852
|
+
if (isObject(parsedValue) && 'expires' in parsedValue && typeof parsedValue.expires === 'number') {
|
|
1853
|
+
return parsedValue.expires
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
return null
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/**
|
|
1861
|
+
* @implements {CallbackStorage}
|
|
1862
|
+
*/
|
|
1863
|
+
class CookieStorage {
|
|
1864
|
+
/**
|
|
1865
|
+
* @param {string} [state]
|
|
1866
|
+
* @returns {CallbackState | null}
|
|
1867
|
+
*/
|
|
1868
|
+
get (state) {
|
|
1869
|
+
if (!state) {
|
|
1870
|
+
return null
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
const value = this.#getCookie(STORAGE_KEY_PREFIX + state)
|
|
1874
|
+
this.#setCookie(STORAGE_KEY_PREFIX + state, '', this.#cookieExpiration(-100))
|
|
1875
|
+
if (value) {
|
|
1876
|
+
return JSON.parse(value)
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
return null
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
/**
|
|
1883
|
+
* @param {CallbackState} state
|
|
1884
|
+
*/
|
|
1885
|
+
add (state) {
|
|
1886
|
+
this.#setCookie(STORAGE_KEY_PREFIX + state.state, JSON.stringify(state), this.#cookieExpiration(60))
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
/**
|
|
1890
|
+
* @param {string} key
|
|
1891
|
+
* @returns
|
|
1892
|
+
*/
|
|
1893
|
+
#getCookie (key) {
|
|
1894
|
+
const name = key + '='
|
|
1895
|
+
const ca = document.cookie.split(';')
|
|
1896
|
+
for (let i = 0; i < ca.length; i++) {
|
|
1897
|
+
let c = ca[i]
|
|
1898
|
+
while (c.charAt(0) === ' ') {
|
|
1899
|
+
c = c.substring(1)
|
|
1900
|
+
}
|
|
1901
|
+
if (c.indexOf(name) === 0) {
|
|
1902
|
+
return c.substring(name.length, c.length)
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
return ''
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
/**
|
|
1909
|
+
* @param {string} key
|
|
1910
|
+
* @param {string} value
|
|
1911
|
+
* @param {Date} expirationDate
|
|
1912
|
+
*/
|
|
1913
|
+
#setCookie (key, value, expirationDate) {
|
|
1914
|
+
const cookie = key + '=' + value + '; ' +
|
|
1915
|
+
'expires=' + expirationDate.toUTCString() + '; '
|
|
1916
|
+
document.cookie = cookie
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* @param {number} minutes
|
|
1921
|
+
* @returns {Date}
|
|
1922
|
+
*/
|
|
1923
|
+
#cookieExpiration (minutes) {
|
|
1924
|
+
const exp = new Date()
|
|
1925
|
+
exp.setTime(exp.getTime() + (minutes * 60 * 1000))
|
|
1926
|
+
return exp
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/**
|
|
1931
|
+
* @param {Uint8Array<ArrayBuffer>} bytes
|
|
1932
|
+
* @see https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
|
|
1933
|
+
*/
|
|
1934
|
+
function bytesToBase64 (bytes) {
|
|
1935
|
+
const binString = String.fromCodePoint(...bytes)
|
|
1936
|
+
return btoa(binString)
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
/**
|
|
1940
|
+
* @param {string} message
|
|
1941
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#basic_example
|
|
1942
|
+
*/
|
|
1943
|
+
async function sha256Digest (message) {
|
|
1944
|
+
const encoder = new TextEncoder()
|
|
1945
|
+
const data = encoder.encode(message)
|
|
1946
|
+
|
|
1947
|
+
if (typeof crypto === 'undefined' || typeof crypto.subtle === 'undefined') {
|
|
1948
|
+
throw new Error('Web Crypto API is not available.')
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
return await crypto.subtle.digest('SHA-256', data)
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/**
|
|
1955
|
+
* @param {string} token
|
|
1956
|
+
* @returns {KeycloakTokenParsed}
|
|
1957
|
+
*/
|
|
1958
|
+
function decodeToken (token) {
|
|
1959
|
+
const [, payload] = token.split('.')
|
|
1960
|
+
|
|
1961
|
+
if (typeof payload !== 'string') {
|
|
1962
|
+
throw new Error('Unable to decode token, payload not found.')
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
let decoded
|
|
1966
|
+
|
|
1967
|
+
try {
|
|
1968
|
+
decoded = base64UrlDecode(payload)
|
|
1969
|
+
} catch (error) {
|
|
1970
|
+
throw new Error('Unable to decode token, payload is not a valid Base64URL value.', { cause: error })
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
try {
|
|
1974
|
+
return JSON.parse(decoded)
|
|
1975
|
+
} catch (error) {
|
|
1976
|
+
throw new Error('Unable to decode token, payload is not a valid JSON value.', { cause: error })
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/**
|
|
1981
|
+
* @param {string} input
|
|
1982
|
+
*/
|
|
1983
|
+
function base64UrlDecode (input) {
|
|
1984
|
+
let output = input
|
|
1985
|
+
.replaceAll('-', '+')
|
|
1986
|
+
.replaceAll('_', '/')
|
|
1987
|
+
|
|
1988
|
+
switch (output.length % 4) {
|
|
1989
|
+
case 0:
|
|
1990
|
+
break
|
|
1991
|
+
case 2:
|
|
1992
|
+
output += '=='
|
|
1993
|
+
break
|
|
1994
|
+
case 3:
|
|
1995
|
+
output += '='
|
|
1996
|
+
break
|
|
1997
|
+
default:
|
|
1998
|
+
throw new Error('Input is not of the correct length.')
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
try {
|
|
2002
|
+
return b64DecodeUnicode(output)
|
|
2003
|
+
} catch (error) {
|
|
2004
|
+
return atob(output)
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* @param {string} input
|
|
2010
|
+
*/
|
|
2011
|
+
function b64DecodeUnicode (input) {
|
|
2012
|
+
return decodeURIComponent(atob(input).replace(/(.)/g, (m, p) => {
|
|
2013
|
+
let code = p.charCodeAt(0).toString(16).toUpperCase()
|
|
2014
|
+
|
|
2015
|
+
if (code.length < 2) {
|
|
2016
|
+
code = '0' + code
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
return '%' + code
|
|
2020
|
+
}))
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
/**
|
|
2024
|
+
* Check if the input is an object that can be operated on.
|
|
2025
|
+
* @param {unknown} input
|
|
2026
|
+
*/
|
|
2027
|
+
function isObject (input) {
|
|
2028
|
+
return typeof input === 'object' && input !== null
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
/**
|
|
2032
|
+
* @typedef {Object} JsonConfig The JSON version of the adapter configuration.
|
|
2033
|
+
* @property {string} auth-server-url The URL of the authentication server.
|
|
2034
|
+
* @property {string} realm The name of the realm.
|
|
2035
|
+
* @property {string} resource The name of the resource, usually the client ID.
|
|
2036
|
+
*/
|
|
2037
|
+
|
|
2038
|
+
/**
|
|
2039
|
+
* Fetch the adapter configuration from the given URL.
|
|
2040
|
+
* @param {string} url
|
|
2041
|
+
* @returns {Promise<JsonConfig>}
|
|
2042
|
+
*/
|
|
2043
|
+
async function fetchJsonConfig (url) {
|
|
2044
|
+
return await fetchJSON(url)
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
/**
|
|
2048
|
+
* Fetch the OpenID configuration from the given URL.
|
|
2049
|
+
* @param {string} url
|
|
2050
|
+
* @returns {Promise<OpenIdProviderMetadata>}
|
|
2051
|
+
*/
|
|
2052
|
+
async function fetchOpenIdConfig (url) {
|
|
2053
|
+
return await fetchJSON(url)
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
/**
|
|
2057
|
+
* @typedef {Object} AccessTokenResponse The successful token response from the authorization server, based on the {@link https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 OAuth 2.0 Authorization Framework specification}.
|
|
2058
|
+
* @property {string} access_token The access token issued by the authorization server.
|
|
2059
|
+
* @property {string} token_type The type of the token issued by the authorization server.
|
|
2060
|
+
* @property {number} [expires_in] The lifetime in seconds of the access token.
|
|
2061
|
+
* @property {string} [refresh_token] The refresh token issued by the authorization server.
|
|
2062
|
+
* @property {string} [id_token] The ID token issued by the authorization server, if requested.
|
|
2063
|
+
* @property {string} [scope] The scope of the access token.
|
|
2064
|
+
*/
|
|
2065
|
+
|
|
2066
|
+
/**
|
|
2067
|
+
* Fetch the access token from the given URL.
|
|
2068
|
+
* @param {string} url
|
|
2069
|
+
* @param {string} code
|
|
2070
|
+
* @param {string} clientId
|
|
2071
|
+
* @param {string} redirectUri
|
|
2072
|
+
* @param {string} [pkceCodeVerifier]
|
|
2073
|
+
* @returns {Promise<AccessTokenResponse>}
|
|
2074
|
+
*/
|
|
2075
|
+
async function fetchAccessToken (url, code, clientId, redirectUri, pkceCodeVerifier) {
|
|
2076
|
+
const body = new URLSearchParams([
|
|
2077
|
+
['code', code],
|
|
2078
|
+
['grant_type', 'authorization_code'],
|
|
2079
|
+
['client_id', clientId],
|
|
2080
|
+
['redirect_uri', redirectUri]
|
|
2081
|
+
])
|
|
2082
|
+
|
|
2083
|
+
if (pkceCodeVerifier) {
|
|
2084
|
+
body.append('code_verifier', pkceCodeVerifier)
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
return await fetchJSON(url, {
|
|
2088
|
+
method: 'POST',
|
|
2089
|
+
credentials: 'include',
|
|
2090
|
+
body
|
|
2091
|
+
})
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/**
|
|
2095
|
+
* Fetch the refresh token from the given URL.
|
|
2096
|
+
* @param {string} url
|
|
2097
|
+
* @param {string} refreshToken
|
|
2098
|
+
* @param {string} clientId
|
|
2099
|
+
* @returns {Promise<AccessTokenResponse>}
|
|
2100
|
+
*/
|
|
2101
|
+
async function fetchRefreshToken (url, refreshToken, clientId) {
|
|
2102
|
+
const body = new URLSearchParams([
|
|
2103
|
+
['grant_type', 'refresh_token'],
|
|
2104
|
+
['refresh_token', refreshToken],
|
|
2105
|
+
['client_id', clientId]
|
|
2106
|
+
])
|
|
2107
|
+
|
|
2108
|
+
return await fetchJSON(url, {
|
|
2109
|
+
method: 'POST',
|
|
2110
|
+
credentials: 'include',
|
|
2111
|
+
body
|
|
2112
|
+
})
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
/**
|
|
2116
|
+
* @template [T=unknown]
|
|
2117
|
+
* @param {string} url
|
|
2118
|
+
* @param {RequestInit} init
|
|
2119
|
+
* @returns {Promise<T>}
|
|
2120
|
+
*/
|
|
2121
|
+
async function fetchJSON (url, init = {}) {
|
|
2122
|
+
const headers = new Headers(init.headers)
|
|
2123
|
+
headers.set('Accept', CONTENT_TYPE_JSON)
|
|
2124
|
+
|
|
2125
|
+
const response = await fetchWithErrorHandling(url, {
|
|
2126
|
+
...init,
|
|
2127
|
+
headers
|
|
2128
|
+
})
|
|
2129
|
+
|
|
2130
|
+
return await response.json()
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
/**
|
|
2134
|
+
* @param {string} url
|
|
2135
|
+
* @param {RequestInit} [init]
|
|
2136
|
+
* @returns {Promise<Response>}
|
|
2137
|
+
*/
|
|
2138
|
+
async function fetchWithErrorHandling (url, init) {
|
|
2139
|
+
const response = await fetch(url, init)
|
|
2140
|
+
|
|
2141
|
+
if (!response.ok) {
|
|
2142
|
+
throw new NetworkError('Server responded with an invalid status.', { response })
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
return response
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
/**
|
|
2149
|
+
* @param {string} [token]
|
|
2150
|
+
* @returns {[string, string]}
|
|
2151
|
+
*/
|
|
2152
|
+
function buildAuthorizationHeader (token) {
|
|
2153
|
+
if (!token) {
|
|
2154
|
+
throw new Error('Unable to build authorization header, token is not set, make sure the user is authenticated.')
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
return ['Authorization', `bearer ${token}`]
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
/**
|
|
2161
|
+
* @param {string} url
|
|
2162
|
+
* @returns {string}
|
|
2163
|
+
*/
|
|
2164
|
+
function stripTrailingSlash (url) {
|
|
2165
|
+
return url.endsWith('/') ? url.slice(0, -1) : url
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
/**
|
|
2169
|
+
* @typedef {Object} NetworkErrorOptionsProperties
|
|
2170
|
+
* @property {Response} response
|
|
2171
|
+
* @typedef {ErrorOptions & NetworkErrorOptionsProperties} NetworkErrorOptions
|
|
2172
|
+
*/
|
|
2173
|
+
|
|
2174
|
+
export class NetworkError extends Error {
|
|
2175
|
+
/** @type {Response} */
|
|
2176
|
+
response
|
|
2177
|
+
|
|
2178
|
+
/**
|
|
2179
|
+
* @param {string} message
|
|
2180
|
+
* @param {NetworkErrorOptions} options
|
|
2181
|
+
*/
|
|
2182
|
+
constructor (message, options) {
|
|
2183
|
+
super(message, options)
|
|
2184
|
+
this.response = options.response
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/**
|
|
2189
|
+
* @param {number} delay
|
|
2190
|
+
* @returns {Promise<void>}
|
|
2191
|
+
*/
|
|
2192
|
+
const waitForTimeout = (delay) => new Promise((resolve) => setTimeout(resolve, delay))
|