@stack-spot/auth-react 2.14.1-beta.1 → 2.14.1

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.
@@ -1,296 +1,313 @@
1
- import { AccessTokenPayload, AuthConfig, AuthManager, GenericLogger, Session, ThirdPartyAuthType, ThirdPartyLoginParams } from '@stack-spot/auth'
2
- import { sessionCookie } from './utils/cookies'
3
- import { redirect } from './utils/redirect'
4
-
5
- const sessionKey = 'session'
6
-
7
- interface SessionManagerConfig extends Pick<AuthConfig, 'accountUrl' | 'authUrl' | 'clientId' | 'defaultTenant' | 'retry' | 'retryDelay' | 'logger'> {
8
- /**
9
- * The URL to redirect to when the user logs out.
10
- * @default location.origin
11
- */
12
- loginUrl?: string,
13
- /**
14
- * The URL to redirect to when the login completes in the authentication app. If not provided, will be the same as `loginUrl`.
15
- * @default loginUrl
16
- */
17
- redirectUrl?: string,
18
- /**
19
- * Forbidden authentication types to this Session Manager.
20
- */
21
- blockedAuthTypes?: ThirdPartyAuthType[]
22
- /**
23
- * A URL to send login events to (observability).
24
- */
25
- rdUrl?: string,
26
- }
27
-
28
- type AuthExtraData = { from?: string | null, finalRedirect?: string | null }
29
-
30
- type ChangeListener = (session: Session | undefined) => void
31
-
32
- /**
33
- * Controls the current session in a browser.
34
- *
35
- * This should not be used under a Node.JS environment.
36
- *
37
- * This is a singleton. To create the first instance or recover the current one, use `SessionManager.create`.
38
- */
39
- export class SessionManager {
40
- private current: Session | undefined
41
- private readonly auth: AuthManager<AuthExtraData>
42
- private config: SessionManagerConfig
43
- private changeListeners: ChangeListener[] = []
44
- private logger: GenericLogger
45
- static instance: SessionManager | undefined
46
-
47
- private constructor(config: SessionManagerConfig) {
48
- config.loginUrl ||= location.origin
49
- const redirectUrl = (config.redirectUrl || config.loginUrl).replace(/([^/])$/, '$1/') // the trailing "/" is required by Stackspot IAM.
50
- this.config = config
51
- this.auth = new AuthManager<AuthExtraData>({
52
- ...config,
53
- redirectUrl,
54
- storage: localStorage,
55
- sessionPersistence: {
56
- load: () => localStorage.getItem(sessionKey),
57
- save: (session) => localStorage.setItem(sessionKey, session),
58
- },
59
- })
60
- this.logger = this.auth.config.logger
61
- SessionManager.instance = this
62
-
63
- // Keep session in sync with other app's session
64
- addEventListener('focus', () => this.validateSharedSession())
65
- }
66
-
67
- static create(config: SessionManagerConfig) {
68
- return SessionManager.instance ?? new SessionManager(config)
69
- }
70
-
71
- private setSession(session: Session | undefined) {
72
- this.current = session
73
- this.changeListeners.forEach(l => l(session))
74
- if (session) this.setSessionCookie(session)
75
- }
76
-
77
- async restoreSession() {
78
- const session = await this.auth.restoreSession()
79
- this.logger.log('Validating shared session.')
80
- const sessionValid = await this.validateSharedSession(session)
81
- this.setSession(sessionValid ? session : undefined)
82
- }
83
-
84
- async validateSharedSession(session: Session | undefined = this.current): Promise<boolean> {
85
-
86
- // skipping because authentication is in progress
87
- if (this.urlHasThirdPartyLoginData()) {
88
- this.logger.log('Session is invalid because there\'s another authentication in progress.')
89
- return false
90
- }
91
-
92
- const sharedSessionCookie = sessionCookie.get()
93
-
94
- // It has been logged out on another portal, so logout on this one too
95
- if (!sharedSessionCookie) {
96
- this.logger.log('Session is invalid because no shared session cookie was found, i.e, a logout was performed in another portal. Forcing log off.')
97
- session && await this.logout()
98
- return false
99
- }
100
-
101
- const isDifferentSessionActive = sharedSessionCookie.sub != session?.getTokenData().sub
102
- const isSharedSessionTypeBlocked = this.config.blockedAuthTypes?.includes(sharedSessionCookie.type)
103
- if (isSharedSessionTypeBlocked) {
104
- this.logger.log('Session is invalid because shared sessions have been blocked in the SessionManager\'s configuration (blockedAuthTypes).')
105
- return false
106
- } else if (isDifferentSessionActive || !session) {
107
- this.logger.log(isDifferentSessionActive
108
- ? 'Session is invalid because a different session is already active.'
109
- : 'Session is invalid because it\'s undefined.'
110
- )
111
- this.logger.log('Starting login with tenant from the session cookie.')
112
- await this.startThirdPartyLoginUsingTenant(sharedSessionCookie)
113
- return false
114
- }
115
- return true
116
- }
117
-
118
- hasSession() {
119
- return !!this.current && !this.current.isExpired()
120
- }
121
-
122
- getSession() {
123
- if (!this.hasSession()) {
124
- this.endSession()
125
- throw new Error('Session is not available, redirecting to login.')
126
- }
127
- return this.current!
128
- }
129
-
130
- async endSession(redirectToLogin = true) {
131
- this.current = undefined
132
- localStorage.removeItem(sessionKey)
133
- sessionCookie.delete()
134
- if (redirectToLogin && this.config.loginUrl) await redirect(this.config.loginUrl)
135
- }
136
-
137
- async restartSession() {
138
- await this.logout({ endSession: false })
139
- this.current = undefined
140
- localStorage.removeItem(sessionKey)
141
- await this.restoreSession()
142
- }
143
-
144
- async logout({ endSession }: { endSession?: boolean } | undefined = { endSession: true }) {
145
- try {
146
- await this.current?.logout()
147
- } catch (error) {
148
- // eslint-disable-next-line no-console
149
- console.error(`Could not logout from IDM.\n${error}`)
150
- }
151
- if (!endSession) return
152
- await this.endSession()
153
- }
154
-
155
- async startThirdPartyLogin(data: ThirdPartyLoginParams) {
156
- const params = new URLSearchParams(location.search)
157
- const authUrl = await this.auth.startThirdPartyLogin(data, {
158
- from: location.href,
159
- finalRedirect: params.get('finalRedirect'),
160
- })
161
- await redirect(authUrl)
162
- }
163
-
164
- urlHasThirdPartyLoginData() {
165
- const url = new URL(location.toString())
166
- return url.searchParams.has('state') && !url.searchParams.has('error')
167
- }
168
-
169
- async startThirdPartyLoginUsingTenant(data: ThirdPartyLoginParams) {
170
- const params = new URLSearchParams(location.search)
171
- const cookie = sessionCookie.get()
172
- if (!cookie || !cookie.tenant) {
173
- this.logger.log('Login out because no tenant information is available in the following data:', JSON.stringify(data))
174
- //If no tenant is available we should log out the user
175
- await this.logout()
176
- return
177
- }
178
- const authUrl = await this.auth.getThirdPartyLoginFromTenant(data, cookie.tenant, {
179
- from: location.href,
180
- finalRedirect: params.get('finalRedirect'),
181
- }
182
- )
183
- await redirect(authUrl)
184
- }
185
-
186
- async completeThirdPartyLogin() {
187
- const url = new URL(location.toString())
188
- if (url.searchParams.has('error')) {
189
- throw new Error(`Error while signing in: ${url.searchParams.get('error_description')}`)
190
- }
191
- const { session, data: { from, finalRedirect } } = await this.auth.completeThirdPartyLogin(location.search)
192
- this.setSession(session)
193
- history.replaceState(null, '', from || location.toString().replace(/\?.*$/, ''))
194
- this.sendLoginEventRd(this.current?.getTokenData())
195
- if (finalRedirect) await redirect(finalRedirect)
196
- }
197
-
198
- getEmailForLogin() {
199
- const session = sessionCookie.get()
200
- return session?.type == 'sso' ? session.email : undefined
201
- }
202
-
203
- async switchAccount(accountId: string) {
204
- this.logger.log('Switching accounts', accountId, this.current?.getTokenData().account_id_v2)
205
- try {
206
- this.current && await this.auth.switchAccount(accountId, this.current)
207
- } catch (error) {
208
- this.logger.error('Error while switching accounts', error)
209
- throw error
210
- }
211
- this.setSession(this.current)
212
- }
213
-
214
- onChange(listener: ChangeListener) {
215
- this.changeListeners.push(listener)
216
- return () => {
217
- const index = this.changeListeners.indexOf(listener)
218
- if (index != -1) this.changeListeners.splice(index, 1)
219
- }
220
- }
221
-
222
- private setSessionCookie(session: Session) {
223
- const { email, account_type, sub, tenant } = session.getTokenData()
224
- const { provider, refresh_expires_in } = session.getSessionData()
225
- if (!email || !sub || !tenant) return
226
- const isFreemium = account_type == 'FREEMIUM'
227
- const cookieAttributes = { 'Max-Age': refresh_expires_in, path: '/' }
228
- if (isFreemium) {
229
- sessionCookie.set({ type: 'idp', provider: provider!, sub, tenant }, cookieAttributes)
230
- } else {
231
- sessionCookie.set({ email, type: 'sso', sub, tenant }, cookieAttributes)
232
- }
233
- }
234
-
235
- private async sendLoginEventRd(tokenData?: AccessTokenPayload) {
236
- if (!this.config.rdUrl) return
237
-
238
- if (!tokenData) {
239
- // eslint-disable-next-line no-console
240
- console.error('Unable to trigger login hook. No sessionEmail or name identified.')
241
- return
242
- }
243
-
244
- const { email, name, account_type, client_id, account_name, trial_account_status } = tokenData
245
- const isLoginAI = client_id === "stackspot-portal-ai"
246
- const isLoginEDP = client_id === "stackspot-portal"
247
-
248
- if (!isLoginAI && !isLoginEDP && trial_account_status === 'PENDING') return
249
-
250
- const leadType = account_type === 'FREEMIUM' ? 'TRIAL' : 'ENTERPRISE'
251
-
252
- const rdObject = {
253
- event_type: 'CONVERSION',
254
- event_family: 'CDP',
255
- payload: {
256
- email,
257
- name,
258
- conversion_identifier: isLoginAI ? 'login_ai' : 'login_edp',
259
- cf_leadtype: leadType,
260
- cf_account_name: leadType === 'TRIAL' ? leadType : account_name,
261
- },
262
- }
263
-
264
- const response = await fetch(this.config.rdUrl, {
265
- method: 'POST',
266
- body: JSON.stringify(rdObject),
267
- headers: {
268
- 'content-type': 'application/json',
269
- },
270
- })
271
- const data = await response.json()
272
-
273
- if (!response.ok) {
274
- // eslint-disable-next-line no-console
275
- console.error('Error while sending event to RD Station', data)
276
- }
277
- }
278
-
279
- async getTrialEnabledProviders() {
280
- try {
281
- const response = await fetch(`${this.config.accountUrl}/v1/accounts/trial/sso`)
282
- const trialProviders = await response.json()
283
-
284
- if (!response.ok) {
285
- // eslint-disable-next-line no-console
286
- console.error('Error while fetching available login providers', trialProviders)
287
- }
288
-
289
- const providerKeys = Object.keys(trialProviders || {})
290
- return providerKeys.filter(key => trialProviders[key] === true)
291
- } catch (error) {
292
- console.error('Error while fetching available login providers', error)
293
- return []
294
- }
295
- }
296
- }
1
+ import { AccessTokenPayload, AuthConfig, AuthManager, GenericLogger, Session, ThirdPartyAuthType, ThirdPartyLoginParams } from '@stack-spot/auth'
2
+ import { sessionCookie } from './utils/cookies'
3
+ import { redirect } from './utils/redirect'
4
+
5
+ const sessionKey = 'session'
6
+
7
+ interface SessionManagerConfig extends Pick<AuthConfig, 'accountUrl' | 'authUrl' | 'clientId' | 'defaultTenant' | 'retry' | 'retryDelay' | 'logger'> {
8
+ /**
9
+ * The URL to redirect to when the user logs out.
10
+ * @default location.origin
11
+ */
12
+ loginUrl?: string,
13
+ /**
14
+ * The URL to redirect to when the login completes in the authentication app. If not provided, will be the same as `loginUrl`.
15
+ * @default loginUrl
16
+ */
17
+ redirectUrl?: string,
18
+ /**
19
+ * Forbidden authentication types to this Session Manager.
20
+ */
21
+ blockedAuthTypes?: ThirdPartyAuthType[]
22
+ /**
23
+ * A URL to send login events to (observability).
24
+ */
25
+ rdUrl?: string,
26
+ }
27
+
28
+ type AuthExtraData = { from?: string | null, finalRedirect?: string | null }
29
+
30
+ type ChangeListener = (session: Session | undefined) => void
31
+
32
+ /**
33
+ * Controls the current session in a browser.
34
+ *
35
+ * This should not be used under a Node.JS environment.
36
+ *
37
+ * This is a singleton. To create the first instance or recover the current one, use `SessionManager.create`.
38
+ */
39
+ export class SessionManager {
40
+ private current: Session | undefined
41
+ private readonly auth: AuthManager<AuthExtraData>
42
+ private config: SessionManagerConfig
43
+ private changeListeners: ChangeListener[] = []
44
+ private logger: GenericLogger
45
+ static instance: SessionManager | undefined
46
+
47
+ private constructor(config: SessionManagerConfig) {
48
+ config.loginUrl ||= location.origin
49
+ const redirectUrl = (config.redirectUrl || config.loginUrl).replace(/([^/])$/, '$1/') // the trailing "/" is required by Stackspot IAM.
50
+ this.config = config
51
+ this.auth = new AuthManager<AuthExtraData>({
52
+ ...config,
53
+ redirectUrl,
54
+ storage: localStorage,
55
+ sessionPersistence: {
56
+ load: () => localStorage.getItem(sessionKey),
57
+ save: (session) => localStorage.setItem(sessionKey, session),
58
+ },
59
+ })
60
+ this.logger = this.auth.config.logger
61
+ SessionManager.instance = this
62
+
63
+ // Keep session in sync with other app's session
64
+ addEventListener('focus', () => this.validateSharedSession())
65
+ }
66
+
67
+ static create(config: SessionManagerConfig) {
68
+ return SessionManager.instance ?? new SessionManager(config)
69
+ }
70
+
71
+ private setSession(session: Session | undefined) {
72
+ this.current = session
73
+ this.changeListeners.forEach(l => l(session))
74
+ if (session) this.setSessionCookie(session)
75
+ }
76
+
77
+ async restoreSession() {
78
+ const session = await this.auth.restoreSession()
79
+ this.logger.log('Validating shared session.')
80
+ const sessionValid = await this.validateSharedSession(session)
81
+ this.setSession(sessionValid ? session : undefined)
82
+ }
83
+
84
+ async validateSharedSession(session: Session | undefined = this.current): Promise<boolean> {
85
+
86
+ // skipping because authentication is in progress
87
+ if (this.urlHasThirdPartyLoginData()) {
88
+ this.logger.log('Session is invalid because there\'s another authentication in progress.')
89
+ return false
90
+ }
91
+
92
+ const sharedSessionCookie = sessionCookie.get()
93
+
94
+ // It has been logged out on another portal, so logout on this one too
95
+ if (!sharedSessionCookie) {
96
+ this.logger.log('Session is invalid because no shared session cookie was found, i.e, a logout was performed in another portal. Forcing log off.')
97
+ session && await this.logout()
98
+ return false
99
+ }
100
+
101
+ const isDifferentSessionActive = sharedSessionCookie.sub != session?.getTokenData().sub
102
+ const isSharedSessionTypeBlocked = this.config.blockedAuthTypes?.includes(sharedSessionCookie.type)
103
+ if (isSharedSessionTypeBlocked) {
104
+ this.logger.log('Session is invalid because shared sessions have been blocked in the SessionManager\'s configuration (blockedAuthTypes).')
105
+ return false
106
+ } else if (isDifferentSessionActive || !session) {
107
+ this.logger.log(isDifferentSessionActive
108
+ ? 'Session is invalid because a different session is already active.'
109
+ : 'Session is invalid because it\'s undefined.'
110
+ )
111
+ this.logger.log('Starting login with tenant from the session cookie.')
112
+ await this.startThirdPartyLoginUsingTenant(sharedSessionCookie)
113
+ return false
114
+ }
115
+ return true
116
+ }
117
+
118
+ hasSession() {
119
+ return !!this.current && !this.current.isExpired()
120
+ }
121
+
122
+ getSession() {
123
+ if (!this.hasSession()) {
124
+ if (!this.current) {
125
+ this.logger.warn('getSession() was called, but no session object available in memory. i.e. the user is not logged in.')
126
+ } else {
127
+ const data = {
128
+ createdAt: `${new Date(this.current.getSessionData().clientTimeMs)}`,
129
+ expiresIn: `${this.current.getSessionData().expires_in}ms`,
130
+ mustBeRefreshedIn: `${this.current.getSessionData().refresh_expires_in}ms`,
131
+ state: this.current.getSessionData().session_state,
132
+ }
133
+ this.logger.warn(
134
+ `getSession() was called, but the current session is expired. See the details below\n${JSON.stringify(data, null, 2)}`,
135
+ )
136
+ }
137
+ this.endSession()
138
+ throw new Error('Session is not available, redirecting to login.')
139
+ }
140
+ return this.current!
141
+ }
142
+
143
+ async endSession(redirectToLogin = true) {
144
+ this.logger.log('Running the end session routine.')
145
+ this.current = undefined
146
+ localStorage.removeItem(sessionKey)
147
+ sessionCookie.delete()
148
+ if (redirectToLogin && this.config.loginUrl) {
149
+ this.logger.log('Redirecting to the login page.')
150
+ await redirect(this.config.loginUrl)
151
+ }
152
+ }
153
+
154
+ async restartSession() {
155
+ await this.logout({ endSession: false })
156
+ this.current = undefined
157
+ localStorage.removeItem(sessionKey)
158
+ await this.restoreSession()
159
+ }
160
+
161
+ async logout({ endSession }: { endSession?: boolean } | undefined = { endSession: true }) {
162
+ try {
163
+ await this.current?.logout()
164
+ } catch (error) {
165
+ // eslint-disable-next-line no-console
166
+ console.error(`Could not logout from IDM.\n${error}`)
167
+ }
168
+ if (!endSession) return
169
+ await this.endSession()
170
+ }
171
+
172
+ async startThirdPartyLogin(data: ThirdPartyLoginParams) {
173
+ const params = new URLSearchParams(location.search)
174
+ const authUrl = await this.auth.startThirdPartyLogin(data, {
175
+ from: location.href,
176
+ finalRedirect: params.get('finalRedirect'),
177
+ })
178
+ await redirect(authUrl)
179
+ }
180
+
181
+ urlHasThirdPartyLoginData() {
182
+ const url = new URL(location.toString())
183
+ return url.searchParams.has('state') && !url.searchParams.has('error')
184
+ }
185
+
186
+ async startThirdPartyLoginUsingTenant(data: ThirdPartyLoginParams) {
187
+ const params = new URLSearchParams(location.search)
188
+ const cookie = sessionCookie.get()
189
+ if (!cookie || !cookie.tenant) {
190
+ this.logger.log('Login out because no tenant information is available in the following data:', JSON.stringify(data))
191
+ //If no tenant is available we should log out the user
192
+ await this.logout()
193
+ return
194
+ }
195
+ const authUrl = await this.auth.getThirdPartyLoginFromTenant(data, cookie.tenant, {
196
+ from: location.href,
197
+ finalRedirect: params.get('finalRedirect'),
198
+ }
199
+ )
200
+ await redirect(authUrl)
201
+ }
202
+
203
+ async completeThirdPartyLogin() {
204
+ const url = new URL(location.toString())
205
+ if (url.searchParams.has('error')) {
206
+ throw new Error(`Error while signing in: ${url.searchParams.get('error_description')}`)
207
+ }
208
+ const { session, data: { from, finalRedirect } } = await this.auth.completeThirdPartyLogin(location.search)
209
+ this.setSession(session)
210
+ history.replaceState(null, '', from || location.toString().replace(/\?.*$/, ''))
211
+ this.sendLoginEventRd(this.current?.getTokenData())
212
+ if (finalRedirect) await redirect(finalRedirect)
213
+ }
214
+
215
+ getEmailForLogin() {
216
+ const session = sessionCookie.get()
217
+ return session?.type == 'sso' ? session.email : undefined
218
+ }
219
+
220
+ async switchAccount(accountId: string) {
221
+ this.logger.log('Switching accounts', accountId, this.current?.getTokenData().account_id_v2)
222
+ try {
223
+ this.current && await this.auth.switchAccount(accountId, this.current)
224
+ } catch (error) {
225
+ this.logger.error('Error while switching accounts', error)
226
+ throw error
227
+ }
228
+ this.setSession(this.current)
229
+ }
230
+
231
+ onChange(listener: ChangeListener) {
232
+ this.changeListeners.push(listener)
233
+ return () => {
234
+ const index = this.changeListeners.indexOf(listener)
235
+ if (index != -1) this.changeListeners.splice(index, 1)
236
+ }
237
+ }
238
+
239
+ private setSessionCookie(session: Session) {
240
+ const { email, account_type, sub, tenant } = session.getTokenData()
241
+ const { provider, refresh_expires_in } = session.getSessionData()
242
+ if (!email || !sub || !tenant) return
243
+ const isFreemium = account_type == 'FREEMIUM'
244
+ const cookieAttributes = { 'Max-Age': refresh_expires_in, path: '/' }
245
+ if (isFreemium) {
246
+ sessionCookie.set({ type: 'idp', provider: provider!, sub, tenant }, cookieAttributes)
247
+ } else {
248
+ sessionCookie.set({ email, type: 'sso', sub, tenant }, cookieAttributes)
249
+ }
250
+ }
251
+
252
+ private async sendLoginEventRd(tokenData?: AccessTokenPayload) {
253
+ if (!this.config.rdUrl) return
254
+
255
+ if (!tokenData) {
256
+ // eslint-disable-next-line no-console
257
+ console.error('Unable to trigger login hook. No sessionEmail or name identified.')
258
+ return
259
+ }
260
+
261
+ const { email, name, account_type, client_id, account_name, trial_account_status } = tokenData
262
+ const isLoginAI = client_id === "stackspot-portal-ai"
263
+ const isLoginEDP = client_id === "stackspot-portal"
264
+
265
+ if (!isLoginAI && !isLoginEDP && trial_account_status === 'PENDING') return
266
+
267
+ const leadType = account_type === 'FREEMIUM' ? 'TRIAL' : 'ENTERPRISE'
268
+
269
+ const rdObject = {
270
+ event_type: 'CONVERSION',
271
+ event_family: 'CDP',
272
+ payload: {
273
+ email,
274
+ name,
275
+ conversion_identifier: isLoginAI ? 'login_ai' : 'login_edp',
276
+ cf_leadtype: leadType,
277
+ cf_account_name: leadType === 'TRIAL' ? leadType : account_name,
278
+ },
279
+ }
280
+
281
+ const response = await fetch(this.config.rdUrl, {
282
+ method: 'POST',
283
+ body: JSON.stringify(rdObject),
284
+ headers: {
285
+ 'content-type': 'application/json',
286
+ },
287
+ })
288
+ const data = await response.json()
289
+
290
+ if (!response.ok) {
291
+ // eslint-disable-next-line no-console
292
+ console.error('Error while sending event to RD Station', data)
293
+ }
294
+ }
295
+
296
+ async getTrialEnabledProviders() {
297
+ try {
298
+ const response = await fetch(`${this.config.accountUrl}/v1/accounts/trial/sso`)
299
+ const trialProviders = await response.json()
300
+
301
+ if (!response.ok) {
302
+ // eslint-disable-next-line no-console
303
+ console.error('Error while fetching available login providers', trialProviders)
304
+ }
305
+
306
+ const providerKeys = Object.keys(trialProviders || {})
307
+ return providerKeys.filter(key => trialProviders[key] === true)
308
+ } catch (error) {
309
+ console.error('Error while fetching available login providers', error)
310
+ return []
311
+ }
312
+ }
313
+ }