Skip to content

Commit ce3c5c0

Browse files
committed
Return the selected account ID and email from auth login
1 parent 12dfc42 commit ce3c5c0

19 files changed

Lines changed: 492 additions & 97 deletions

File tree

‎.changeset/auth-login-json.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
'@shopify/cli': minor
33
---
44

5-
Add typed JSON output to `auth login`.
5+
Add typed JSON output with selected account details to `auth login`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/cli-kit': minor
3+
---
4+
5+
Add account details to session selection while preserving the alias-only API.

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ describe('ensureAuthenticated when previous session is invalid', () => {
172172
// Verify the session was stored with email as alias
173173
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
174174
expect(storedSession[fqdn]![userId]!.identity.alias).toBe('user@example.com')
175+
expect(storedSession[fqdn]![userId]!.identity.email).toBe('user@example.com')
175176

176177
// The userID is cached in memory and the secureStore is not accessed again
177178
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('1234-5678')
@@ -211,6 +212,7 @@ The CLI is currently unable to prompt for reauthentication.`,
211212
identity: {
212213
...validIdentityToken,
213214
alias: 'user@example.com',
215+
email: 'user@example.com',
214216
},
215217
applications: appTokens,
216218
},
@@ -687,6 +689,8 @@ describe('ensureAuthenticated email fetch functionality', () => {
687689
// Then
688690
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
689691
expect(storedSession[fqdn]![userId]!.identity.alias).toBe('work@example.com')
692+
expect(storedSession[fqdn]![userId]!.identity.email).toBe('work@example.com')
693+
expect(businessPlatformRequest).toHaveBeenCalledTimes(1)
690694
expect(got).toEqual(validTokens)
691695
})
692696

@@ -788,6 +792,48 @@ describe('ensureAuthenticated email fetch functionality', () => {
788792
// Then
789793
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
790794
expect(storedSession[fqdn]![userId]!.identity.alias).toBe(userId)
795+
expect(storedSession[fqdn]![userId]!.identity.email).toBeUndefined()
791796
expect(got).toEqual(validTokens)
792797
})
793798
})
799+
800+
describe('stored authentication email', () => {
801+
test('preserves a separately stored email through refresh without another request', async () => {
802+
const sessions: Sessions = {
803+
[fqdn]: {
804+
[userId]: {
805+
identity: {...validIdentityToken, alias: 'Work Account', email: 'work@example.com'},
806+
applications: appTokens,
807+
},
808+
},
809+
}
810+
vi.mocked(fetchSessions).mockResolvedValue(sessions)
811+
vi.mocked(validateSession).mockResolvedValueOnce('needs_refresh')
812+
813+
await ensureAuthenticated(defaultApplications)
814+
815+
const stored = vi.mocked(storeSessions).mock.calls[0]![0]
816+
expect(stored[fqdn]![userId]!.identity).toMatchObject({alias: 'Work Account', email: 'work@example.com'})
817+
expect(businessPlatformRequest).not.toHaveBeenCalled()
818+
})
819+
820+
test('does not copy a cached email when reauthentication selects a different user', async () => {
821+
vi.mocked(fetchSessions).mockResolvedValue({
822+
[fqdn]: {
823+
[userId]: {
824+
identity: {...validIdentityToken, alias: 'Work Account', email: 'previous@example.com'},
825+
applications: appTokens,
826+
},
827+
},
828+
})
829+
vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth')
830+
vi.mocked(pollForDeviceAuthorization).mockResolvedValue({...validIdentityToken, userId: 'different-user'})
831+
832+
await ensureAuthenticated(defaultApplications)
833+
834+
const stored = vi.mocked(storeSessions).mock.calls[0]![0]
835+
expect(stored[fqdn]!['different-user']!.identity.alias).toBe('Work Account')
836+
expect(stored[fqdn]!['different-user']!.identity.email).toBeUndefined()
837+
expect(businessPlatformRequest).not.toHaveBeenCalled()
838+
})
839+
})

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

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -237,15 +237,15 @@ ${outputToken.json(applications)}
237237
if (validationResult === 'needs_full_auth') {
238238
await throwOnNoPrompt(noPrompt)
239239
outputDebug(outputContent`Initiating the full authentication flow...`)
240-
newSession = await executeCompleteFlow(applications, currentSession?.identity.alias)
240+
newSession = await executeCompleteFlow(applications, currentSession?.identity)
241241
} else if (validationResult === 'needs_refresh' || forceRefresh) {
242242
outputDebug(outputContent`The current session is valid but needs refresh. Refreshing...`)
243243
try {
244244
newSession = await refreshTokens(currentSession!, applications)
245245
} catch (error) {
246246
if (error instanceof InvalidGrantError) {
247247
await throwOnNoPrompt(noPrompt)
248-
newSession = await executeCompleteFlow(applications, currentSession?.identity.alias)
248+
newSession = await executeCompleteFlow(applications, currentSession?.identity)
249249
} else if (error instanceof InvalidRequestError) {
250250
await sessionStore.remove()
251251
throw new AbortError('\nError validating auth session', "We've cleared the current session, please try again")
@@ -295,9 +295,12 @@ The CLI is currently unable to prompt for reauthentication.`,
295295
* Execute the full authentication flow.
296296
*
297297
* @param applications - An object containing the applications we need to be authenticated with.
298-
* @param existingAlias - Optional alias from a previous session to preserve if the email fetch fails.
298+
* @param existingIdentity - Optional identity from the previous session.
299299
*/
300-
async function executeCompleteFlow(applications: OAuthApplications, existingAlias?: string): Promise<Session> {
300+
async function executeCompleteFlow(
301+
applications: OAuthApplications,
302+
existingIdentity?: IdentityToken,
303+
): Promise<Session> {
301304
const scopes = getFlattenScopes(applications)
302305
const exchangeScopes = getExchangeScopes(applications)
303306
const store = applications.adminApi?.storeFqdn
@@ -320,14 +323,19 @@ async function executeCompleteFlow(applications: OAuthApplications, existingAlia
320323
outputDebug(outputContent`CLI token received. Exchanging it for application tokens...`)
321324
const result = await exchangeAccessForApplicationTokens(identityToken, exchangeScopes, store)
322325

323-
// Preserve existing alias if available, otherwise try fetching email
326+
// Preserve existing alias if available, otherwise try fetching email.
324327
const businessPlatformToken = result[applicationId('business-platform')]?.accessToken
325-
const alias = existingAlias ?? (await fetchEmail(businessPlatformToken)) ?? identityToken.userId
328+
const existingAlias = existingIdentity?.alias
329+
// A cached email belongs to its user ID, even when reauthentication selects another account.
330+
const existingEmail = existingIdentity?.userId === identityToken.userId ? existingIdentity.email : undefined
331+
const email = existingAlias === undefined ? await fetchEmail(businessPlatformToken) : existingEmail
332+
const alias = existingAlias ?? email ?? identityToken.userId
326333

327334
const session: Session = {
328335
identity: {
329336
...identityToken,
330337
alias,
338+
email,
331339
},
332340
applications: result,
333341
}
@@ -354,7 +362,11 @@ async function refreshTokens(session: Session, applications: OAuthApplications):
354362
)
355363

356364
return {
357-
identity: {...identityToken, alias: session.identity.alias},
365+
identity: {
366+
...identityToken,
367+
alias: session.identity.alias,
368+
email: identityToken.userId === session.identity.userId ? session.identity.email : undefined,
369+
},
358370
applications: applicationTokens,
359371
}
360372
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {fetch, store, getSessionAccount, findSessionAccountByAlias, setSessionAlias} from './store.js'
2+
import * as confStore from '../conf-store.js'
3+
import * as fqdn from '../../../public/node/context/fqdn.js'
4+
import {LocalStorage} from '../../../public/node/local-storage.js'
5+
import {inTemporaryDirectory} from '../../../public/node/fs.js'
6+
import {expect, test, vi} from 'vitest'
7+
import type {Sessions} from './schema.js'
8+
9+
const {getSessions, setSessions} = confStore
10+
11+
test.each([undefined, 'verified@example.com'])(
12+
'preserves stored account email %s through file reload and alias changes',
13+
async (email) => {
14+
await inTemporaryDirectory(async (directory) => {
15+
const storage = new LocalStorage<confStore.ConfSchema>({cwd: directory})
16+
vi.spyOn(confStore, 'getSessions').mockImplementation(() => getSessions(storage))
17+
vi.spyOn(confStore, 'setSessions').mockImplementation((sessions) => setSessions(sessions, storage))
18+
vi.spyOn(fqdn, 'identityFqdn').mockResolvedValue('accounts.example.com')
19+
const sessions: Sessions = {
20+
'accounts.example.com': {
21+
'user-123': {
22+
identity: {
23+
userId: 'user-123',
24+
alias: 'nickname@example.com',
25+
email,
26+
accessToken: 'access-token',
27+
refreshToken: 'refresh-token',
28+
expiresAt: new Date('2030-01-01T00:00:00Z'),
29+
scopes: [],
30+
},
31+
applications: {},
32+
},
33+
},
34+
}
35+
36+
await store(sessions)
37+
38+
const reloadedStorage = new LocalStorage<confStore.ConfSchema>({cwd: directory})
39+
const reloaded = JSON.parse(getSessions(reloadedStorage)!)
40+
expect(reloaded['accounts.example.com']['user-123'].identity.email).toBe(email)
41+
expect((await fetch())!['accounts.example.com']!['user-123']!.identity.email).toBe(email)
42+
await expect(getSessionAccount('user-123')).resolves.toEqual({
43+
userId: 'user-123',
44+
alias: 'nickname@example.com',
45+
email,
46+
})
47+
48+
await setSessionAlias('user-123', 'New label')
49+
50+
await expect(findSessionAccountByAlias('New label')).resolves.toEqual({
51+
userId: 'user-123',
52+
alias: 'New label',
53+
email,
54+
})
55+
expect(JSON.parse(getSessions(reloadedStorage)!)['accounts.example.com']['user-123'].identity.email).toBe(email)
56+
})
57+
},
58+
)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const IdentityTokenSchema = zod.object({
1515
scopes: zod.array(zod.string()),
1616
userId: zod.string(),
1717
alias: zod.string().optional(),
18+
email: zod.string().optional(),
1819
})
1920

2021
/**

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

Lines changed: 7 additions & 7 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, setSessionAlias, findSessionByAlias} from './store.js'
2+
import {store, fetch, remove, getSessionAccount, 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

@@ -129,24 +129,24 @@ describe('session store', () => {
129129
})
130130
})
131131

132-
describe('getSessionAlias', () => {
132+
describe('getSessionAccount', () => {
133133
test('returns alias for existing user', async () => {
134134
// Given
135135
vi.mocked(getSessions).mockReturnValue(JSON.stringify(mockSessions))
136136

137137
// When
138-
const result = await getSessionAlias('user1')
138+
const result = await getSessionAccount('user1')
139139

140140
// Then
141-
expect(result).toBe('Work Account')
141+
expect(result).toEqual({userId: 'user1', alias: 'Work Account', email: undefined})
142142
})
143143

144144
test('returns undefined for non-existent user', async () => {
145145
// Given
146146
vi.mocked(getSessions).mockReturnValue(JSON.stringify(mockSessions))
147147

148148
// When
149-
const result = await getSessionAlias('nonexistent')
149+
const result = await getSessionAccount('nonexistent')
150150

151151
// Then
152152
expect(result).toBeUndefined()
@@ -157,7 +157,7 @@ describe('session store', () => {
157157
vi.mocked(getSessions).mockReturnValue(undefined)
158158

159159
// When
160-
const result = await getSessionAlias('user1')
160+
const result = await getSessionAccount('user1')
161161

162162
// Then
163163
expect(result).toBeUndefined()
@@ -169,7 +169,7 @@ describe('session store', () => {
169169
vi.mocked(identityFqdn).mockResolvedValue('different.fqdn.com')
170170

171171
// When
172-
const result = await getSessionAlias('user1')
172+
const result = await getSessionAccount('user1')
173173

174174
// Then
175175
expect(result).toBeUndefined()

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

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
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 {Session, Sessions} from './schema.js'
4+
import type {IdentityToken, Session, Sessions} from './schema.js'
5+
6+
type SessionAccount = Pick<IdentityToken, 'userId' | 'alias' | 'email'>
57

68
/**
79
* Serializes the session as a JSON and stores it in the system.
@@ -51,18 +53,19 @@ export async function remove() {
5153
}
5254

5355
/**
54-
* Gets the session alias for a given user ID.
56+
* Gets public account details for a stored user ID.
5557
*
56-
* @param userId - The user ID of the session to get the alias for.
57-
* @returns The alias for the session if it exists, otherwise undefined.
58+
* @param userId - The stored user ID whose account details are requested.
59+
* @returns The account details if the session exists, otherwise undefined.
5860
*/
59-
export async function getSessionAlias(userId: string): Promise<string | undefined> {
61+
export async function getSessionAccount(userId: string): Promise<SessionAccount | undefined> {
6062
const sessions = await fetch()
6163
if (!sessions) return undefined
6264

6365
const fqdn = await identityFqdn()
64-
if (!sessions[fqdn] || !sessions[fqdn][userId]) return undefined
65-
return sessions[fqdn][userId].identity.alias
66+
const session = sessions[fqdn]?.[userId]
67+
if (!session) return undefined
68+
return {userId, alias: session.identity.alias, email: session.identity.email}
6669
}
6770

6871
/**
@@ -90,6 +93,10 @@ export async function setSessionAlias(userId: string, alias: string): Promise<vo
9093
* @returns The user ID if found, otherwise undefined
9194
*/
9295
export async function findSessionByAlias(alias: string): Promise<string | undefined> {
96+
return (await findSessionAccountByAlias(alias))?.userId
97+
}
98+
99+
export async function findSessionAccountByAlias(alias: string): Promise<SessionAccount | undefined> {
93100
const sessions = await fetch()
94101
if (!sessions) return undefined
95102

@@ -99,7 +106,7 @@ export async function findSessionByAlias(alias: string): Promise<string | undefi
99106

100107
for (const [userId, session] of Object.entries(fqdnSessions)) {
101108
if (session.identity.alias === alias || userId === alias) {
102-
return userId
109+
return {userId, alias: session.identity.alias, email: session.identity.email}
103110
}
104111
}
105112

0 commit comments

Comments
 (0)