mirror of
https://github.com/hanzoai/openid-client.git
synced 2026-09-24 17:01:10 +00:00
1442 lines
41 KiB
TypeScript
1442 lines
41 KiB
TypeScript
import http from 'node:http'
|
|
import type { AddressInfo } from 'node:net'
|
|
import { Readable } from 'node:stream'
|
|
import * as crypto from 'node:crypto'
|
|
|
|
import test from 'ava'
|
|
import type passport from 'passport'
|
|
import * as client from '../src/index.js'
|
|
import { Strategy } from '../src/passport.js'
|
|
import type { AuthenticateOptions, VerifyFunction } from '../src/passport.js'
|
|
|
|
function close(server: http.Server): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
server.close((err) => {
|
|
if (err) {
|
|
reject(err)
|
|
return
|
|
}
|
|
resolve()
|
|
})
|
|
})
|
|
}
|
|
|
|
async function startTokenEndpoint(
|
|
tokenResponse?: () => {
|
|
status: number
|
|
body: unknown
|
|
headers?: Record<string, string>
|
|
},
|
|
options?: { handlePAR?: boolean },
|
|
) {
|
|
const requests: string[] = []
|
|
const parRequests: string[] = []
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
|
|
if (req.method === 'POST' && url.pathname === '/token') {
|
|
let body = ''
|
|
for await (const chunk of req) {
|
|
body += chunk
|
|
}
|
|
|
|
requests.push(body)
|
|
|
|
const response = tokenResponse?.() ?? {
|
|
status: 200,
|
|
body: { access_token: 'ok', token_type: 'bearer' },
|
|
}
|
|
|
|
res.writeHead(response.status, {
|
|
'content-type': 'application/json',
|
|
...response.headers,
|
|
})
|
|
res.end(JSON.stringify(response.body))
|
|
return
|
|
}
|
|
|
|
if (
|
|
options?.handlePAR &&
|
|
req.method === 'POST' &&
|
|
url.pathname === '/par'
|
|
) {
|
|
let body = ''
|
|
for await (const chunk of req) {
|
|
body += chunk
|
|
}
|
|
parRequests.push(body)
|
|
|
|
res.writeHead(201, { 'content-type': 'application/json' })
|
|
res.end(
|
|
JSON.stringify({
|
|
request_uri: 'urn:ietf:params:oauth:request_uri:par_ref',
|
|
expires_in: 60,
|
|
}),
|
|
)
|
|
return
|
|
}
|
|
|
|
res.writeHead(404)
|
|
res.end()
|
|
})
|
|
|
|
await new Promise<void>((resolve) => {
|
|
server.listen(0, '127.0.0.1', resolve)
|
|
})
|
|
|
|
const { port } = server.address() as AddressInfo
|
|
|
|
return {
|
|
port,
|
|
requests,
|
|
parRequests,
|
|
server,
|
|
}
|
|
}
|
|
|
|
function createConfiguration(
|
|
port: number,
|
|
metadataOverrides?: Record<string, unknown>,
|
|
) {
|
|
const config = new client.Configuration(
|
|
{
|
|
issuer: `http://127.0.0.1:${port}/issuer`,
|
|
authorization_endpoint: `http://127.0.0.1:${port}/authorize`,
|
|
code_challenge_methods_supported: ['S256'],
|
|
token_endpoint: `http://127.0.0.1:${port}/token`,
|
|
...metadataOverrides,
|
|
},
|
|
'client',
|
|
undefined,
|
|
client.None(),
|
|
)
|
|
|
|
client.allowInsecureRequests(config)
|
|
|
|
return config
|
|
}
|
|
|
|
type StrategyHarness = passport.StrategyCreated<
|
|
Strategy,
|
|
Strategy & passport.StrategyCreatedStatic
|
|
>
|
|
|
|
interface HarnessResult {
|
|
strategy: StrategyHarness
|
|
session: Record<string, unknown>
|
|
results: {
|
|
redirectTo?: string
|
|
error?: unknown
|
|
failInfo?: unknown
|
|
successUser?: Express.User
|
|
}
|
|
}
|
|
|
|
function createStrategyHarness(
|
|
port: number,
|
|
verify?: (
|
|
tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers,
|
|
verified: passport.AuthenticateCallback,
|
|
) => void,
|
|
strategyOptions?: Partial<{
|
|
callbackURL: string
|
|
passReqToCallback: boolean
|
|
scope: string
|
|
resource: string | string[]
|
|
sessionKey: string
|
|
}>,
|
|
metadataOverrides?: Record<string, unknown>,
|
|
): HarnessResult {
|
|
const results: HarnessResult['results'] = {}
|
|
const session: Record<string, unknown> = {}
|
|
|
|
const strategy = new Strategy(
|
|
{
|
|
callbackURL:
|
|
strategyOptions?.callbackURL ?? `http://127.0.0.1:${port}/cb`,
|
|
config: createConfiguration(port, metadataOverrides),
|
|
...strategyOptions,
|
|
} as any,
|
|
verify ??
|
|
((_tokens, verified) => verified(null, { sub: 'user' } as Express.User)),
|
|
) as StrategyHarness
|
|
|
|
strategy.redirect = (href: string) => {
|
|
results.redirectTo = href
|
|
}
|
|
strategy.error = (err: unknown) => {
|
|
results.error = err
|
|
}
|
|
strategy.fail = (info: unknown) => {
|
|
results.failInfo = info
|
|
}
|
|
strategy.success = (user: Express.User) => {
|
|
results.successUser = user
|
|
}
|
|
|
|
return { strategy, session, results }
|
|
}
|
|
|
|
async function doAuthorizationRequest(
|
|
harness: HarnessResult,
|
|
options: AuthenticateOptions = {},
|
|
): Promise<string> {
|
|
await Strategy.prototype.authorizationRequest.call(
|
|
harness.strategy,
|
|
{ session: harness.session } as any,
|
|
options,
|
|
)
|
|
return harness.results.redirectTo!
|
|
}
|
|
|
|
async function doAuthorizationCodeGrant(
|
|
harness: HarnessResult,
|
|
callbackUrl: string,
|
|
options: AuthenticateOptions = {},
|
|
method = 'GET',
|
|
): Promise<void> {
|
|
await Strategy.prototype.authorizationCodeGrant.call(
|
|
harness.strategy,
|
|
{
|
|
method,
|
|
session: harness.session,
|
|
} as any,
|
|
new URL(callbackUrl),
|
|
options,
|
|
)
|
|
}
|
|
|
|
// --- Constructor ---
|
|
|
|
test('constructor throws on missing config', (t) => {
|
|
t.throws(() => new Strategy({} as any, () => {}), {
|
|
instanceOf: TypeError,
|
|
})
|
|
})
|
|
|
|
test('constructor throws on non-function verify', (t) => {
|
|
const { port } = { port: 0 }
|
|
t.throws(
|
|
() =>
|
|
new Strategy(
|
|
{ config: createConfiguration(port) } as any,
|
|
'not a function' as any,
|
|
),
|
|
{ instanceOf: TypeError },
|
|
)
|
|
})
|
|
|
|
test('constructor uses issuer host as default name and sessionKey', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const strategy = new Strategy({ config }, () => {})
|
|
t.is(strategy.name, '127.0.0.1:9999')
|
|
t.is(strategy._sessionKey, '127.0.0.1:9999')
|
|
})
|
|
|
|
test('constructor accepts custom name and sessionKey', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const strategy = new Strategy(
|
|
{ config, name: 'custom-name', sessionKey: 'custom-key' },
|
|
() => {},
|
|
)
|
|
t.is(strategy.name, 'custom-name')
|
|
t.is(strategy._sessionKey, 'custom-key')
|
|
})
|
|
|
|
// --- authenticate routing ---
|
|
|
|
test('authenticate errors when session is missing', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const strategy = new Strategy({ config }, () => {}) as StrategyHarness
|
|
|
|
let error: unknown
|
|
strategy.error = (err: unknown) => {
|
|
error = err
|
|
}
|
|
|
|
Strategy.prototype.authenticate.call(strategy, {} as any, {})
|
|
t.true(error instanceof Error)
|
|
t.regex((error as Error).message, /session/i)
|
|
})
|
|
|
|
// --- shouldInitiateAuthRequest ---
|
|
|
|
test('shouldInitiateAuthRequest returns true for plain GET without callback params', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const strategy = new Strategy({ config }, () => {})
|
|
const url = new URL('http://localhost/login')
|
|
t.true(strategy.shouldInitiateAuthRequest({ method: 'GET' } as any, url, {}))
|
|
})
|
|
|
|
test('shouldInitiateAuthRequest returns false when code param present', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const strategy = new Strategy({ config }, () => {})
|
|
const url = new URL('http://localhost/cb?code=abc')
|
|
t.false(strategy.shouldInitiateAuthRequest({ method: 'GET' } as any, url, {}))
|
|
})
|
|
|
|
test('shouldInitiateAuthRequest returns false when error param present', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const strategy = new Strategy({ config }, () => {})
|
|
const url = new URL('http://localhost/cb?error=access_denied')
|
|
t.false(strategy.shouldInitiateAuthRequest({ method: 'GET' } as any, url, {}))
|
|
})
|
|
|
|
test('shouldInitiateAuthRequest returns false when response param present', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const strategy = new Strategy({ config }, () => {})
|
|
const url = new URL('http://localhost/cb?response=jarm')
|
|
t.false(strategy.shouldInitiateAuthRequest({ method: 'GET' } as any, url, {}))
|
|
})
|
|
|
|
test('shouldInitiateAuthRequest returns false for POST', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const strategy = new Strategy({ config }, () => {})
|
|
const url = new URL('http://localhost/cb')
|
|
t.false(
|
|
strategy.shouldInitiateAuthRequest({ method: 'POST' } as any, url, {}),
|
|
)
|
|
})
|
|
|
|
// --- Authorization Request ---
|
|
|
|
test('authorizationRequest redirects and stores state in session', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const redirectTo = await doAuthorizationRequest(harness)
|
|
|
|
const url = new URL(redirectTo)
|
|
t.truthy(url.searchParams.get('code_challenge'))
|
|
t.is(url.searchParams.get('code_challenge_method'), 'S256')
|
|
t.truthy(url.searchParams.get('redirect_uri'))
|
|
|
|
// Session state was stored
|
|
const sessionKey = harness.strategy._sessionKey
|
|
const stateData = harness.session[sessionKey] as any
|
|
t.truthy(stateData)
|
|
t.truthy(stateData.code_verifier)
|
|
})
|
|
|
|
test('authorizationRequest includes scope from options', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const redirectTo = await doAuthorizationRequest(harness, {
|
|
scope: 'openid profile',
|
|
})
|
|
|
|
const url = new URL(redirectTo)
|
|
t.is(url.searchParams.get('scope'), 'openid profile')
|
|
})
|
|
|
|
test('authorizationRequest includes scope array from options', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const redirectTo = await doAuthorizationRequest(harness, {
|
|
scope: ['openid', 'email'],
|
|
})
|
|
|
|
const url = new URL(redirectTo)
|
|
t.is(url.searchParams.get('scope'), 'openid email')
|
|
})
|
|
|
|
test('authorizationRequest uses strategy-level scope as fallback', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port, undefined, {
|
|
scope: 'openid',
|
|
})
|
|
const redirectTo = await doAuthorizationRequest(harness)
|
|
|
|
const url = new URL(redirectTo)
|
|
t.is(url.searchParams.get('scope'), 'openid')
|
|
})
|
|
|
|
test('authorizationRequest includes loginHint', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const redirectTo = await doAuthorizationRequest(harness, {
|
|
loginHint: '[email protected]',
|
|
})
|
|
|
|
const url = new URL(redirectTo)
|
|
t.is(url.searchParams.get('login_hint'), '[email protected]')
|
|
})
|
|
|
|
test('authorizationRequest includes prompt', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const redirectTo = await doAuthorizationRequest(harness, {
|
|
prompt: 'consent',
|
|
})
|
|
|
|
const url = new URL(redirectTo)
|
|
t.is(url.searchParams.get('prompt'), 'consent')
|
|
})
|
|
|
|
test('authorizationRequest includes resource', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const redirectTo = await doAuthorizationRequest(harness, {
|
|
resource: 'https://rs.example.com',
|
|
})
|
|
|
|
const url = new URL(redirectTo)
|
|
t.is(url.searchParams.get('resource'), 'https://rs.example.com')
|
|
})
|
|
|
|
test('authorizationRequest includes strategy-level resource as fallback', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port, undefined, {
|
|
resource: 'https://rs.example.com',
|
|
})
|
|
const redirectTo = await doAuthorizationRequest(harness)
|
|
|
|
const url = new URL(redirectTo)
|
|
t.is(url.searchParams.get('resource'), 'https://rs.example.com')
|
|
})
|
|
|
|
// --- Authorization Code Grant (callback) ---
|
|
|
|
test('callback with missing session state calls fail', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
|
|
// No authorizationRequest was made, so session is empty
|
|
await doAuthorizationCodeGrant(harness, `http://127.0.0.1:${port}/cb?code=ok`)
|
|
|
|
t.truthy(harness.results.failInfo)
|
|
t.like(harness.results.failInfo as Record<string, unknown>, {
|
|
message: 'Unable to verify authorization request state',
|
|
})
|
|
})
|
|
|
|
test('verify callback error propagates via strategy.error', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const verifyError = new Error('verify failed')
|
|
const harness = createStrategyHarness(port, (_tokens, verified) => {
|
|
verified(verifyError)
|
|
})
|
|
|
|
const redirectTo = await doAuthorizationRequest(harness)
|
|
await doAuthorizationCodeGrant(
|
|
harness,
|
|
`${new URL(redirectTo).origin}/cb?code=ok`,
|
|
)
|
|
|
|
t.is(harness.results.error, verifyError)
|
|
})
|
|
|
|
test('verify callback with no user calls fail', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port, (_tokens, verified) => {
|
|
verified(null, false, { message: 'no such user' })
|
|
})
|
|
|
|
const redirectTo = await doAuthorizationRequest(harness)
|
|
await doAuthorizationCodeGrant(
|
|
harness,
|
|
`${new URL(redirectTo).origin}/cb?code=ok`,
|
|
)
|
|
|
|
t.truthy(harness.results.failInfo)
|
|
})
|
|
|
|
test('access_denied error response calls fail instead of error', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const redirectTo = await doAuthorizationRequest(harness)
|
|
|
|
await doAuthorizationCodeGrant(
|
|
harness,
|
|
`${new URL(redirectTo).origin}/cb?error=access_denied&error_description=user+denied`,
|
|
)
|
|
|
|
t.truthy(harness.results.failInfo)
|
|
t.falsy(harness.results.error)
|
|
t.like(harness.results.failInfo as Record<string, unknown>, {
|
|
message: 'user denied',
|
|
})
|
|
})
|
|
|
|
test('non-access_denied error response calls error', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const redirectTo = await doAuthorizationRequest(harness)
|
|
|
|
await doAuthorizationCodeGrant(
|
|
harness,
|
|
`${new URL(redirectTo).origin}/cb?error=server_error`,
|
|
)
|
|
|
|
t.truthy(harness.results.error)
|
|
t.true(harness.results.error instanceof client.AuthorizationResponseError)
|
|
})
|
|
|
|
test('callbackURL option overrides strategy-level callbackURL in callback', async (t) => {
|
|
const { port, server, requests } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const redirectTo = await doAuthorizationRequest(harness)
|
|
|
|
const overrideUrl = `http://127.0.0.1:${port}/other-cb`
|
|
|
|
await doAuthorizationCodeGrant(
|
|
harness,
|
|
`${new URL(redirectTo).origin}/cb?code=ok`,
|
|
{ callbackURL: overrideUrl },
|
|
)
|
|
|
|
t.truthy(harness.results.successUser)
|
|
// The token request should include the overridden redirect_uri
|
|
const tokenBody = new URLSearchParams(requests[0])
|
|
t.is(tokenBody.get('redirect_uri'), overrideUrl)
|
|
})
|
|
|
|
test('passReqToCallback passes request to verify function', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
let receivedReq: unknown
|
|
const strategy = new Strategy(
|
|
{
|
|
callbackURL: `http://127.0.0.1:${port}/cb`,
|
|
config: createConfiguration(port),
|
|
passReqToCallback: true,
|
|
},
|
|
(req, _tokens, verified) => {
|
|
receivedReq = req
|
|
verified(null, { sub: 'user' } as Express.User)
|
|
},
|
|
) as StrategyHarness
|
|
|
|
const session: Record<string, unknown> = {}
|
|
const results: HarnessResult['results'] = {}
|
|
|
|
strategy.redirect = (href: string) => {
|
|
results.redirectTo = href
|
|
}
|
|
strategy.error = (err: unknown) => {
|
|
results.error = err
|
|
}
|
|
strategy.fail = (info: unknown) => {
|
|
results.failInfo = info
|
|
}
|
|
strategy.success = (user: Express.User) => {
|
|
results.successUser = user
|
|
}
|
|
|
|
await Strategy.prototype.authorizationRequest.call(
|
|
strategy,
|
|
{ session } as any,
|
|
{},
|
|
)
|
|
|
|
const fakeReq = { method: 'GET', session, marker: 'the-request' }
|
|
|
|
await Strategy.prototype.authorizationCodeGrant.call(
|
|
strategy,
|
|
fakeReq as any,
|
|
new URL(`${new URL(results.redirectTo!).origin}/cb?code=ok`),
|
|
{},
|
|
)
|
|
|
|
t.truthy(results.successUser)
|
|
t.is((receivedReq as any).marker, 'the-request')
|
|
})
|
|
|
|
test('token endpoint request includes code_verifier from session', async (t) => {
|
|
const { port, server, requests } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const redirectTo = await doAuthorizationRequest(harness)
|
|
|
|
await doAuthorizationCodeGrant(
|
|
harness,
|
|
`${new URL(redirectTo).origin}/cb?code=test_code`,
|
|
)
|
|
|
|
t.truthy(harness.results.successUser)
|
|
const tokenBody = new URLSearchParams(requests[0])
|
|
t.is(tokenBody.get('grant_type'), 'authorization_code')
|
|
t.is(tokenBody.get('code'), 'test_code')
|
|
t.truthy(tokenBody.get('code_verifier'))
|
|
})
|
|
|
|
// --- authorizationRequestParams: idTokenHint ---
|
|
|
|
test('authorizationRequest includes idTokenHint', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const redirectTo = await doAuthorizationRequest(harness, {
|
|
idTokenHint: 'eyJ.previous.token',
|
|
})
|
|
|
|
const url = new URL(redirectTo)
|
|
t.is(url.searchParams.get('id_token_hint'), 'eyJ.previous.token')
|
|
})
|
|
|
|
// --- authorizationRequestParams: authorizationDetails ---
|
|
|
|
test('authorizationRequest includes authorizationDetails from options', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const details: client.AuthorizationDetails = {
|
|
type: 'payment_initiation',
|
|
actions: ['initiate'],
|
|
}
|
|
const redirectTo = await doAuthorizationRequest(harness, {
|
|
authorizationDetails: details,
|
|
})
|
|
|
|
const url = new URL(redirectTo)
|
|
const parsed = JSON.parse(url.searchParams.get('authorization_details')!)
|
|
t.true(Array.isArray(parsed))
|
|
t.is(parsed[0].type, 'payment_initiation')
|
|
})
|
|
|
|
test('authorizationRequest includes authorizationDetails array from options', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const details: client.AuthorizationDetails[] = [
|
|
{ type: 'payment_initiation' },
|
|
{ type: 'account_information' },
|
|
]
|
|
const redirectTo = await doAuthorizationRequest(harness, {
|
|
authorizationDetails: details,
|
|
})
|
|
|
|
const url = new URL(redirectTo)
|
|
const parsed = JSON.parse(url.searchParams.get('authorization_details')!)
|
|
t.is(parsed.length, 2)
|
|
t.is(parsed[0].type, 'payment_initiation')
|
|
t.is(parsed[1].type, 'account_information')
|
|
})
|
|
|
|
// --- authorizationRequestParams: callbackURL override in auth request ---
|
|
|
|
test('authorizationRequest includes callbackURL option as redirect_uri', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const redirectTo = await doAuthorizationRequest(harness, {
|
|
callbackURL: `http://127.0.0.1:${port}/override-cb`,
|
|
})
|
|
|
|
const url = new URL(redirectTo)
|
|
t.is(
|
|
url.searchParams.get('redirect_uri'),
|
|
`http://127.0.0.1:${port}/override-cb`,
|
|
)
|
|
})
|
|
|
|
// --- authorizationDetails strategy-level fallback ---
|
|
|
|
test('authorizationRequest uses strategy-level authorizationDetails as fallback', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const details: client.AuthorizationDetails = { type: 'payment_initiation' }
|
|
const config = createConfiguration(port)
|
|
const strategy = new Strategy(
|
|
{
|
|
callbackURL: `http://127.0.0.1:${port}/cb`,
|
|
config,
|
|
authorizationDetails: details,
|
|
},
|
|
(_tokens, verified) => verified(null, { sub: 'user' } as Express.User),
|
|
) as StrategyHarness
|
|
|
|
const session: Record<string, unknown> = {}
|
|
const results: HarnessResult['results'] = {}
|
|
strategy.redirect = (href: string) => {
|
|
results.redirectTo = href
|
|
}
|
|
strategy.error = (err: unknown) => {
|
|
results.error = err
|
|
}
|
|
|
|
await Strategy.prototype.authorizationRequest.call(
|
|
strategy,
|
|
{ session } as any,
|
|
{},
|
|
)
|
|
|
|
const url = new URL(results.redirectTo!)
|
|
const parsed = JSON.parse(url.searchParams.get('authorization_details')!)
|
|
t.true(Array.isArray(parsed))
|
|
t.is(parsed[0].type, 'payment_initiation')
|
|
})
|
|
|
|
// --- Session state stores nonce, state, max_age ---
|
|
|
|
test('authorizationRequest stores max_age in session when present', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
|
|
// Provide max_age via the overridable authorizationRequestParams
|
|
const origParams = Strategy.prototype.authorizationRequestParams
|
|
harness.strategy.authorizationRequestParams = function (req, options) {
|
|
const params = new URLSearchParams(origParams.call(this, req, options))
|
|
params.set('max_age', '3600')
|
|
return params
|
|
}
|
|
|
|
await doAuthorizationRequest(harness)
|
|
|
|
const sessionKey = harness.strategy._sessionKey
|
|
const stateData = harness.session[sessionKey] as any
|
|
t.is(stateData.max_age, 3600)
|
|
t.truthy(stateData.code_verifier)
|
|
})
|
|
|
|
// --- authorizationRequest error handling ---
|
|
|
|
test('authorizationRequest error routes to strategy.error', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
|
|
// Force an error by making buildAuthorizationUrl throw via bad params
|
|
harness.strategy.authorizationRequestParams = () => {
|
|
throw new Error('params failed')
|
|
}
|
|
|
|
await doAuthorizationRequest(harness)
|
|
|
|
t.truthy(harness.results.error)
|
|
t.is((harness.results.error as Error).message, 'params failed')
|
|
t.falsy(harness.results.redirectTo)
|
|
})
|
|
|
|
// --- resource forwarded to token endpoint ---
|
|
|
|
test('resource option is forwarded to token endpoint request', async (t) => {
|
|
const { port, server, requests } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const redirectTo = await doAuthorizationRequest(harness)
|
|
|
|
await doAuthorizationCodeGrant(
|
|
harness,
|
|
`${new URL(redirectTo).origin}/cb?code=ok`,
|
|
{ resource: 'https://rs.example.com' },
|
|
)
|
|
|
|
t.truthy(harness.results.successUser)
|
|
const tokenBody = new URLSearchParams(requests[0])
|
|
t.is(tokenBody.get('resource'), 'https://rs.example.com')
|
|
})
|
|
|
|
// --- currentUrl ---
|
|
|
|
test('currentUrl constructs URL from request properties', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const strategy = new Strategy({ config }, () => {})
|
|
|
|
const req = {
|
|
protocol: 'https',
|
|
host: 'example.com',
|
|
originalUrl: '/cb?code=abc&state=xyz',
|
|
get(header: string) {
|
|
if (header === 'host') return 'example.com'
|
|
return undefined
|
|
},
|
|
socket: { remoteAddress: '127.0.0.1' },
|
|
app: {
|
|
get() {
|
|
return () => false
|
|
},
|
|
},
|
|
}
|
|
|
|
const url = strategy.currentUrl(req as any)
|
|
t.is(url.origin, 'https://example.com')
|
|
t.is(url.pathname, '/cb')
|
|
t.is(url.searchParams.get('code'), 'abc')
|
|
t.is(url.searchParams.get('state'), 'xyz')
|
|
})
|
|
|
|
test('currentUrl falls back to req.url when originalUrl is absent', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const strategy = new Strategy({ config }, () => {})
|
|
|
|
const req = {
|
|
protocol: 'http',
|
|
host: 'localhost:3000',
|
|
url: '/callback?code=test',
|
|
get(header: string) {
|
|
if (header === 'host') return 'localhost:3000'
|
|
return undefined
|
|
},
|
|
socket: { remoteAddress: '127.0.0.1' },
|
|
app: {
|
|
get() {
|
|
return () => false
|
|
},
|
|
},
|
|
}
|
|
|
|
const url = strategy.currentUrl(req as any)
|
|
t.is(url.href, 'http://localhost:3000/callback?code=test')
|
|
})
|
|
|
|
// --- authenticate integration ---
|
|
|
|
test('authenticate routes to authorizationRequest for initial GET', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
|
|
// Provide enough request shape for currentUrl + authenticate
|
|
const req = {
|
|
method: 'GET',
|
|
protocol: 'http',
|
|
host: `127.0.0.1:${port}`,
|
|
originalUrl: '/login',
|
|
session: harness.session,
|
|
get(header: string) {
|
|
if (header === 'host') return `127.0.0.1:${port}`
|
|
return undefined
|
|
},
|
|
socket: { remoteAddress: '127.0.0.1' },
|
|
app: {
|
|
get() {
|
|
return () => false
|
|
},
|
|
},
|
|
}
|
|
|
|
Strategy.prototype.authenticate.call(harness.strategy, req as any, {})
|
|
|
|
// Give the async authorizationRequest time to complete
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
|
|
t.truthy(harness.results.redirectTo)
|
|
const url = new URL(harness.results.redirectTo!)
|
|
t.truthy(url.searchParams.get('code_challenge'))
|
|
})
|
|
|
|
test('authenticate routes to authorizationCodeGrant for callback GET with code', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
|
|
// First, do the auth request to populate session
|
|
await doAuthorizationRequest(harness)
|
|
|
|
const req = {
|
|
method: 'GET',
|
|
protocol: 'http',
|
|
host: `127.0.0.1:${port}`,
|
|
originalUrl: '/cb?code=ok',
|
|
session: harness.session,
|
|
get(header: string) {
|
|
if (header === 'host') return `127.0.0.1:${port}`
|
|
return undefined
|
|
},
|
|
socket: { remoteAddress: '127.0.0.1' },
|
|
app: {
|
|
get() {
|
|
return () => false
|
|
},
|
|
},
|
|
}
|
|
|
|
Strategy.prototype.authenticate.call(harness.strategy, req as any, {})
|
|
|
|
// Give the async authorizationCodeGrant time to complete
|
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
|
|
t.truthy(harness.results.successUser)
|
|
})
|
|
|
|
// --- Constructor: callbackURL string→URL conversion ---
|
|
|
|
test('constructor converts string callbackURL to URL instance', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const strategy = new Strategy(
|
|
{ config, callbackURL: 'http://example.com/cb' },
|
|
() => {},
|
|
)
|
|
t.true(strategy._callbackURL instanceof URL)
|
|
t.is(strategy._callbackURL!.href, 'http://example.com/cb')
|
|
})
|
|
|
|
test('constructor accepts URL callbackURL', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const url = new URL('http://example.com/cb')
|
|
const strategy = new Strategy({ config, callbackURL: url }, () => {})
|
|
t.true(strategy._callbackURL instanceof URL)
|
|
t.is(strategy._callbackURL!.href, 'http://example.com/cb')
|
|
})
|
|
|
|
test('constructor leaves _callbackURL undefined when not provided', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const strategy = new Strategy({ config }, () => {})
|
|
t.is(strategy._callbackURL, undefined)
|
|
})
|
|
|
|
// --- State stored in session when server lacks PKCE support ---
|
|
|
|
test('authorizationRequest adds state to session when server lacks PKCE support', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
// No code_challenge_methods_supported → supportsPKCE() returns false
|
|
const harness = createStrategyHarness(port, undefined, undefined, {
|
|
code_challenge_methods_supported: undefined,
|
|
})
|
|
const redirectTo = await doAuthorizationRequest(harness)
|
|
|
|
const url = new URL(redirectTo)
|
|
// PKCE is still always used
|
|
t.truthy(url.searchParams.get('code_challenge'))
|
|
// State should be added as fallback
|
|
t.truthy(url.searchParams.get('state'))
|
|
|
|
const sessionKey = harness.strategy._sessionKey
|
|
const stateData = harness.session[sessionKey] as any
|
|
t.truthy(stateData.state)
|
|
t.is(stateData.state, url.searchParams.get('state'))
|
|
})
|
|
|
|
// --- Nonce stored in session for implicit/hybrid flows ---
|
|
|
|
test('authorizationRequest stores nonce in session when response_type includes id_token', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
|
|
// Override authorizationRequestParams to inject response_type with id_token
|
|
harness.strategy.authorizationRequestParams = function (_req, _options) {
|
|
return new URLSearchParams({ response_type: 'code id_token' })
|
|
}
|
|
|
|
await doAuthorizationRequest(harness)
|
|
|
|
const sessionKey = harness.strategy._sessionKey
|
|
const stateData = harness.session[sessionKey] as any
|
|
t.truthy(stateData.nonce)
|
|
t.truthy(stateData.code_verifier)
|
|
|
|
// response_mode should default to form_post for implicit
|
|
const url = new URL(harness.results.redirectTo!)
|
|
t.is(url.searchParams.get('response_mode'), 'form_post')
|
|
t.truthy(url.searchParams.get('nonce'))
|
|
})
|
|
|
|
// --- passReqToCallback option-level override ---
|
|
|
|
test('options.passReqToCallback overrides strategy-level passReqToCallback', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
let argCount = 0
|
|
const strategy = new Strategy(
|
|
{
|
|
callbackURL: `http://127.0.0.1:${port}/cb`,
|
|
config: createConfiguration(port),
|
|
passReqToCallback: true,
|
|
},
|
|
// This verify has the req signature, but we track arg count
|
|
((...args: unknown[]) => {
|
|
argCount = args.length
|
|
const verified = args[args.length - 1] as passport.AuthenticateCallback
|
|
verified(null, { sub: 'user' } as Express.User)
|
|
}) as any,
|
|
) as StrategyHarness
|
|
|
|
const session: Record<string, unknown> = {}
|
|
const results: HarnessResult['results'] = {}
|
|
|
|
strategy.redirect = (href: string) => {
|
|
results.redirectTo = href
|
|
}
|
|
strategy.error = (err: unknown) => {
|
|
results.error = err
|
|
}
|
|
strategy.fail = (info: unknown) => {
|
|
results.failInfo = info
|
|
}
|
|
strategy.success = (user: Express.User) => {
|
|
results.successUser = user
|
|
}
|
|
|
|
await Strategy.prototype.authorizationRequest.call(
|
|
strategy,
|
|
{ session } as any,
|
|
{},
|
|
)
|
|
|
|
// Override passReqToCallback to false at the option level
|
|
await Strategy.prototype.authorizationCodeGrant.call(
|
|
strategy,
|
|
{ method: 'GET', session } as any,
|
|
new URL(`${new URL(results.redirectTo!).origin}/cb?code=ok`),
|
|
{ passReqToCallback: false },
|
|
)
|
|
|
|
t.truthy(results.successUser)
|
|
// With passReqToCallback=false override, verify receives (tokens, verified) = 2 args
|
|
t.is(argCount, 2)
|
|
})
|
|
|
|
// --- currentUrl: x-forwarded-host ---
|
|
|
|
test('currentUrl uses x-forwarded-host when trust proxy is configured', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const strategy = new Strategy({ config }, () => {})
|
|
|
|
const req = {
|
|
protocol: 'https',
|
|
host: 'proxy.example.com',
|
|
originalUrl: '/cb?code=abc',
|
|
get(header: string) {
|
|
if (header === 'x-forwarded-host') return 'frontend.example.com'
|
|
if (header === 'host') return 'backend.internal'
|
|
return undefined
|
|
},
|
|
socket: { remoteAddress: '10.0.0.1' },
|
|
app: {
|
|
get() {
|
|
// Trust all proxies
|
|
return () => true
|
|
},
|
|
},
|
|
}
|
|
|
|
const url = strategy.currentUrl(req as any)
|
|
t.is(url.host, 'frontend.example.com')
|
|
})
|
|
|
|
test('currentUrl ignores x-forwarded-host when trust proxy rejects', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const strategy = new Strategy({ config }, () => {})
|
|
|
|
const req = {
|
|
protocol: 'https',
|
|
host: 'backend.internal',
|
|
originalUrl: '/cb?code=abc',
|
|
get(header: string) {
|
|
if (header === 'x-forwarded-host') return 'attacker.example.com'
|
|
if (header === 'host') return 'backend.internal'
|
|
return undefined
|
|
},
|
|
socket: { remoteAddress: '10.0.0.1' },
|
|
app: {
|
|
get() {
|
|
// Don't trust any proxy
|
|
return () => false
|
|
},
|
|
},
|
|
}
|
|
|
|
const url = strategy.currentUrl(req as any)
|
|
t.is(url.host, 'backend.internal')
|
|
})
|
|
|
|
test('currentUrl trims first value from multi-value x-forwarded-host', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const strategy = new Strategy({ config }, () => {})
|
|
|
|
const req = {
|
|
protocol: 'https',
|
|
host: 'backend.internal',
|
|
originalUrl: '/cb?code=abc',
|
|
get(header: string) {
|
|
if (header === 'x-forwarded-host')
|
|
return 'frontend.example.com , proxy2.internal'
|
|
if (header === 'host') return 'backend.internal'
|
|
return undefined
|
|
},
|
|
socket: { remoteAddress: '10.0.0.1' },
|
|
app: {
|
|
get() {
|
|
return () => true
|
|
},
|
|
},
|
|
}
|
|
|
|
const url = strategy.currentUrl(req as any)
|
|
t.is(url.host, 'frontend.example.com')
|
|
})
|
|
|
|
test('currentUrl falls back to req.host when app.get throws', (t) => {
|
|
const config = createConfiguration(9999)
|
|
const strategy = new Strategy({ config }, () => {})
|
|
|
|
// Simulates non-express environment (e.g. fastify-express)
|
|
const req = {
|
|
protocol: 'http',
|
|
host: 'fastify.local:3000',
|
|
originalUrl: '/cb?code=test',
|
|
app: {
|
|
get() {
|
|
throw new Error('not express')
|
|
},
|
|
},
|
|
}
|
|
|
|
const url = strategy.currentUrl(req as any)
|
|
t.is(url.host, 'fastify.local:3000')
|
|
})
|
|
|
|
// --- POST (form_post) callback ---
|
|
|
|
test('callback handles POST form_post response', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
await doAuthorizationRequest(harness)
|
|
|
|
// Simulate a form_post: the authorization response params come in the POST body
|
|
const formBody = 'code=post_code'
|
|
const bodyStream = Readable.from(Buffer.from(formBody))
|
|
|
|
await Strategy.prototype.authorizationCodeGrant.call(
|
|
harness.strategy,
|
|
{
|
|
method: 'POST',
|
|
session: harness.session,
|
|
headersDistinct: {
|
|
'content-type': ['application/x-www-form-urlencoded'],
|
|
},
|
|
...bodyStream,
|
|
[Symbol.asyncIterator]: bodyStream[Symbol.asyncIterator].bind(bodyStream),
|
|
} as any,
|
|
new URL(`http://127.0.0.1:${port}/cb`),
|
|
{},
|
|
)
|
|
|
|
t.truthy(harness.results.successUser)
|
|
})
|
|
|
|
// --- DPoP: dpop_jkt in authorization request ---
|
|
|
|
test('authorizationRequest includes dpop_jkt when DPoP is configured', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const keyPair = await client.randomDPoPKeyPair()
|
|
const config = createConfiguration(port)
|
|
const dpopHandle = client.getDPoPHandle(config, keyPair)
|
|
|
|
const strategy = new Strategy(
|
|
{
|
|
callbackURL: `http://127.0.0.1:${port}/cb`,
|
|
config,
|
|
DPoP: () => dpopHandle,
|
|
},
|
|
(_tokens, verified) => verified(null, { sub: 'user' } as Express.User),
|
|
) as StrategyHarness
|
|
|
|
const session: Record<string, unknown> = {}
|
|
const results: HarnessResult['results'] = {}
|
|
|
|
strategy.redirect = (href: string) => {
|
|
results.redirectTo = href
|
|
}
|
|
strategy.error = (err: unknown) => {
|
|
results.error = err
|
|
}
|
|
|
|
await Strategy.prototype.authorizationRequest.call(
|
|
strategy,
|
|
{ session } as any,
|
|
{},
|
|
)
|
|
|
|
t.falsy(results.error)
|
|
const url = new URL(results.redirectTo!)
|
|
t.truthy(url.searchParams.get('dpop_jkt'))
|
|
})
|
|
|
|
// --- useJAR ---
|
|
|
|
test('authorizationRequest uses JAR when useJAR is configured', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const keyPair = await crypto.subtle.generateKey(
|
|
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
false,
|
|
['sign', 'verify'],
|
|
)
|
|
|
|
const config = createConfiguration(port)
|
|
const strategy = new Strategy(
|
|
{
|
|
callbackURL: `http://127.0.0.1:${port}/cb`,
|
|
config,
|
|
useJAR: keyPair.privateKey as CryptoKey,
|
|
},
|
|
((_tokens: any, verified: any) =>
|
|
verified(null, { sub: 'user' } as Express.User)) as VerifyFunction,
|
|
) as StrategyHarness
|
|
|
|
const session: Record<string, unknown> = {}
|
|
const results: HarnessResult['results'] = {}
|
|
|
|
strategy.redirect = (href: string) => {
|
|
results.redirectTo = href
|
|
}
|
|
strategy.error = (err: unknown) => {
|
|
results.error = err
|
|
}
|
|
|
|
await Strategy.prototype.authorizationRequest.call(
|
|
strategy,
|
|
{ session } as any,
|
|
{},
|
|
)
|
|
|
|
t.falsy(results.error)
|
|
const url = new URL(results.redirectTo!)
|
|
// JAR replaces individual params with a `request` JWT
|
|
t.truthy(url.searchParams.get('request'))
|
|
t.truthy(url.searchParams.get('client_id'))
|
|
// The individual params should have been collapsed into the request object
|
|
t.falsy(url.searchParams.get('code_challenge'))
|
|
})
|
|
|
|
test('authorizationRequest uses JAR with modifyAssertion function', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const keyPair = await crypto.subtle.generateKey(
|
|
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
false,
|
|
['sign', 'verify'],
|
|
)
|
|
|
|
let modifyCalled = false
|
|
const config = createConfiguration(port)
|
|
const strategy = new Strategy(
|
|
{
|
|
callbackURL: `http://127.0.0.1:${port}/cb`,
|
|
config,
|
|
useJAR: [
|
|
keyPair.privateKey as CryptoKey,
|
|
(header, _payload) => {
|
|
modifyCalled = true
|
|
// just verify we can modify the header
|
|
header.kid = 'custom-kid'
|
|
},
|
|
],
|
|
},
|
|
((_tokens: any, verified: any) =>
|
|
verified(null, { sub: 'user' } as Express.User)) as VerifyFunction,
|
|
) as StrategyHarness
|
|
|
|
const session: Record<string, unknown> = {}
|
|
const results: HarnessResult['results'] = {}
|
|
|
|
strategy.redirect = (href: string) => {
|
|
results.redirectTo = href
|
|
}
|
|
strategy.error = (err: unknown) => {
|
|
results.error = err
|
|
}
|
|
|
|
await Strategy.prototype.authorizationRequest.call(
|
|
strategy,
|
|
{ session } as any,
|
|
{},
|
|
)
|
|
|
|
t.falsy(results.error)
|
|
t.true(modifyCalled)
|
|
const url = new URL(results.redirectTo!)
|
|
t.truthy(url.searchParams.get('request'))
|
|
})
|
|
|
|
// --- usePAR ---
|
|
|
|
test('authorizationRequest uses PAR when usePAR is configured', async (t) => {
|
|
const { port, server } = await startTokenEndpoint(undefined, {
|
|
handlePAR: true,
|
|
})
|
|
t.teardown(() => close(server))
|
|
|
|
const config = createConfiguration(port, {
|
|
pushed_authorization_request_endpoint: `http://127.0.0.1:${port}/par`,
|
|
})
|
|
|
|
const strategy = new Strategy(
|
|
{
|
|
callbackURL: `http://127.0.0.1:${port}/cb`,
|
|
config,
|
|
usePAR: true,
|
|
},
|
|
(_tokens, verified) => verified(null, { sub: 'user' } as Express.User),
|
|
) as StrategyHarness
|
|
|
|
const session: Record<string, unknown> = {}
|
|
const results: HarnessResult['results'] = {}
|
|
|
|
strategy.redirect = (href: string) => {
|
|
results.redirectTo = href
|
|
}
|
|
strategy.error = (err: unknown) => {
|
|
results.error = err
|
|
}
|
|
|
|
await Strategy.prototype.authorizationRequest.call(
|
|
strategy,
|
|
{ session } as any,
|
|
{},
|
|
)
|
|
|
|
t.falsy(results.error)
|
|
const url = new URL(results.redirectTo!)
|
|
// PAR replaces individual params with request_uri
|
|
t.truthy(url.searchParams.get('request_uri'))
|
|
t.truthy(url.searchParams.get('client_id'))
|
|
// Individual params should not be in the redirect URL
|
|
t.falsy(url.searchParams.get('code_challenge'))
|
|
})
|
|
|
|
// --- Multiple resource values ---
|
|
|
|
test('authorizationRequest includes multiple resource values from array', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const redirectTo = await doAuthorizationRequest(harness, {
|
|
resource: ['https://rs1.example.com', 'https://rs2.example.com'],
|
|
})
|
|
|
|
const url = new URL(redirectTo)
|
|
const resources = url.searchParams.getAll('resource')
|
|
t.is(resources.length, 2)
|
|
t.is(resources[0], 'https://rs1.example.com')
|
|
t.is(resources[1], 'https://rs2.example.com')
|
|
})
|
|
|
|
// --- Option-level scope overrides strategy-level scope ---
|
|
|
|
test('option-level scope takes precedence over strategy-level scope', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port, undefined, {
|
|
scope: 'openid',
|
|
})
|
|
const redirectTo = await doAuthorizationRequest(harness, {
|
|
scope: 'openid profile email',
|
|
})
|
|
|
|
const url = new URL(redirectTo)
|
|
t.is(url.searchParams.get('scope'), 'openid profile email')
|
|
})
|
|
|
|
// --- Custom sessionKey isolates state between strategies ---
|
|
|
|
test('custom sessionKey isolates state from another strategy', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness1 = createStrategyHarness(port, undefined, {
|
|
sessionKey: 'strategy-a',
|
|
})
|
|
const harness2 = createStrategyHarness(port, undefined, {
|
|
sessionKey: 'strategy-b',
|
|
})
|
|
|
|
// Share the same session
|
|
harness2.session = harness1.session
|
|
|
|
await doAuthorizationRequest(harness1)
|
|
await doAuthorizationRequest(harness2)
|
|
|
|
t.truthy(harness1.session['strategy-a'])
|
|
t.truthy(harness1.session['strategy-b'])
|
|
t.not(harness1.session['strategy-a'], harness1.session['strategy-b'])
|
|
})
|
|
|
|
// --- Session state cleanup ---
|
|
|
|
test('successful callback calls success and clears session state', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const redirectTo = await doAuthorizationRequest(harness)
|
|
|
|
await doAuthorizationCodeGrant(
|
|
harness,
|
|
`${new URL(redirectTo).origin}/cb?code=ok`,
|
|
)
|
|
|
|
t.truthy(harness.results.successUser)
|
|
t.deepEqual(harness.session, {})
|
|
})
|
|
|
|
test('replayed callback fails because state was consumed', async (t) => {
|
|
const { port, server } = await startTokenEndpoint()
|
|
t.teardown(() => close(server))
|
|
|
|
const harness = createStrategyHarness(port)
|
|
const redirectTo = await doAuthorizationRequest(harness)
|
|
const callbackUrl = `${new URL(redirectTo).origin}/cb?code=ok`
|
|
|
|
// First callback succeeds
|
|
await doAuthorizationCodeGrant(harness, callbackUrl)
|
|
t.truthy(harness.results.successUser)
|
|
|
|
// Reset results
|
|
harness.results.successUser = undefined
|
|
harness.results.failInfo = undefined
|
|
|
|
// Second callback with same session should fail
|
|
await doAuthorizationCodeGrant(harness, callbackUrl)
|
|
t.truthy(harness.results.failInfo)
|
|
t.like(harness.results.failInfo as Record<string, unknown>, {
|
|
message: 'Unable to verify authorization request state',
|
|
})
|
|
})
|