Skip to content

Commit 56d3ba3

Browse files
Merge pull request #6935 from Shopify/ask-for-alias-on-login
Ask for an alias on login when the email is not found
2 parents 74e0717 + 81f2cc6 commit 56d3ba3

6 files changed

Lines changed: 163 additions & 25 deletions

File tree

packages/cli-kit/src/private/node/session.test.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ const validIdentityToken: IdentityToken = {
4646
expiresAt: futureDate,
4747
scopes: ['scope', 'scope2'],
4848
userId,
49-
alias: userId,
5049
}
5150

5251
const validTokens: OAuthSession = {
@@ -229,7 +228,7 @@ The CLI is currently unable to prompt for reauthentication.`,
229228
expect(fetchSessions).toHaveBeenCalledOnce()
230229
})
231230

232-
test('falls back to userId when email fetch fails', async () => {
231+
test('falls back to userId as alias when email fetch fails', async () => {
233232
// Given
234233
vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth')
235234
vi.mocked(fetchSessions).mockResolvedValue(undefined)
@@ -243,7 +242,6 @@ The CLI is currently unable to prompt for reauthentication.`,
243242
expect(businessPlatformRequest).toHaveBeenCalled()
244243
expect(storeSessions).toHaveBeenCalledOnce()
245244

246-
// Verify the session was stored with userId as alias (fallback)
247245
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
248246
expect(storedSession[fqdn]![userId]!.identity.alias).toBe(userId)
249247

@@ -262,7 +260,7 @@ The CLI is currently unable to prompt for reauthentication.`,
262260
vi.mocked(exchangeAccessForApplicationTokens).mockResolvedValueOnce(appTokensWithoutBusinessPlatform)
263261

264262
// When
265-
const got = await ensureAuthenticated(defaultApplications)
263+
await ensureAuthenticated(defaultApplications)
266264

267265
// Then
268266
expect(businessPlatformRequest).not.toHaveBeenCalled()
@@ -650,16 +648,24 @@ describe('ensureAuthenticated email fetch functionality', () => {
650648

651649
test('preserves existing alias during refresh token flow', async () => {
652650
// Given
651+
const sessionsWithAlias: Sessions = {
652+
[fqdn]: {
653+
[userId]: {
654+
identity: {...validIdentityToken, alias: 'user@example.com'},
655+
applications: appTokens,
656+
},
657+
},
658+
}
653659
vi.mocked(validateSession).mockResolvedValueOnce('needs_refresh')
654-
vi.mocked(fetchSessions).mockResolvedValue(validSessions)
660+
vi.mocked(fetchSessions).mockResolvedValue(sessionsWithAlias)
655661

656662
// When
657663
const got = await ensureAuthenticated(defaultApplications)
658664

659665
// Then
660-
// The email fetch is not called during refresh - the session keeps its existing alias
661666
expect(businessPlatformRequest).not.toHaveBeenCalled()
662-
expect(storeSessions).toBeCalledWith(validSessions)
667+
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
668+
expect(storedSession[fqdn]![userId]!.identity.alias).toBe('user@example.com')
663669
expect(got).toEqual(validTokens)
664670
})
665671

@@ -684,7 +690,32 @@ describe('ensureAuthenticated email fetch functionality', () => {
684690
expect(got).toEqual(validTokens)
685691
})
686692

687-
test('uses userId as alias when email is not available', async () => {
693+
test('preserves existing alias during token refresh error fallback when email fetch fails', async () => {
694+
// Given
695+
const sessionsWithAlias: Sessions = {
696+
[fqdn]: {
697+
[userId]: {
698+
identity: {...validIdentityToken, alias: 'my-custom-alias'},
699+
applications: {},
700+
},
701+
},
702+
}
703+
const tokenResponseError = new InvalidGrantError()
704+
vi.mocked(validateSession).mockResolvedValueOnce('needs_refresh')
705+
vi.mocked(fetchSessions).mockResolvedValue(sessionsWithAlias)
706+
vi.mocked(refreshAccessToken).mockRejectedValueOnce(tokenResponseError)
707+
vi.mocked(businessPlatformRequest).mockRejectedValueOnce(new Error('API Error'))
708+
709+
// When
710+
const got = await ensureAuthenticated(defaultApplications)
711+
712+
// Then
713+
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
714+
expect(storedSession[fqdn]![userId]!.identity.alias).toBe('my-custom-alias')
715+
expect(got).toEqual(validTokens)
716+
})
717+
718+
test('falls back to userId as alias when email is not available', async () => {
688719
// Given
689720
vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth')
690721
vi.mocked(fetchSessions).mockResolvedValue(undefined)

packages/cli-kit/src/private/node/session.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ async function fetchEmail(businessPlatformToken: string | undefined): Promise<st
3535

3636
try {
3737
const userEmailResult = await businessPlatformRequest<UserEmailQuery>(UserEmailQueryString, businessPlatformToken)
38-
return userEmailResult.currentUserAccount?.email
38+
return userEmailResult.currentUserAccount?.email ?? undefined
3939
// eslint-disable-next-line no-catch-all/no-catch-all
4040
} catch (error) {
4141
outputDebug(outputContent`Failed to fetch user email: ${(error as Error).message ?? String(error)}`)
@@ -230,15 +230,15 @@ ${outputToken.json(applications)}
230230
if (validationResult === 'needs_full_auth') {
231231
await throwOnNoPrompt(noPrompt)
232232
outputDebug(outputContent`Initiating the full authentication flow...`)
233-
newSession = await executeCompleteFlow(applications)
233+
newSession = await executeCompleteFlow(applications, currentSession?.identity.alias)
234234
} else if (validationResult === 'needs_refresh' || forceRefresh) {
235235
outputDebug(outputContent`The current session is valid but needs refresh. Refreshing...`)
236236
try {
237237
newSession = await refreshTokens(currentSession!, applications)
238238
} catch (error) {
239239
if (error instanceof InvalidGrantError) {
240240
await throwOnNoPrompt(noPrompt)
241-
newSession = await executeCompleteFlow(applications)
241+
newSession = await executeCompleteFlow(applications, currentSession?.identity.alias)
242242
} else if (error instanceof InvalidRequestError) {
243243
await sessionStore.remove()
244244
throw new AbortError('\nError validating auth session', "We've cleared the current session, please try again")
@@ -288,9 +288,9 @@ The CLI is currently unable to prompt for reauthentication.`,
288288
* Execute the full authentication flow.
289289
*
290290
* @param applications - An object containing the applications we need to be authenticated with.
291-
* @param alias - Optional alias to use for the session.
291+
* @param existingAlias - Optional alias from a previous session to preserve if the email fetch fails.
292292
*/
293-
async function executeCompleteFlow(applications: OAuthApplications): Promise<Session> {
293+
async function executeCompleteFlow(applications: OAuthApplications, existingAlias?: string): Promise<Session> {
294294
const scopes = getFlattenScopes(applications)
295295
const exchangeScopes = getExchangeScopes(applications)
296296
const store = applications.adminApi?.storeFqdn
@@ -317,9 +317,9 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise<Ses
317317
outputDebug(outputContent`CLI token received. Exchanging it for application tokens...`)
318318
const result = await exchangeAccessForApplicationTokens(identityToken, exchangeScopes, store)
319319

320-
// Get the alias for the session (email or userId)
320+
// Preserve existing alias if available, otherwise try fetching email
321321
const businessPlatformToken = result[applicationId('business-platform')]?.accessToken
322-
const alias = (await fetchEmail(businessPlatformToken)) ?? identityToken.userId
322+
const alias = existingAlias ?? (await fetchEmail(businessPlatformToken)) ?? identityToken.userId
323323

324324
const session: Session = {
325325
identity: {
@@ -351,7 +351,7 @@ async function refreshTokens(session: Session, applications: OAuthApplications):
351351
)
352352

353353
return {
354-
identity: identityToken,
354+
identity: {...identityToken, alias: session.identity.alias},
355355
applications: applicationTokens,
356356
}
357357
}
@@ -446,6 +446,6 @@ function buildIdentityTokenFromEnv(
446446
...identityTokenInformation,
447447
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
448448
scopes,
449-
alias: identityTokenInformation.userId,
449+
alias: undefined,
450450
}
451451
}

packages/cli-kit/src/private/node/session/store.test.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {Sessions} from './schema.js'
2-
import {store, fetch, remove, getSessionAlias, findSessionByAlias} from './store.js'
2+
import {store, fetch, remove, getSessionAlias, setSessionAlias, findSessionByAlias} from './store.js'
33
import {getSessions, removeSessions, setSessions, removeCurrentSessionId} from '../conf-store.js'
44
import {identityFqdn} from '../../../public/node/context/fqdn.js'
55

@@ -28,7 +28,6 @@ const mockSessions: Sessions = {
2828
expiresAt: new Date(),
2929
scopes: ['scope2'],
3030
userId: 'user2',
31-
alias: 'user2',
3231
},
3332
applications: {},
3433
},
@@ -132,6 +131,54 @@ describe('session store', () => {
132131
})
133132
})
134133

134+
describe('setSessionAlias', () => {
135+
test('updates alias for an existing user', async () => {
136+
// Given
137+
vi.mocked(getSessions).mockReturnValue(JSON.stringify(mockSessions))
138+
139+
// When
140+
await setSessionAlias('user1', 'New Alias')
141+
142+
// Then
143+
const storedSessions = JSON.parse(vi.mocked(setSessions).mock.calls[0]![0])
144+
expect(storedSessions['identity.fqdn.com'].user1.identity.alias).toBe('New Alias')
145+
})
146+
147+
test('does nothing when no sessions exist', async () => {
148+
// Given
149+
vi.mocked(getSessions).mockReturnValue(undefined)
150+
151+
// When
152+
await setSessionAlias('user1', 'New Alias')
153+
154+
// Then
155+
expect(setSessions).not.toHaveBeenCalled()
156+
})
157+
158+
test('does nothing when user does not exist', async () => {
159+
// Given
160+
vi.mocked(getSessions).mockReturnValue(JSON.stringify(mockSessions))
161+
162+
// When
163+
await setSessionAlias('nonexistent', 'New Alias')
164+
165+
// Then
166+
expect(setSessions).not.toHaveBeenCalled()
167+
})
168+
169+
test('does nothing when fqdn does not match', async () => {
170+
// Given
171+
vi.mocked(getSessions).mockReturnValue(JSON.stringify(mockSessions))
172+
vi.mocked(identityFqdn).mockResolvedValue('different.fqdn.com')
173+
174+
// When
175+
await setSessionAlias('user1', 'New Alias')
176+
177+
// Then
178+
expect(setSessions).not.toHaveBeenCalled()
179+
})
180+
})
181+
135182
describe('findSessionByAlias', () => {
136183
test('returns userId for existing alias', async () => {
137184
// Given

packages/cli-kit/src/private/node/session/store.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {SessionsSchema} from './schema.js'
22
import {getSessions, removeCurrentSessionId, removeSessions, setSessions} from '../conf-store.js'
33
import {identityFqdn} from '../../../public/node/context/fqdn.js'
4-
import type {Sessions} from './schema.js'
4+
import type {Session, Sessions} from './schema.js'
55

66
/**
77
* Serializes the session as a JSON and stores it in the system.
@@ -56,6 +56,24 @@ export async function getSessionAlias(userId: string): Promise<string | undefine
5656
return sessions[fqdn][userId].identity.alias
5757
}
5858

59+
/**
60+
* Sets the alias for a given user's session and persists it.
61+
*
62+
* @param userId - The user ID of the session to update.
63+
* @param alias - The new alias to set.
64+
*/
65+
export async function setSessionAlias(userId: string, alias: string): Promise<void> {
66+
const sessions = await fetch()
67+
if (!sessions) return
68+
69+
const fqdn = await identityFqdn()
70+
const session: Session | undefined = sessions[fqdn]?.[userId]
71+
if (!session) return
72+
73+
session.identity.alias = alias
74+
await store(sessions)
75+
}
76+
5977
/**
6078
* Finds a session by its alias.
6179
*

packages/cli-kit/src/public/node/session-prompt.test.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {promptSessionSelect} from './session-prompt.js'
2-
import {renderSelectPrompt} from './ui.js'
2+
import {renderSelectPrompt, renderTextPrompt} from './ui.js'
33
import {ensureAuthenticatedUser} from './session.js'
44
import {identityFqdn} from './context/fqdn.js'
55
import {setCurrentSessionId} from '../../private/node/conf-store.js'
@@ -34,8 +34,6 @@ const mockSessions: Sessions = {
3434
expiresAt: new Date(),
3535
scopes: ['scope2'],
3636
userId: 'user2',
37-
// Default alias is same as userId
38-
alias: 'user2',
3937
},
4038
applications: {},
4139
},
@@ -64,6 +62,21 @@ describe('promptSessionSelect', () => {
6462
expect(result).toEqual('new-alias')
6563
})
6664

65+
test('prompts for alias when no alias is stored', async () => {
66+
// Given
67+
vi.mocked(sessionStore.fetch).mockResolvedValue(undefined)
68+
vi.mocked(sessionStore.getSessionAlias).mockResolvedValue(undefined)
69+
vi.mocked(renderTextPrompt).mockResolvedValue('typed-alias')
70+
71+
// When
72+
const result = await promptSessionSelect()
73+
74+
// Then
75+
expect(renderTextPrompt).toHaveBeenCalled()
76+
expect(sessionStore.setSessionAlias).toHaveBeenCalledWith('new-user-id', 'typed-alias')
77+
expect(result).toEqual('typed-alias')
78+
})
79+
6780
test('prompts user to create new session with alias when no existing sessions', async () => {
6881
// Given
6982
vi.mocked(sessionStore.fetch).mockResolvedValue(undefined)
@@ -166,6 +179,25 @@ describe('promptSessionSelect', () => {
166179
expect(result).toEqual('custom-alias')
167180
})
168181

182+
test('prompts for alias when selecting "Log in with a different account" and no alias is stored', async () => {
183+
// Given
184+
vi.mocked(sessionStore.fetch).mockResolvedValue(mockSessions)
185+
vi.mocked(renderSelectPrompt).mockResolvedValue('NEW_LOGIN')
186+
vi.mocked(sessionStore.getSessionAlias).mockResolvedValue(undefined)
187+
vi.mocked(renderTextPrompt).mockResolvedValue('my-work-account')
188+
189+
// When
190+
const result = await promptSessionSelect()
191+
192+
// Then
193+
expect(ensureAuthenticatedUser).toHaveBeenCalledWith({}, {forceNewSession: true})
194+
expect(renderTextPrompt).toHaveBeenCalledWith({
195+
message: 'Enter an alias for this account (e.g. your email or a nickname)',
196+
})
197+
expect(sessionStore.setSessionAlias).toHaveBeenCalledWith('new-user-id', 'my-work-account')
198+
expect(result).toEqual('my-work-account')
199+
})
200+
169201
test('does not update alias for existing session when not provided', async () => {
170202
// Given
171203
vi.mocked(sessionStore.fetch).mockResolvedValue(mockSessions)

packages/cli-kit/src/public/node/session-prompt.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {renderSelectPrompt} from './ui.js'
1+
import {renderSelectPrompt, renderTextPrompt} from './ui.js'
22
import {ensureAuthenticatedUser} from './session.js'
33
import {identityFqdn} from './context/fqdn.js'
44
import * as sessionStore from '../../private/node/session/store.js'
@@ -37,13 +37,23 @@ function buildSessionChoices(sessions: Sessions, fqdn: string): SessionChoice[]
3737

3838
/**
3939
* Handles the new login flow.
40+
* If no alias is stored (email couldn't be fetched), prompts the user for a friendly alias.
4041
*
4142
* @returns The alias of the authenticated user.
4243
*/
4344
async function handleNewLogin(): Promise<string> {
4445
const result = await ensureAuthenticatedUser({}, {forceNewSession: true})
4546
const alias = await sessionStore.getSessionAlias(result.userId)
46-
return alias ?? result.userId
47+
48+
if (!alias) {
49+
const userAlias = await renderTextPrompt({
50+
message: 'Enter an alias for this account (e.g. your email or a nickname)',
51+
})
52+
await sessionStore.setSessionAlias(result.userId, userAlias)
53+
return userAlias
54+
}
55+
56+
return alias
4757
}
4858

4959
/**

0 commit comments

Comments
 (0)