remora/web/server/oauth.test.mjs

244 lines
7 KiB
JavaScript
Raw Normal View History

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