Skip to content

Commit 29bea6b

Browse files
committed
fix(auth): reuse existing login sessions
1 parent 9c4d1c6 commit 29bea6b

3 files changed

Lines changed: 99 additions & 1 deletion

File tree

src/commands/login.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
* - Otherwise → PKCE loopback.
88
*/
99

10+
import type { StoredSession } from '../auth/store.ts'
1011
import { styleText } from 'node:util'
1112
import * as p from '@clack/prompts'
1213
import { defineCommand } from 'citty'
1314
import { runDeviceFlow } from '../auth/device-flow.ts'
1415
import { isGhaOidcAvailable, runOidcExchange } from '../auth/oidc.ts'
1516
import { runPkceFlow } from '../auth/pkce-flow.ts'
16-
import { saveSession } from '../auth/store.ts'
17+
import { loadSession, saveSession } from '../auth/store.ts'
1718
import { getRegistryBase } from '../registry/client.ts'
1819
import { track } from '../telemetry.ts'
1920
import { version } from '../version.ts'
@@ -28,12 +29,33 @@ function shouldUseDevice(force: boolean): boolean {
2829
return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY
2930
}
3031

32+
export type LoginRequirement
33+
= | { _tag: 'Reuse', session: StoredSession }
34+
| { _tag: 'Authenticate' }
35+
36+
export function resolveLoginRequirement(
37+
session: StoredSession | null,
38+
nowSeconds = Math.floor(Date.now() / 1000),
39+
): LoginRequirement {
40+
if (!session)
41+
return { _tag: 'Authenticate' }
42+
if (session.refreshToken || session.expiresAt === undefined || session.expiresAt > nowSeconds)
43+
return { _tag: 'Reuse', session }
44+
return { _tag: 'Authenticate' }
45+
}
46+
3147
export const loginCommandDef = defineCommand({
3248
meta: { name: 'login', description: 'Authenticate with skilld.dev' },
3349
args: {
3450
device: { type: 'boolean', description: 'Use RFC 8628 device flow' },
3551
},
3652
async run({ args }) {
53+
const requirement = resolveLoginRequirement(await loadSession())
54+
if (requirement._tag === 'Reuse') {
55+
p.log.success(`Already logged in as ${styleText('cyan', `@${requirement.session.login}`)}`)
56+
return
57+
}
58+
3759
const registryBase = getRegistryBase()
3860

3961
if (isGhaOidcAvailable()) {

test/unit/login-command.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
const mocks = vi.hoisted(() => ({
4+
loadSession: vi.fn(),
5+
saveSession: vi.fn(),
6+
runDeviceFlow: vi.fn(),
7+
runOidcExchange: vi.fn(),
8+
runPkceFlow: vi.fn(),
9+
success: vi.fn(),
10+
}))
11+
12+
vi.mock('../../src/auth/store', () => ({
13+
loadSession: mocks.loadSession,
14+
saveSession: mocks.saveSession,
15+
}))
16+
vi.mock('../../src/auth/device-flow', () => ({ runDeviceFlow: mocks.runDeviceFlow }))
17+
vi.mock('../../src/auth/oidc', () => ({
18+
isGhaOidcAvailable: () => false,
19+
runOidcExchange: mocks.runOidcExchange,
20+
}))
21+
vi.mock('../../src/auth/pkce-flow', () => ({ runPkceFlow: mocks.runPkceFlow }))
22+
vi.mock('@clack/prompts', () => ({
23+
log: { success: mocks.success },
24+
}))
25+
26+
const { loginCommandDef } = await import('../../src/commands/login')
27+
28+
describe('login command', () => {
29+
beforeEach(() => vi.clearAllMocks())
30+
31+
it('does not start authentication when the existing session can refresh', async () => {
32+
mocks.loadSession.mockResolvedValue({
33+
scheme: 'file',
34+
login: 'harlan-zw',
35+
accessToken: 'expired',
36+
refreshToken: 'refresh',
37+
expiresAt: 100,
38+
host: 'https://skilld.dev',
39+
})
40+
41+
await loginCommandDef.run!({ args: { device: false } } as any)
42+
43+
expect(mocks.runOidcExchange).not.toHaveBeenCalled()
44+
expect(mocks.runDeviceFlow).not.toHaveBeenCalled()
45+
expect(mocks.runPkceFlow).not.toHaveBeenCalled()
46+
expect(mocks.success).toHaveBeenCalledWith('Already logged in as @harlan-zw')
47+
})
48+
})

test/unit/login.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { StoredSession } from '../../src/auth/store'
2+
import { describe, expect, it } from 'vitest'
3+
import { resolveLoginRequirement } from '../../src/commands/login'
4+
5+
const session: StoredSession = {
6+
scheme: 'file',
7+
login: 'harlan-zw',
8+
accessToken: 'access',
9+
refreshToken: 'refresh',
10+
expiresAt: 100,
11+
host: 'https://skilld.dev',
12+
}
13+
14+
describe('resolveLoginRequirement', () => {
15+
it('reuses a refreshable session after its access token expires', () => {
16+
expect(resolveLoginRequirement(session, 200)).toEqual({ _tag: 'Reuse', session })
17+
})
18+
19+
it('reuses an unexpired session without a refresh token', () => {
20+
const current = { ...session, refreshToken: undefined, expiresAt: 300 }
21+
expect(resolveLoginRequirement(current, 200)).toEqual({ _tag: 'Reuse', session: current })
22+
})
23+
24+
it('authenticates again when a nonrefreshable session expires', () => {
25+
const expired = { ...session, refreshToken: undefined }
26+
expect(resolveLoginRequirement(expired, 200)).toEqual({ _tag: 'Authenticate' })
27+
})
28+
})

0 commit comments

Comments
 (0)