Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 37 additions & 23 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export { createDcrProvider } from './providers/dcr.js'
export type {
DcrClientMetadata,
DcrProviderOptions,
DcrRegisteredClient,
Comment thread
scottlovegrove marked this conversation as resolved.
DcrTokenEndpointAuthMethod,
} from './providers/dcr.js'
export type {
Expand Down
214 changes: 214 additions & 0 deletions src/auth/providers/dcr.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'

import { createDcrProvider } from './dcr.js'
import type { DcrRegisteredClient } from './dcr.js'

type Account = { id: string; label?: string }

Expand Down Expand Up @@ -390,4 +391,217 @@ describe('createDcrProvider', () => {
provider.prepare!({ redirectUri: REDIRECT_URI, flags: {} }),
).rejects.toMatchObject({ code: 'AUTH_DCR_FAILED' })
})

it('threads the RFC 8707 resource indicator into the authorize URL and the token request body', async () => {
const { calls, fetchImpl } = makeFetchRecorder((u) =>
u === REGISTRATION_URL
? registration({ client_id: 'pub' })
: token({ access_token: 'tok', expires_in: 3600, scope: 'user:read' }),
)
const provider = createDcrProvider<Account>({
registrationUrl: REGISTRATION_URL,
authorizeUrl: AUTHORIZE_URL,
tokenUrl: TOKEN_URL,
resource: 'https://api.example.com',
clientMetadata: { clientName: 'CLI', tokenEndpointAuthMethod: 'none' },
validate,
fetchImpl,
})

const prepared = await provider.prepare!({ redirectUri: REDIRECT_URI, flags: {} })
const authorize = await provider.authorize({
redirectUri: REDIRECT_URI,
state: 'state-123',
scopes: ['user:read'],
readOnly: false,
flags: {},
handshake: prepared.handshake,
})
expect(new URL(authorize.authorizeUrl).searchParams.get('resource')).toBe(
'https://api.example.com',
)

const result = await provider.exchangeCode({
code: 'auth-code',
state: 'state-123',
redirectUri: REDIRECT_URI,
handshake: authorize.handshake,
})
// The server-granted scope is surfaced for validateToken to record.
expect(result.scope).toBe('user:read')
const tokenBody = bodyOf(calls.find((c) => c.url === TOKEN_URL)!)
expect(tokenBody.get('resource')).toBe('https://api.example.com')
})

it('refreshToken runs the refresh_token grant, forwarding the resource indicator', async () => {
const { calls, fetchImpl } = makeFetchRecorder(() =>
token({
access_token: 'tok-2',
refresh_token: 'rt-2',
expires_in: 3600,
scope: 'user:read',
}),
)
const provider = createDcrProvider<Account>({
registrationUrl: REGISTRATION_URL,
authorizeUrl: AUTHORIZE_URL,
tokenUrl: TOKEN_URL,
resource: 'https://api.example.com',
clientMetadata: { clientName: 'CLI', tokenEndpointAuthMethod: 'none' },
validate,
fetchImpl,
})

const result = await provider.refreshToken!({
refreshToken: 'rt-1',
handshake: { clientId: 'pub' },
})
expect(result.accessToken).toBe('tok-2')
expect(result.refreshToken).toBe('rt-2')
expect(result.expiresAt).toBeGreaterThan(Date.now())
expect(result.scope).toBe('user:read')

const tokenBody = bodyOf(calls.find((c) => c.url === TOKEN_URL)!)
expect(tokenBody.get('grant_type')).toBe('refresh_token')
expect(tokenBody.get('refresh_token')).toBe('rt-1')
expect(tokenBody.get('resource')).toBe('https://api.example.com')
})

it('refreshToken honours the handshake auth method over the configured one', async () => {
const { calls, fetchImpl } = makeFetchRecorder(() => token({ access_token: 'tok' }))
// Configured: client_secret_basic. Handshake (server-issued at
// registration): client_secret_post. The refresh must follow the
// handshake — so the secret goes in the body and no Basic header is
// sent. Differing the two values is what makes this assert precedence.
const provider = createDcrProvider<Account>({
registrationUrl: REGISTRATION_URL,
authorizeUrl: AUTHORIZE_URL,
tokenUrl: TOKEN_URL,
clientMetadata: { clientName: 'CLI', tokenEndpointAuthMethod: 'client_secret_basic' },
validate,
fetchImpl,
})

await provider.refreshToken!({
refreshToken: 'rt',
handshake: {
clientId: 'cid',
clientSecret: 'sec',
tokenEndpointAuthMethod: 'client_secret_post',
},
})
const tokenCall = calls.find((c) => c.url === TOKEN_URL)!
const tokenBody = bodyOf(tokenCall)
expect(headersOf(tokenCall).has('authorization')).toBe(false)
expect(tokenBody.get('client_id')).toBe('cid')
expect(tokenBody.get('client_secret')).toBe('sec')
})

it('maps an invalid_grant refresh rejection to AUTH_REFRESH_EXPIRED', async () => {
const provider = createDcrProvider<Account>({
registrationUrl: REGISTRATION_URL,
authorizeUrl: AUTHORIZE_URL,
tokenUrl: TOKEN_URL,
clientMetadata: { clientName: 'CLI', tokenEndpointAuthMethod: 'none' },
validate,
fetchImpl: (() =>
Promise.resolve(respond({ error: 'invalid_grant' }, 400))) as typeof fetch,
})
await expect(
provider.refreshToken!({ refreshToken: 'rt', handshake: { clientId: 'pub' } }),
).rejects.toMatchObject({ code: 'AUTH_REFRESH_EXPIRED' })
})

it('refreshToken without a clientId in the handshake is AUTH_REFRESH_UNAVAILABLE', async () => {
const provider = createDcrProvider<Account>({
registrationUrl: REGISTRATION_URL,
authorizeUrl: AUTHORIZE_URL,
tokenUrl: TOKEN_URL,
clientMetadata: { clientName: 'CLI' },
validate,
fetchImpl: (() => Promise.resolve(token({ access_token: 'x' }))) as typeof fetch,
})
await expect(
provider.refreshToken!({ refreshToken: 'rt', handshake: {} }),
).rejects.toMatchObject({ code: 'AUTH_REFRESH_UNAVAILABLE' })
})

it('reuses a cached client via an async loadClient without a registration POST', async () => {
const { calls, fetchImpl } = makeFetchRecorder(() => {
throw new Error('registration must not be called on a cache hit')
})
const provider = createDcrProvider<Account>({
registrationUrl: REGISTRATION_URL,
authorizeUrl: AUTHORIZE_URL,
tokenUrl: TOKEN_URL,
clientMetadata: { clientName: 'CLI' },
// Async hook: a dropped `await` in the provider would surface a
// Promise here instead of the resolved client and fail the assert.
loadClient: (input) =>
Promise.resolve(
input.redirectUri === REDIRECT_URI
? { clientId: 'cached', tokenEndpointAuthMethod: 'none' }
: null,
),
validate,
fetchImpl,
})

const prepared = await provider.prepare!({ redirectUri: REDIRECT_URI, flags: {} })
expect(prepared.handshake).toEqual({
clientId: 'cached',
tokenEndpointAuthMethod: 'none',
})
expect(calls).toHaveLength(0)
})

it('registers and persists a fresh client via an async saveClient on a cache miss', async () => {
const saved: DcrRegisteredClient[] = []
const { calls, fetchImpl } = makeFetchRecorder(() =>
registration({ client_id: 'fresh', client_secret: 'sec' }),
)
const provider = createDcrProvider<Account>({
registrationUrl: REGISTRATION_URL,
authorizeUrl: AUTHORIZE_URL,
tokenUrl: TOKEN_URL,
clientMetadata: { clientName: 'CLI' },
loadClient: () => Promise.resolve(null),
saveClient: async (client) => {
await Promise.resolve()
saved.push(client)
},
validate,
fetchImpl,
})

const prepared = await provider.prepare!({ redirectUri: REDIRECT_URI, flags: {} })
expect(calls).toHaveLength(1)
expect(prepared.handshake).toEqual({ clientId: 'fresh', clientSecret: 'sec' })
expect(saved).toEqual([{ clientId: 'fresh', clientSecret: 'sec' }])
})

it('rejects a malformed cached client from loadClient', async () => {
const make = (cached: unknown) =>
createDcrProvider<Account>({
registrationUrl: REGISTRATION_URL,
authorizeUrl: AUTHORIZE_URL,
tokenUrl: TOKEN_URL,
clientMetadata: { clientName: 'CLI' },
loadClient: () => cached as DcrRegisteredClient,
validate,
fetchImpl: (() => {
throw new Error('registration must not be reached')
}) as typeof fetch,
})

await expect(
make({ clientId: 42 }).prepare!({ redirectUri: REDIRECT_URI, flags: {} }),
).rejects.toMatchObject({ code: 'AUTH_DCR_FAILED' })
await expect(
make({ clientId: 'cid', tokenEndpointAuthMethod: 'private_key_jwt' }).prepare!({
redirectUri: REDIRECT_URI,
flags: {},
}),
).rejects.toMatchObject({ code: 'AUTH_DCR_FAILED' })
})
})
Loading
Loading