mirror of
https://github.com/hanzoai/openid-client.git
synced 2026-09-24 17:01:10 +00:00
724 lines
17 KiB
TypeScript
724 lines
17 KiB
TypeScript
import test from 'ava'
|
|
import * as client from '../src/index.js'
|
|
import * as undici from 'undici'
|
|
|
|
const issuer = new URL('https://op.example.com')
|
|
|
|
async function setupMockAgent() {
|
|
const agent = new undici.MockAgent()
|
|
agent.disableNetConnect()
|
|
|
|
const mockAgent = agent.get(issuer.origin)
|
|
|
|
return { agent, mockAgent }
|
|
}
|
|
|
|
function createConfig(agent: undici.MockAgent) {
|
|
const config = new client.Configuration(
|
|
{
|
|
issuer: issuer.href,
|
|
token_endpoint: `${issuer.origin}/token`,
|
|
},
|
|
'client_id',
|
|
)
|
|
|
|
// @ts-ignore
|
|
config[client.customFetch] = (url, options) => {
|
|
return undici.fetch(url, { ...options, dispatcher: agent })
|
|
}
|
|
|
|
return config
|
|
}
|
|
|
|
function createSignalTrackingConfig() {
|
|
const seenSignals: AbortSignal[] = []
|
|
|
|
const config = new client.Configuration(
|
|
{
|
|
issuer: issuer.href,
|
|
token_endpoint: `${issuer.origin}/token`,
|
|
},
|
|
'client_id',
|
|
)
|
|
|
|
config[client.customFetch] = async (_url, options) => {
|
|
if (options.signal) {
|
|
seenSignals.push(options.signal)
|
|
}
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
access_token: 'access_token',
|
|
token_type: 'bearer',
|
|
}),
|
|
{
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
return { config, seenSignals }
|
|
}
|
|
|
|
// Tests for Device Authorization Grant polling with retry-after
|
|
|
|
test('pollBackchannelAuthenticationGrant - passes abort signal to token request', async (t) => {
|
|
const { config, seenSignals } = createSignalTrackingConfig()
|
|
const controller = new AbortController()
|
|
|
|
await client.pollBackchannelAuthenticationGrant(
|
|
config,
|
|
{
|
|
auth_req_id: 'req-id',
|
|
expires_in: 600,
|
|
interval: 0,
|
|
},
|
|
undefined,
|
|
{ signal: controller.signal },
|
|
)
|
|
|
|
const [signal] = seenSignals
|
|
t.truthy(signal)
|
|
controller.abort()
|
|
t.true(signal.aborted)
|
|
})
|
|
|
|
test('pollDeviceAuthorizationGrant - passes abort signal to token request', async (t) => {
|
|
const { config, seenSignals } = createSignalTrackingConfig()
|
|
const controller = new AbortController()
|
|
|
|
await client.pollDeviceAuthorizationGrant(
|
|
config,
|
|
{
|
|
device_code: 'device123',
|
|
user_code: 'user123',
|
|
verification_uri: `${issuer.origin}/device`,
|
|
expires_in: 600,
|
|
interval: 0,
|
|
},
|
|
undefined,
|
|
{ signal: controller.signal },
|
|
)
|
|
|
|
const [signal] = seenSignals
|
|
t.truthy(signal)
|
|
controller.abort()
|
|
t.true(signal.aborted)
|
|
})
|
|
|
|
test('pollBackchannelAuthenticationGrant - passes abort signal to retried token request', async (t) => {
|
|
const { config, seenSignals } = createSignalTrackingConfig()
|
|
const controller = new AbortController()
|
|
|
|
let attempt = 0
|
|
config[client.customFetch] = async (_url, options) => {
|
|
if (options.signal) {
|
|
seenSignals.push(options.signal)
|
|
}
|
|
|
|
if (++attempt === 1) {
|
|
return new Response('Service Unavailable', {
|
|
status: 503,
|
|
headers: {
|
|
'retry-after': '0',
|
|
},
|
|
})
|
|
}
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
access_token: 'access_token',
|
|
token_type: 'bearer',
|
|
}),
|
|
{
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
await client.pollBackchannelAuthenticationGrant(
|
|
config,
|
|
{
|
|
auth_req_id: 'req-id',
|
|
expires_in: 600,
|
|
interval: 0,
|
|
},
|
|
undefined,
|
|
{ signal: controller.signal },
|
|
)
|
|
|
|
t.is(seenSignals.length, 2)
|
|
controller.abort()
|
|
t.true(seenSignals[0]!.aborted)
|
|
t.true(seenSignals[1]!.aborted)
|
|
})
|
|
|
|
test('pollDeviceAuthorizationGrant - passes abort signal to retried token request', async (t) => {
|
|
const { config, seenSignals } = createSignalTrackingConfig()
|
|
const controller = new AbortController()
|
|
|
|
let attempt = 0
|
|
config[client.customFetch] = async (_url, options) => {
|
|
if (options.signal) {
|
|
seenSignals.push(options.signal)
|
|
}
|
|
|
|
if (++attempt === 1) {
|
|
return new Response('Service Unavailable', {
|
|
status: 503,
|
|
headers: {
|
|
'retry-after': '0',
|
|
},
|
|
})
|
|
}
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
access_token: 'access_token',
|
|
token_type: 'bearer',
|
|
}),
|
|
{
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
await client.pollDeviceAuthorizationGrant(
|
|
config,
|
|
{
|
|
device_code: 'device123',
|
|
user_code: 'user123',
|
|
verification_uri: `${issuer.origin}/device`,
|
|
expires_in: 600,
|
|
interval: 0,
|
|
},
|
|
undefined,
|
|
{ signal: controller.signal },
|
|
)
|
|
|
|
t.is(seenSignals.length, 2)
|
|
controller.abort()
|
|
t.true(seenSignals[0]!.aborted)
|
|
t.true(seenSignals[1]!.aborted)
|
|
})
|
|
|
|
test('pollBackchannelAuthenticationGrant - respects retry-after header with numeric seconds', async (t) => {
|
|
const { agent, mockAgent } = await setupMockAgent()
|
|
|
|
const startTime = Date.now()
|
|
|
|
// First poll - return 503 with retry-after
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(503, '503 Service Unavailable', {
|
|
headers: {
|
|
'retry-after': '2', // 2 seconds
|
|
},
|
|
})
|
|
|
|
// Second poll - return success
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(200, {
|
|
access_token: 'access_token',
|
|
token_type: 'bearer',
|
|
})
|
|
|
|
const config = createConfig(agent)
|
|
|
|
// Mock device authorization response to test polling directly
|
|
const response = {
|
|
auth_req_id: 'req-id',
|
|
expires_in: 600,
|
|
interval: 1,
|
|
}
|
|
|
|
const result = await client.pollBackchannelAuthenticationGrant(
|
|
config,
|
|
response,
|
|
)
|
|
|
|
const elapsed = Date.now() - startTime
|
|
// Should take ~3 seconds (1 second initial interval, 2 second retry-after the initial response)
|
|
t.true(
|
|
elapsed >= 3000 && elapsed <= 4000,
|
|
`expected ~3s wait, got ${elapsed}ms`,
|
|
)
|
|
t.is(result.access_token, 'access_token')
|
|
t.notThrows(() => agent.assertNoPendingInterceptors())
|
|
})
|
|
|
|
test('pollBackchannelAuthenticationGrant - respects retry-after header with date', async (t) => {
|
|
const { agent, mockAgent } = await setupMockAgent()
|
|
|
|
const futureDate = new Date(Date.now() + 3000) // 3 seconds from now, means 2 seconds from the initial poll
|
|
const startTime = Date.now()
|
|
|
|
// First poll - return 503 with retry-after date
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(503, '503 Service Unavailable', {
|
|
headers: {
|
|
'retry-after': futureDate.toUTCString(),
|
|
},
|
|
})
|
|
|
|
// Second poll - return success
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(200, {
|
|
access_token: 'access_token',
|
|
token_type: 'bearer',
|
|
})
|
|
|
|
const config = createConfig(agent)
|
|
|
|
const response = {
|
|
auth_req_id: 'req-id',
|
|
expires_in: 600,
|
|
interval: 1,
|
|
}
|
|
|
|
const result = await client.pollBackchannelAuthenticationGrant(
|
|
config,
|
|
response,
|
|
)
|
|
|
|
const elapsed = Date.now() - startTime
|
|
// Should take ~3 seconds (1 second initial interval, 2 second retry-after the initial response)
|
|
t.true(
|
|
elapsed >= 3000 && elapsed <= 5000,
|
|
`expected ~3s wait, got ${elapsed}ms`,
|
|
)
|
|
t.is(result.access_token, 'access_token')
|
|
t.notThrows(() => agent.assertNoPendingInterceptors())
|
|
})
|
|
|
|
test('pollBackchannelAuthenticationGrant - throws on invalid retry-after header when status is 503', async (t) => {
|
|
const { agent, mockAgent } = await setupMockAgent()
|
|
|
|
// Return 503 with invalid retry-after
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(503, '503 Service Unavailable', {
|
|
headers: {
|
|
'retry-after': 'invalid-value',
|
|
},
|
|
})
|
|
|
|
const config = createConfig(agent)
|
|
|
|
const response = {
|
|
auth_req_id: 'req-id',
|
|
expires_in: 600,
|
|
interval: 5,
|
|
}
|
|
|
|
const error = await t.throwsAsync(
|
|
client.pollBackchannelAuthenticationGrant(config, response),
|
|
)
|
|
|
|
t.true(error.message.includes('invalid Retry-After header value'))
|
|
t.notThrows(() => agent.assertNoPendingInterceptors())
|
|
})
|
|
|
|
test('pollBackchannelAuthenticationGrant - respects retry-after on authorization_pending error', async (t) => {
|
|
const { agent, mockAgent } = await setupMockAgent()
|
|
|
|
const startTime = Date.now()
|
|
|
|
// First poll - return authorization_pending with retry-after
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(
|
|
400,
|
|
{
|
|
error: 'authorization_pending',
|
|
},
|
|
{
|
|
headers: {
|
|
'retry-after': '2',
|
|
'content-type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
|
|
// Second poll - return success
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(200, {
|
|
access_token: 'access_token',
|
|
token_type: 'bearer',
|
|
})
|
|
|
|
const config = createConfig(agent)
|
|
|
|
const response = {
|
|
auth_req_id: 'req-id',
|
|
expires_in: 600,
|
|
interval: 1,
|
|
}
|
|
|
|
const result = await client.pollBackchannelAuthenticationGrant(
|
|
config,
|
|
response,
|
|
)
|
|
|
|
const elapsed = Date.now() - startTime
|
|
// Should take ~3 seconds (1 second initial interval, 2 second retry-after the initial response)
|
|
t.true(
|
|
elapsed >= 3000 && elapsed <= 4000,
|
|
`expected ~3s wait, got ${elapsed}ms`,
|
|
)
|
|
t.is(result.access_token, 'access_token')
|
|
t.notThrows(() => agent.assertNoPendingInterceptors())
|
|
})
|
|
|
|
test('pollBackchannelAuthenticationGrant - ignores invalid retry-after on authorization_pending error', async (t) => {
|
|
const { agent, mockAgent } = await setupMockAgent()
|
|
|
|
const startTime = Date.now()
|
|
|
|
// First poll - return authorization_pending with invalid retry-after (should be ignored)
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(
|
|
400,
|
|
{
|
|
error: 'authorization_pending',
|
|
},
|
|
{
|
|
headers: {
|
|
'retry-after': 'invalid',
|
|
'content-type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
|
|
// Second poll - return success
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(200, {
|
|
access_token: 'access_token',
|
|
token_type: 'bearer',
|
|
})
|
|
|
|
const config = createConfig(agent)
|
|
|
|
const response = {
|
|
auth_req_id: 'req-id',
|
|
expires_in: 600,
|
|
interval: 1,
|
|
}
|
|
|
|
const result = await client.pollBackchannelAuthenticationGrant(
|
|
config,
|
|
response,
|
|
)
|
|
|
|
const elapsed = Date.now() - startTime
|
|
// Should only wait for the normal intervals (twice 1 second)
|
|
t.true(
|
|
elapsed >= 2000 && elapsed <= 3000,
|
|
`expected ~2s wait, got ${elapsed}ms`,
|
|
)
|
|
t.is(result.access_token, 'access_token')
|
|
t.notThrows(() => agent.assertNoPendingInterceptors())
|
|
})
|
|
|
|
// Tests for Device Authorization Grant polling with retry-after
|
|
|
|
test('pollDeviceAuthorizationGrant - respects retry-after header with numeric seconds', async (t) => {
|
|
const { agent, mockAgent } = await setupMockAgent()
|
|
|
|
const startTime = Date.now()
|
|
|
|
// First poll - return 503 with retry-after
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(503, '503 Service Unavailable', {
|
|
headers: {
|
|
'retry-after': '2', // 2 seconds
|
|
},
|
|
})
|
|
|
|
// Second poll - return success
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(200, {
|
|
access_token: 'access_token',
|
|
token_type: 'bearer',
|
|
})
|
|
|
|
const config = createConfig(agent)
|
|
|
|
// Mock device authorization response to test polling directly
|
|
const deviceAuthorizationResponse = {
|
|
device_code: 'device123',
|
|
user_code: 'user123',
|
|
verification_uri: 'https://op.example.com/device',
|
|
expires_in: 600,
|
|
interval: 1,
|
|
}
|
|
|
|
const result = await client.pollDeviceAuthorizationGrant(
|
|
config,
|
|
deviceAuthorizationResponse,
|
|
)
|
|
|
|
const elapsed = Date.now() - startTime
|
|
// Should take ~3 seconds (1 second initial interval, 2 second retry-after the initial response)
|
|
t.true(
|
|
elapsed >= 3000 && elapsed <= 4000,
|
|
`expected ~3s wait, got ${elapsed}ms`,
|
|
)
|
|
t.is(result.access_token, 'access_token')
|
|
t.notThrows(() => agent.assertNoPendingInterceptors())
|
|
})
|
|
|
|
test('pollDeviceAuthorizationGrant - respects retry-after header with date', async (t) => {
|
|
const { agent, mockAgent } = await setupMockAgent()
|
|
|
|
const futureDate = new Date(Date.now() + 3000) // 3 seconds from now, means 2 seconds from the initial poll
|
|
const startTime = Date.now()
|
|
|
|
// First poll - return 503 with retry-after date
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(503, '503 Service Unavailable', {
|
|
headers: {
|
|
'retry-after': futureDate.toUTCString(),
|
|
},
|
|
})
|
|
|
|
// Second poll - return success
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(200, {
|
|
access_token: 'access_token',
|
|
token_type: 'bearer',
|
|
})
|
|
|
|
const config = createConfig(agent)
|
|
|
|
const deviceAuthorizationResponse = {
|
|
device_code: 'device123',
|
|
user_code: 'user123',
|
|
verification_uri: 'https://op.example.com/device',
|
|
expires_in: 600,
|
|
interval: 1,
|
|
}
|
|
|
|
const result = await client.pollDeviceAuthorizationGrant(
|
|
config,
|
|
deviceAuthorizationResponse,
|
|
)
|
|
|
|
const elapsed = Date.now() - startTime
|
|
// Should take ~3 seconds (1 second initial interval, 2 second retry-after the initial response)
|
|
t.true(
|
|
elapsed >= 3000 && elapsed <= 4000,
|
|
`expected ~3s wait, got ${elapsed}ms`,
|
|
)
|
|
t.is(result.access_token, 'access_token')
|
|
t.notThrows(() => agent.assertNoPendingInterceptors())
|
|
})
|
|
|
|
test('pollDeviceAuthorizationGrant - throws on invalid retry-after header when status is 503', async (t) => {
|
|
const { agent, mockAgent } = await setupMockAgent()
|
|
|
|
// Return 503 with invalid retry-after
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(503, '503 Service Unavailable', {
|
|
headers: {
|
|
'retry-after': 'invalid-value',
|
|
},
|
|
})
|
|
|
|
const config = createConfig(agent)
|
|
|
|
const deviceAuthorizationResponse = {
|
|
device_code: 'device123',
|
|
user_code: 'user123',
|
|
verification_uri: 'https://op.example.com/device',
|
|
expires_in: 600,
|
|
interval: 5,
|
|
}
|
|
|
|
const error = await t.throwsAsync(
|
|
client.pollDeviceAuthorizationGrant(config, deviceAuthorizationResponse),
|
|
)
|
|
|
|
t.true(error.message.includes('invalid Retry-After header value'))
|
|
t.notThrows(() => agent.assertNoPendingInterceptors())
|
|
})
|
|
|
|
test('pollDeviceAuthorizationGrant - respects retry-after on authorization_pending error', async (t) => {
|
|
const { agent, mockAgent } = await setupMockAgent()
|
|
|
|
const startTime = Date.now()
|
|
|
|
// First poll - return authorization_pending with retry-after
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(
|
|
400,
|
|
{
|
|
error: 'authorization_pending',
|
|
},
|
|
{
|
|
headers: {
|
|
'retry-after': '2',
|
|
'content-type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
|
|
// Second poll - return success
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(200, {
|
|
access_token: 'access_token',
|
|
token_type: 'bearer',
|
|
})
|
|
|
|
const config = createConfig(agent)
|
|
|
|
const deviceAuthorizationResponse = {
|
|
device_code: 'device123',
|
|
user_code: 'user123',
|
|
verification_uri: 'https://op.example.com/device',
|
|
expires_in: 600,
|
|
interval: 1,
|
|
}
|
|
|
|
const result = await client.pollDeviceAuthorizationGrant(
|
|
config,
|
|
deviceAuthorizationResponse,
|
|
)
|
|
|
|
const elapsed = Date.now() - startTime
|
|
// Should take ~3 seconds (1 second initial interval, 2 second retry-after the initial response)
|
|
t.true(
|
|
elapsed >= 3000 && elapsed <= 4000,
|
|
`expected ~3s wait, got ${elapsed}ms`,
|
|
)
|
|
t.is(result.access_token, 'access_token')
|
|
t.notThrows(() => agent.assertNoPendingInterceptors())
|
|
})
|
|
|
|
test('pollDeviceAuthorizationGrant - ignores invalid retry-after on authorization_pending error', async (t) => {
|
|
const { agent, mockAgent } = await setupMockAgent()
|
|
|
|
const startTime = Date.now()
|
|
|
|
// First poll - return authorization_pending with invalid retry-after (should be ignored)
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(
|
|
400,
|
|
{
|
|
error: 'authorization_pending',
|
|
},
|
|
{
|
|
headers: {
|
|
'retry-after': 'invalid',
|
|
'content-type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
|
|
// Second poll - return success
|
|
mockAgent
|
|
.intercept({
|
|
method: 'POST',
|
|
path: '/token',
|
|
})
|
|
.reply(200, {
|
|
access_token: 'access_token',
|
|
token_type: 'bearer',
|
|
})
|
|
|
|
const config = createConfig(agent)
|
|
|
|
const deviceAuthorizationResponse = {
|
|
device_code: 'device123',
|
|
user_code: 'user123',
|
|
verification_uri: 'https://op.example.com/device',
|
|
expires_in: 600,
|
|
interval: 1,
|
|
}
|
|
|
|
const result = await client.pollDeviceAuthorizationGrant(
|
|
config,
|
|
deviceAuthorizationResponse,
|
|
)
|
|
|
|
const elapsed = Date.now() - startTime
|
|
// Should only wait for the normal intervals (twice 1 second)
|
|
t.true(
|
|
elapsed >= 2000 && elapsed <= 3000,
|
|
`expected ~2s wait, got ${elapsed}ms`,
|
|
)
|
|
t.is(result.access_token, 'access_token')
|
|
t.notThrows(() => agent.assertNoPendingInterceptors())
|
|
})
|