244 lines
7 KiB
JavaScript
244 lines
7 KiB
JavaScript
|
|
import assert from 'node:assert/strict'
|
||
|
|
import { createSign, generateKeyPairSync } from 'node:crypto'
|
||
|
|
import { test } from 'node:test'
|
||
|
|
|
||
|
|
import { OAuthClient } from './oauth.mjs'
|
||
|
|
import { SESSION_COOKIE } from './sessions.mjs'
|
||
|
|
|
||
|
|
const identifier = 'a'.repeat(43)
|
||
|
|
const replacement = 'b'.repeat(43)
|
||
|
|
const now = Math.floor(Date.now() / 1000)
|
||
|
|
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
|
||
|
|
modulusLength: 2048,
|
||
|
|
})
|
||
|
|
const publicJwk = publicKey.export({ format: 'jwk' })
|
||
|
|
publicJwk.kid = 'test-key'
|
||
|
|
publicJwk.alg = 'RS256'
|
||
|
|
publicJwk.use = 'sig'
|
||
|
|
|
||
|
|
const config = {
|
||
|
|
publicOrigin: 'https://remora.example.com',
|
||
|
|
oidcAuthority: 'https://identity.example.com',
|
||
|
|
oidcClientId: 'remora client',
|
||
|
|
oidcClientSecret: 'secret:value',
|
||
|
|
oidcScope: 'openid profile',
|
||
|
|
secureCookie: true,
|
||
|
|
}
|
||
|
|
|
||
|
|
test('completes authorization code flow without exposing tokens to the browser', async () => {
|
||
|
|
const store = new MemoryStore()
|
||
|
|
const requests = []
|
||
|
|
const oauth = new OAuthClient(config, store, async (url, init = {}) => {
|
||
|
|
requests.push({ url: String(url), init })
|
||
|
|
if (String(url).endsWith('/.well-known/openid-configuration'))
|
||
|
|
return Response.json(metadata())
|
||
|
|
if (String(url) === metadata().token_endpoint) {
|
||
|
|
assert.equal(
|
||
|
|
init.headers.Authorization,
|
||
|
|
`Basic ${Buffer.from('remora+client:secret%3Avalue').toString('base64')}`,
|
||
|
|
)
|
||
|
|
const parameters = new URLSearchParams(init.body)
|
||
|
|
assert.equal(parameters.get('code_verifier'), store.value.verifier)
|
||
|
|
return Response.json({
|
||
|
|
access_token: 'server-only-access-token',
|
||
|
|
refresh_token: 'server-only-refresh-token',
|
||
|
|
id_token: idToken(store.value.nonce),
|
||
|
|
expires_in: 300,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
if (String(url) === metadata().jwks_uri)
|
||
|
|
return Response.json({ keys: [publicJwk] })
|
||
|
|
throw new Error(`unexpected request: ${url}`)
|
||
|
|
})
|
||
|
|
|
||
|
|
const login = new TestResponse()
|
||
|
|
await oauth.login({ headers: {} }, login)
|
||
|
|
const authorization = new URL(login.headers.Location)
|
||
|
|
assert.equal(authorization.searchParams.get('code_challenge_method'), 'S256')
|
||
|
|
assert.equal(authorization.searchParams.get('state'), store.value.state)
|
||
|
|
assert.equal(authorization.searchParams.get('nonce'), store.value.nonce)
|
||
|
|
assert.match(login.headers['Set-Cookie'], /HttpOnly; Secure; SameSite=Strict/)
|
||
|
|
|
||
|
|
const callback = new TestResponse()
|
||
|
|
await oauth.callback(
|
||
|
|
{ headers: { cookie: `${SESSION_COOKIE}=${identifier}` } },
|
||
|
|
callback,
|
||
|
|
new URL(
|
||
|
|
`/auth/callback?code=authorization-code&state=${store.value.state}`,
|
||
|
|
config.publicOrigin,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
assert.equal(callback.status, 302)
|
||
|
|
assert.equal(callback.headers.Location, config.publicOrigin)
|
||
|
|
assert.match(callback.headers['Set-Cookie'], new RegExp(replacement))
|
||
|
|
assert.equal(store.value.kind, 'authenticated')
|
||
|
|
assert.equal(store.value.identity, `${config.oidcAuthority}\u0000subject-123`)
|
||
|
|
assert.equal(store.value.accessToken, 'server-only-access-token')
|
||
|
|
assert.equal(requests.length, 3)
|
||
|
|
|
||
|
|
const session = new TestResponse()
|
||
|
|
await oauth.session(
|
||
|
|
{ headers: { cookie: `${SESSION_COOKIE}=${replacement}` } },
|
||
|
|
session,
|
||
|
|
)
|
||
|
|
assert.deepEqual(JSON.parse(session.body), {
|
||
|
|
kind: 'authenticated',
|
||
|
|
identity: `${config.oidcAuthority}\u0000subject-123`,
|
||
|
|
csrfToken: store.value.csrfToken,
|
||
|
|
})
|
||
|
|
assert.doesNotMatch(session.body, /access|refresh|id_token/i)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('destroys pending authorization when nonce validation fails', async () => {
|
||
|
|
const store = new MemoryStore()
|
||
|
|
const oauth = new OAuthClient(config, store, async (url) => {
|
||
|
|
if (String(url).endsWith('/.well-known/openid-configuration'))
|
||
|
|
return Response.json(metadata())
|
||
|
|
if (String(url) === metadata().token_endpoint)
|
||
|
|
return Response.json({
|
||
|
|
access_token: 'access',
|
||
|
|
id_token: idToken('wrong-nonce'),
|
||
|
|
expires_in: 300,
|
||
|
|
})
|
||
|
|
if (String(url) === metadata().jwks_uri)
|
||
|
|
return Response.json({ keys: [publicJwk] })
|
||
|
|
throw new Error(`unexpected request: ${url}`)
|
||
|
|
})
|
||
|
|
await oauth.login({ headers: {} }, new TestResponse())
|
||
|
|
const response = new TestResponse()
|
||
|
|
|
||
|
|
await oauth.callback(
|
||
|
|
{ headers: { cookie: `${SESSION_COOKIE}=${identifier}` } },
|
||
|
|
response,
|
||
|
|
new URL(
|
||
|
|
`/auth/callback?code=authorization-code&state=${store.value.state}`,
|
||
|
|
config.publicOrigin,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
assert.equal(response.status, 302)
|
||
|
|
assert.equal(
|
||
|
|
response.headers.Location,
|
||
|
|
`${config.publicOrigin}/?authentication=failed`,
|
||
|
|
)
|
||
|
|
assert.equal(store.value, null)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('reauthentication destroys the preceding browser session', async () => {
|
||
|
|
const store = new MemoryStore()
|
||
|
|
store.value = validSession()
|
||
|
|
const oauth = new OAuthClient(config, store, async () =>
|
||
|
|
Response.json(metadata()),
|
||
|
|
)
|
||
|
|
|
||
|
|
await oauth.login(
|
||
|
|
{ headers: { cookie: `${SESSION_COOKIE}=${replacement}` } },
|
||
|
|
new TestResponse(),
|
||
|
|
)
|
||
|
|
|
||
|
|
assert.deepEqual(store.destroyed, [replacement])
|
||
|
|
assert.equal(store.value.kind, 'authorization')
|
||
|
|
})
|
||
|
|
|
||
|
|
test('expired and malformed authenticated sessions are destroyed', async () => {
|
||
|
|
const store = new MemoryStore()
|
||
|
|
const oauth = new OAuthClient(config, store)
|
||
|
|
store.value = { kind: 'authenticated', accessToken: 'incomplete' }
|
||
|
|
assert.equal(
|
||
|
|
await oauth.authenticated(
|
||
|
|
{ headers: { cookie: `${SESSION_COOKIE}=${identifier}` } },
|
||
|
|
false,
|
||
|
|
),
|
||
|
|
null,
|
||
|
|
)
|
||
|
|
store.value = { ...validSession(), accessExpiresAt: now - 1 }
|
||
|
|
assert.equal(
|
||
|
|
await oauth.authenticated(
|
||
|
|
{ headers: { cookie: `${SESSION_COOKIE}=${identifier}` } },
|
||
|
|
false,
|
||
|
|
),
|
||
|
|
null,
|
||
|
|
)
|
||
|
|
assert.deepEqual(store.destroyed, [identifier, identifier])
|
||
|
|
})
|
||
|
|
|
||
|
|
class MemoryStore {
|
||
|
|
value = null
|
||
|
|
destroyed = []
|
||
|
|
|
||
|
|
async create(value) {
|
||
|
|
this.value = value
|
||
|
|
return identifier
|
||
|
|
}
|
||
|
|
|
||
|
|
async read() {
|
||
|
|
return this.value
|
||
|
|
}
|
||
|
|
|
||
|
|
async replace(_identifier, value) {
|
||
|
|
this.value = value
|
||
|
|
return replacement
|
||
|
|
}
|
||
|
|
|
||
|
|
async destroy(identifier) {
|
||
|
|
this.destroyed.push(identifier)
|
||
|
|
this.value = null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class TestResponse {
|
||
|
|
status = 0
|
||
|
|
headers = {}
|
||
|
|
body = ''
|
||
|
|
|
||
|
|
writeHead(status, headers) {
|
||
|
|
this.status = status
|
||
|
|
this.headers = headers
|
||
|
|
}
|
||
|
|
|
||
|
|
end(body = '') {
|
||
|
|
this.body = body
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function metadata() {
|
||
|
|
return {
|
||
|
|
issuer: config.oidcAuthority,
|
||
|
|
authorization_endpoint: `${config.oidcAuthority}/oauth/authorize`,
|
||
|
|
token_endpoint: `${config.oidcAuthority}/oauth/token`,
|
||
|
|
jwks_uri: `${config.oidcAuthority}/oauth/keys`,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function validSession() {
|
||
|
|
return {
|
||
|
|
kind: 'authenticated',
|
||
|
|
identity: `${config.oidcAuthority}\u0000subject-123`,
|
||
|
|
csrfToken: 'csrf-value',
|
||
|
|
accessToken: 'access-token',
|
||
|
|
refreshToken: null,
|
||
|
|
idToken: 'id-token',
|
||
|
|
accessExpiresAt: now + 300,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function idToken(nonce) {
|
||
|
|
const header = encode({ alg: 'RS256', kid: publicJwk.kid })
|
||
|
|
const payload = encode({
|
||
|
|
iss: config.oidcAuthority,
|
||
|
|
aud: config.oidcClientId,
|
||
|
|
sub: 'subject-123',
|
||
|
|
nonce,
|
||
|
|
iat: now,
|
||
|
|
exp: now + 300,
|
||
|
|
})
|
||
|
|
const signer = createSign('RSA-SHA256')
|
||
|
|
signer.update(`${header}.${payload}`)
|
||
|
|
signer.end()
|
||
|
|
return `${header}.${payload}.${signer.sign(privateKey).toString('base64url')}`
|
||
|
|
}
|
||
|
|
|
||
|
|
function encode(value) {
|
||
|
|
return Buffer.from(JSON.stringify(value)).toString('base64url')
|
||
|
|
}
|