@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.
@@ -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))