Move browser authentication behind a generated-contract BFF
This commit is contained in:
parent
6f0f20f5cc
commit
fa7220ee33
1 changed files with 7259 additions and 2825 deletions
244
web/server/oauth.test.mjs
Normal file
244
web/server/oauth.test.mjs
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
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')
|
||||
}
|
||||
Loading…
Reference in a new issue