Files

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',
})
})