Move browser authentication behind a generated-contract BFF
This commit is contained in:
parent
6f0f20f5cc
commit
fa7220ee33
6 changed files with 7259 additions and 2825 deletions
61
web/server/config.mjs
Normal file
61
web/server/config.mjs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
export function loadConfig(environment = process.env) {
|
||||
const publicOrigin = required(environment, 'REMORA_WEB_PUBLIC_ORIGIN')
|
||||
const apiUpstream = required(environment, 'REMORA_WEB_API_UPSTREAM')
|
||||
const oidcAuthority = required(environment, 'REMORA_WEB_OIDC_AUTHORITY')
|
||||
return {
|
||||
root: environment.REMORA_WEB_ROOT || '/app/dist',
|
||||
port: port(environment.REMORA_WEB_PORT || '8080'),
|
||||
publicOrigin: new URL(publicOrigin).origin,
|
||||
apiUpstream: trailingSlash(new URL(apiUpstream)),
|
||||
eventsUpstream: webSocketUrl(
|
||||
environment.REMORA_WEB_EVENTS_UPSTREAM ||
|
||||
new URL('events', trailingSlash(new URL(apiUpstream))).toString(),
|
||||
),
|
||||
oidcAuthority: oidcAuthority.replace(/\/$/, ''),
|
||||
oidcClientId: required(environment, 'REMORA_WEB_OIDC_CLIENT_ID'),
|
||||
oidcClientSecret: required(environment, 'REMORA_WEB_OIDC_CLIENT_SECRET'),
|
||||
oidcScope: environment.REMORA_WEB_OIDC_SCOPE || 'openid profile',
|
||||
sessionUrl: required(environment, 'REMORA_WEB_SESSION_URL'),
|
||||
sessionNamespace:
|
||||
environment.REMORA_WEB_SESSION_NAMESPACE || 'remora:web-session:',
|
||||
sessionTtlSeconds: positiveInteger(
|
||||
environment.REMORA_WEB_SESSION_TTL_SECONDS || '28800',
|
||||
'REMORA_WEB_SESSION_TTL_SECONDS',
|
||||
),
|
||||
secureCookie: true,
|
||||
}
|
||||
}
|
||||
|
||||
function required(environment, name) {
|
||||
const value = environment[name]
|
||||
if (value === undefined || value === '')
|
||||
throw new Error(`${name} is required`)
|
||||
return value
|
||||
}
|
||||
|
||||
function port(value) {
|
||||
const parsed = positiveInteger(value, 'REMORA_WEB_PORT')
|
||||
if (parsed > 65535) throw new Error('REMORA_WEB_PORT must be a valid port')
|
||||
return parsed
|
||||
}
|
||||
|
||||
function positiveInteger(value, name) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1)
|
||||
throw new Error(`${name} must be a positive integer`)
|
||||
return parsed
|
||||
}
|
||||
|
||||
function trailingSlash(url) {
|
||||
if (!url.pathname.endsWith('/')) url.pathname += '/'
|
||||
return url
|
||||
}
|
||||
|
||||
function webSocketUrl(value) {
|
||||
const url = new URL(value)
|
||||
if (url.protocol === 'http:') url.protocol = 'ws:'
|
||||
else if (url.protocol === 'https:') url.protocol = 'wss:'
|
||||
if (url.protocol !== 'ws:' && url.protocol !== 'wss:')
|
||||
throw new Error('REMORA_WEB_EVENTS_UPSTREAM must use HTTP or WebSocket')
|
||||
return url
|
||||
}
|
||||
395
web/server/oauth.mjs
Normal file
395
web/server/oauth.mjs
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
import { createHash, createPublicKey, createVerify } from 'node:crypto'
|
||||
|
||||
import {
|
||||
equalSecret,
|
||||
expiredSessionCookie,
|
||||
randomValue,
|
||||
sessionCookie,
|
||||
sessionIdentifier,
|
||||
} from './sessions.mjs'
|
||||
|
||||
const CLOCK_SKEW_SECONDS = 30
|
||||
const OIDC_TIMEOUT_MILLISECONDS = 15_000
|
||||
|
||||
export class OAuthClient {
|
||||
constructor(config, store, fetchImplementation = fetch) {
|
||||
this.config = config
|
||||
this.store = store
|
||||
this.fetch = fetchImplementation
|
||||
this.discovery = null
|
||||
}
|
||||
|
||||
async route(request, response, url) {
|
||||
if (request.method === 'GET' && url.pathname === '/auth/login')
|
||||
return this.login(request, response)
|
||||
if (request.method === 'GET' && url.pathname === '/auth/callback')
|
||||
return this.callback(request, response, url)
|
||||
if (request.method === 'GET' && url.pathname === '/auth/session')
|
||||
return this.session(request, response)
|
||||
if (request.method === 'POST' && url.pathname === '/auth/logout')
|
||||
return this.logout(request, response)
|
||||
return false
|
||||
}
|
||||
|
||||
async login(request, response) {
|
||||
const previous = sessionIdentifier(request)
|
||||
if (previous !== null) await this.store.destroy(previous)
|
||||
const discovery = await this.metadata()
|
||||
const verifier = randomValue(48)
|
||||
const state = randomValue()
|
||||
const nonce = randomValue()
|
||||
const identifier = await this.store.create({
|
||||
kind: 'authorization',
|
||||
state,
|
||||
nonce,
|
||||
verifier,
|
||||
})
|
||||
const authorization = new URL(discovery.authorization_endpoint)
|
||||
authorization.search = new URLSearchParams({
|
||||
client_id: this.config.oidcClientId,
|
||||
redirect_uri: `${this.config.publicOrigin}/auth/callback`,
|
||||
response_type: 'code',
|
||||
scope: this.config.oidcScope,
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
|
||||
code_challenge_method: 'S256',
|
||||
}).toString()
|
||||
response.writeHead(302, {
|
||||
Location: authorization.toString(),
|
||||
'Set-Cookie': sessionCookie(identifier, this.config),
|
||||
'Cache-Control': 'no-store',
|
||||
})
|
||||
response.end()
|
||||
return true
|
||||
}
|
||||
|
||||
async callback(request, response, url) {
|
||||
const identifier = sessionIdentifier(request)
|
||||
const pending = identifier && (await this.store.read(identifier))
|
||||
const state = url.searchParams.get('state')
|
||||
const code = url.searchParams.get('code')
|
||||
if (
|
||||
pending?.kind !== 'authorization' ||
|
||||
code === null ||
|
||||
state === null ||
|
||||
!equalSecret(state, pending.state)
|
||||
)
|
||||
return this.authenticationFailure(response, identifier)
|
||||
|
||||
try {
|
||||
const discovery = await this.metadata()
|
||||
const tokenResponse = await this.fetch(discovery.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: clientAuthorization(this.config),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: `${this.config.publicOrigin}/auth/callback`,
|
||||
code_verifier: pending.verifier,
|
||||
}),
|
||||
signal: AbortSignal.timeout(OIDC_TIMEOUT_MILLISECONDS),
|
||||
})
|
||||
if (!tokenResponse.ok) throw new Error('token exchange failed')
|
||||
const tokens = await tokenResponse.json()
|
||||
const identity = await this.validateIdToken(
|
||||
tokens.id_token,
|
||||
pending.nonce,
|
||||
)
|
||||
const csrfToken = randomValue()
|
||||
const session = authenticatedSession(tokens, identity, csrfToken)
|
||||
const replacement = await this.store.replace(identifier, session)
|
||||
response.writeHead(302, {
|
||||
Location: this.config.publicOrigin,
|
||||
'Set-Cookie': sessionCookie(replacement, this.config),
|
||||
'Cache-Control': 'no-store',
|
||||
})
|
||||
response.end()
|
||||
} catch {
|
||||
await this.authenticationFailure(response, identifier)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async session(request, response) {
|
||||
const loaded = await this.authenticated(request)
|
||||
if (loaded === null) {
|
||||
json(
|
||||
response,
|
||||
200,
|
||||
{ kind: 'signed_out' },
|
||||
{
|
||||
'Set-Cookie': expiredSessionCookie(this.config),
|
||||
},
|
||||
)
|
||||
return true
|
||||
}
|
||||
json(
|
||||
response,
|
||||
200,
|
||||
{
|
||||
kind: 'authenticated',
|
||||
identity: loaded.session.identity,
|
||||
csrfToken: loaded.session.csrfToken,
|
||||
},
|
||||
loaded.rotated
|
||||
? { 'Set-Cookie': sessionCookie(loaded.identifier, this.config) }
|
||||
: {},
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
async logout(request, response) {
|
||||
const loaded = await this.authenticated(request, false)
|
||||
if (
|
||||
loaded === null ||
|
||||
!validUnsafeRequest(request, this.config, loaded.session.csrfToken)
|
||||
) {
|
||||
json(response, 403, { error: 'invalid_csrf' })
|
||||
return true
|
||||
}
|
||||
await this.store.destroy(loaded.identifier)
|
||||
const discovery = await this.metadata().catch(() => null)
|
||||
let location = this.config.publicOrigin
|
||||
if (discovery?.end_session_endpoint) {
|
||||
const logout = new URL(discovery.end_session_endpoint)
|
||||
logout.searchParams.set(
|
||||
'post_logout_redirect_uri',
|
||||
this.config.publicOrigin,
|
||||
)
|
||||
logout.searchParams.set('id_token_hint', loaded.session.idToken)
|
||||
location = logout.toString()
|
||||
}
|
||||
json(
|
||||
response,
|
||||
200,
|
||||
{ redirect: location },
|
||||
{ 'Set-Cookie': expiredSessionCookie(this.config) },
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
async authenticated(request, refresh = true) {
|
||||
const identifier = sessionIdentifier(request)
|
||||
if (identifier === null) return null
|
||||
let session = await this.store.read(identifier)
|
||||
if (!validAuthenticatedSession(session)) {
|
||||
if (session?.kind === 'authenticated')
|
||||
await this.store.destroy(identifier)
|
||||
return null
|
||||
}
|
||||
let rotated = false
|
||||
if (session.accessExpiresAt <= now() + CLOCK_SKEW_SECONDS) {
|
||||
if (!refresh) {
|
||||
await this.store.destroy(identifier)
|
||||
return null
|
||||
}
|
||||
session = await this.refresh(session).catch(() => null)
|
||||
if (session === null) {
|
||||
await this.store.destroy(identifier)
|
||||
return null
|
||||
}
|
||||
identifier = await this.store.replace(identifier, session)
|
||||
rotated = true
|
||||
}
|
||||
return { identifier, session, rotated }
|
||||
}
|
||||
|
||||
async refresh(session) {
|
||||
if (typeof session.refreshToken !== 'string') return null
|
||||
const discovery = await this.metadata()
|
||||
const response = await this.fetch(discovery.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: clientAuthorization(this.config),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: session.refreshToken,
|
||||
}),
|
||||
signal: AbortSignal.timeout(OIDC_TIMEOUT_MILLISECONDS),
|
||||
})
|
||||
if (!response.ok) return null
|
||||
const tokens = await response.json()
|
||||
const idToken =
|
||||
typeof tokens.id_token === 'string' ? tokens.id_token : session.idToken
|
||||
const identity =
|
||||
typeof tokens.id_token === 'string'
|
||||
? await this.validateIdToken(tokens.id_token, undefined)
|
||||
: session.identity
|
||||
if (identity !== session.identity) return null
|
||||
return authenticatedSession(
|
||||
{
|
||||
...tokens,
|
||||
id_token: idToken,
|
||||
refresh_token:
|
||||
typeof tokens.refresh_token === 'string'
|
||||
? tokens.refresh_token
|
||||
: session.refreshToken,
|
||||
},
|
||||
identity,
|
||||
session.csrfToken,
|
||||
)
|
||||
}
|
||||
|
||||
async metadata() {
|
||||
if (this.discovery !== null) return this.discovery
|
||||
const response = await this.fetch(
|
||||
`${this.config.oidcAuthority}/.well-known/openid-configuration`,
|
||||
{ signal: AbortSignal.timeout(OIDC_TIMEOUT_MILLISECONDS) },
|
||||
)
|
||||
if (!response.ok) throw new Error('OIDC discovery failed')
|
||||
const metadata = await response.json()
|
||||
for (const field of [
|
||||
'issuer',
|
||||
'authorization_endpoint',
|
||||
'token_endpoint',
|
||||
'jwks_uri',
|
||||
])
|
||||
if (typeof metadata[field] !== 'string')
|
||||
throw new Error(`OIDC discovery omitted ${field}`)
|
||||
if (metadata.issuer !== this.config.oidcAuthority)
|
||||
throw new Error('OIDC discovery issuer mismatch')
|
||||
this.discovery = metadata
|
||||
return metadata
|
||||
}
|
||||
|
||||
async validateIdToken(token, nonce) {
|
||||
const [headerPart, payloadPart, signaturePart] = String(token).split('.')
|
||||
if (!headerPart || !payloadPart || !signaturePart)
|
||||
throw new Error('invalid ID token')
|
||||
const header = JSON.parse(Buffer.from(headerPart, 'base64url').toString())
|
||||
const claims = JSON.parse(Buffer.from(payloadPart, 'base64url').toString())
|
||||
if (header.alg !== 'RS256' || typeof header.kid !== 'string')
|
||||
throw new Error('unsupported ID token signature')
|
||||
const discovery = await this.metadata()
|
||||
const response = await this.fetch(discovery.jwks_uri, {
|
||||
signal: AbortSignal.timeout(OIDC_TIMEOUT_MILLISECONDS),
|
||||
})
|
||||
if (!response.ok) throw new Error('JWKS fetch failed')
|
||||
const jwks = await response.json()
|
||||
const key = jwks.keys?.find((candidate) => candidate.kid === header.kid)
|
||||
if (key === undefined) throw new Error('ID token key missing')
|
||||
const verifier = createVerify('RSA-SHA256')
|
||||
verifier.update(`${headerPart}.${payloadPart}`)
|
||||
verifier.end()
|
||||
if (
|
||||
!verifier.verify(
|
||||
createPublicKey({ key, format: 'jwk' }),
|
||||
Buffer.from(signaturePart, 'base64url'),
|
||||
)
|
||||
)
|
||||
throw new Error('invalid ID token signature')
|
||||
const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud]
|
||||
if (
|
||||
claims.iss !== this.config.oidcAuthority ||
|
||||
!audience.includes(this.config.oidcClientId) ||
|
||||
(audience.length > 1 && claims.azp !== this.config.oidcClientId) ||
|
||||
(nonce !== undefined && claims.nonce !== nonce) ||
|
||||
typeof claims.sub !== 'string' ||
|
||||
typeof claims.iat !== 'number' ||
|
||||
claims.iat > now() + CLOCK_SKEW_SECONDS ||
|
||||
typeof claims.exp !== 'number' ||
|
||||
claims.exp <= now() - CLOCK_SKEW_SECONDS
|
||||
)
|
||||
throw new Error('invalid ID token claims')
|
||||
return `${claims.iss}\u0000${claims.sub}`
|
||||
}
|
||||
|
||||
async authenticationFailure(response, identifier) {
|
||||
if (identifier) await this.store.destroy(identifier)
|
||||
response.writeHead(302, {
|
||||
Location: `${this.config.publicOrigin}/?authentication=failed`,
|
||||
'Set-Cookie': expiredSessionCookie(this.config),
|
||||
'Cache-Control': 'no-store',
|
||||
})
|
||||
response.end()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function validUnsafeRequest(request, config, csrfToken) {
|
||||
return (
|
||||
validBrowserContext(request, config) &&
|
||||
equalSecret(request.headers['x-remora-csrf'], csrfToken)
|
||||
)
|
||||
}
|
||||
|
||||
export function validBrowserContext(request, config) {
|
||||
const fetchSite = request.headers['sec-fetch-site']
|
||||
return (
|
||||
request.headers.origin === config.publicOrigin &&
|
||||
(fetchSite === undefined ||
|
||||
fetchSite === 'same-origin' ||
|
||||
fetchSite === 'none')
|
||||
)
|
||||
}
|
||||
|
||||
function authenticatedSession(tokens, identity, csrfToken) {
|
||||
if (
|
||||
typeof tokens.access_token !== 'string' ||
|
||||
typeof tokens.id_token !== 'string' ||
|
||||
typeof tokens.expires_in !== 'number' ||
|
||||
!Number.isFinite(tokens.expires_in) ||
|
||||
tokens.expires_in <= 0
|
||||
)
|
||||
throw new Error('token response incomplete')
|
||||
return {
|
||||
kind: 'authenticated',
|
||||
identity,
|
||||
csrfToken,
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken:
|
||||
typeof tokens.refresh_token === 'string' ? tokens.refresh_token : null,
|
||||
idToken: tokens.id_token,
|
||||
accessExpiresAt: now() + tokens.expires_in,
|
||||
}
|
||||
}
|
||||
|
||||
function validAuthenticatedSession(session) {
|
||||
return (
|
||||
session?.kind === 'authenticated' &&
|
||||
typeof session.identity === 'string' &&
|
||||
session.identity !== '' &&
|
||||
typeof session.csrfToken === 'string' &&
|
||||
session.csrfToken !== '' &&
|
||||
typeof session.accessToken === 'string' &&
|
||||
session.accessToken !== '' &&
|
||||
(session.refreshToken === null ||
|
||||
(typeof session.refreshToken === 'string' &&
|
||||
session.refreshToken !== '')) &&
|
||||
typeof session.idToken === 'string' &&
|
||||
session.idToken !== '' &&
|
||||
Number.isFinite(session.accessExpiresAt) &&
|
||||
session.accessExpiresAt > 0
|
||||
)
|
||||
}
|
||||
|
||||
function clientAuthorization(config) {
|
||||
const identifier = formComponent(config.oidcClientId)
|
||||
const secret = formComponent(config.oidcClientSecret)
|
||||
return `Basic ${Buffer.from(`${identifier}:${secret}`).toString('base64')}`
|
||||
}
|
||||
|
||||
function formComponent(value) {
|
||||
return new URLSearchParams({ value }).toString().slice('value='.length)
|
||||
}
|
||||
|
||||
function now() {
|
||||
return Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
function json(response, status, value, headers = {}) {
|
||||
const body = JSON.stringify(value)
|
||||
response.writeHead(status, {
|
||||
...headers,
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
})
|
||||
response.end(body)
|
||||
}
|
||||
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')
|
||||
}
|
||||
197
web/server/proxy.mjs
Normal file
197
web/server/proxy.mjs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { WebSocket, WebSocketServer } from 'ws'
|
||||
|
||||
import { validBrowserContext, validUnsafeRequest } from './oauth.mjs'
|
||||
import { equalSecret, sessionCookie } from './sessions.mjs'
|
||||
|
||||
const MAX_REQUEST_BYTES = 1024 * 1024
|
||||
const MAX_EVENT_BYTES = 1024 * 1024
|
||||
const API_TIMEOUT_MILLISECONDS = 30_000
|
||||
|
||||
export class ApiProxy {
|
||||
constructor(config, oauth, fetchImplementation = fetch) {
|
||||
this.config = config
|
||||
this.oauth = oauth
|
||||
this.fetch = fetchImplementation
|
||||
this.webSockets = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: MAX_EVENT_BYTES,
|
||||
})
|
||||
}
|
||||
|
||||
async route(request, response, url) {
|
||||
if (!url.pathname.startsWith('/bff/api/')) return false
|
||||
if (!['GET', 'POST'].includes(request.method)) {
|
||||
response.writeHead(405, { Allow: 'GET, POST' })
|
||||
response.end()
|
||||
return true
|
||||
}
|
||||
const loaded = await this.oauth.authenticated(request)
|
||||
if (loaded === null) {
|
||||
response.writeHead(401, { 'Cache-Control': 'no-store' })
|
||||
response.end()
|
||||
return true
|
||||
}
|
||||
if (
|
||||
request.method !== 'GET' &&
|
||||
!validUnsafeRequest(request, this.config, loaded.session.csrfToken)
|
||||
) {
|
||||
requestEvent('csrf_rejected', {
|
||||
origin_matches: request.headers.origin === this.config.publicOrigin,
|
||||
fetch_site: request.headers['sec-fetch-site'] ?? 'absent',
|
||||
csrf_present: typeof request.headers['x-remora-csrf'] === 'string',
|
||||
csrf_matches: equalSecret(
|
||||
request.headers['x-remora-csrf'],
|
||||
loaded.session.csrfToken,
|
||||
),
|
||||
})
|
||||
response.writeHead(403, { 'Cache-Control': 'no-store' })
|
||||
response.end()
|
||||
return true
|
||||
}
|
||||
const suffix = url.pathname.slice('/bff/api/'.length)
|
||||
if (suffix.startsWith('/')) {
|
||||
response.writeHead(400, { 'Cache-Control': 'no-store' })
|
||||
response.end()
|
||||
return true
|
||||
}
|
||||
const upstream = new URL(suffix, this.config.apiUpstream)
|
||||
upstream.search = url.search
|
||||
if (
|
||||
upstream.origin !== this.config.apiUpstream.origin ||
|
||||
!upstream.pathname.startsWith(this.config.apiUpstream.pathname)
|
||||
) {
|
||||
response.writeHead(400, { 'Cache-Control': 'no-store' })
|
||||
response.end()
|
||||
return true
|
||||
}
|
||||
const headers = {
|
||||
Accept: request.headers.accept || 'application/json',
|
||||
Authorization: `Bearer ${loaded.session.accessToken}`,
|
||||
}
|
||||
if (request.method === 'POST' && suffix === 'event-tickets')
|
||||
headers.Origin = this.config.publicOrigin
|
||||
copyHeader(request, headers, 'content-type')
|
||||
copyHeader(request, headers, 'idempotency-key')
|
||||
copyHeader(request, headers, 'traceparent')
|
||||
let body
|
||||
try {
|
||||
body = request.method === 'GET' ? undefined : await requestBody(request)
|
||||
} catch (error) {
|
||||
if (!(error instanceof RequestBodyTooLarge)) throw error
|
||||
response.writeHead(413, { 'Cache-Control': 'no-store' })
|
||||
response.end()
|
||||
return true
|
||||
}
|
||||
const upstreamResponse = await this.fetch(upstream, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body,
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(API_TIMEOUT_MILLISECONDS),
|
||||
})
|
||||
const responseHeaders = { 'Cache-Control': 'no-store' }
|
||||
if (loaded.rotated)
|
||||
responseHeaders['Set-Cookie'] = sessionCookie(
|
||||
loaded.identifier,
|
||||
this.config,
|
||||
)
|
||||
for (const name of ['content-type', 'traceparent'])
|
||||
if (upstreamResponse.headers.has(name))
|
||||
responseHeaders[name] = upstreamResponse.headers.get(name)
|
||||
response.writeHead(upstreamResponse.status, responseHeaders)
|
||||
response.end(Buffer.from(await upstreamResponse.arrayBuffer()))
|
||||
return true
|
||||
}
|
||||
|
||||
async upgrade(request, socket, head) {
|
||||
const url = new URL(request.url || '/', this.config.publicOrigin)
|
||||
if (url.pathname !== '/bff/events') return false
|
||||
const ticket = url.searchParams.get('ticket')
|
||||
const cursor = url.searchParams.get('cursor')
|
||||
const loaded = await this.oauth.authenticated(request, false)
|
||||
if (
|
||||
loaded === null ||
|
||||
ticket === null ||
|
||||
cursor === null ||
|
||||
!validBrowserContext(request, this.config)
|
||||
) {
|
||||
socketEvent('websocket_rejected')
|
||||
rejectUpgrade(socket, 401)
|
||||
return true
|
||||
}
|
||||
const target = new URL(this.config.eventsUpstream)
|
||||
target.search = new URLSearchParams({ ticket, cursor }).toString()
|
||||
this.webSockets.handleUpgrade(request, socket, head, (browser) => {
|
||||
const upstream = new WebSocket(target, {
|
||||
handshakeTimeout: 15_000,
|
||||
maxPayload: MAX_EVENT_BYTES,
|
||||
origin: this.config.publicOrigin,
|
||||
})
|
||||
upstream.on('open', () => socketEvent('websocket_upstream_open'))
|
||||
upstream.on('unexpected-response', (_request, response) =>
|
||||
socketEvent('websocket_upstream_rejected', response.statusCode),
|
||||
)
|
||||
upstream.on('error', () => socketEvent('websocket_upstream_error'))
|
||||
relay(browser, upstream)
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function copyHeader(request, headers, name) {
|
||||
const value = request.headers[name]
|
||||
if (typeof value === 'string') headers[name] = value
|
||||
}
|
||||
|
||||
async function requestBody(request) {
|
||||
const chunks = []
|
||||
let size = 0
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length
|
||||
if (size > MAX_REQUEST_BYTES) throw new RequestBodyTooLarge()
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
class RequestBodyTooLarge extends Error {}
|
||||
|
||||
function relay(browser, upstream) {
|
||||
upstream.on('open', () => undefined)
|
||||
browser.on('message', (data, binary) => {
|
||||
if (upstream.readyState === WebSocket.OPEN) upstream.send(data, { binary })
|
||||
})
|
||||
upstream.on('message', (data, binary) => {
|
||||
if (browser.readyState === WebSocket.OPEN) browser.send(data, { binary })
|
||||
})
|
||||
const closeBrowser = (code = 1011, reason = '') => {
|
||||
if (browser.readyState === WebSocket.OPEN) browser.close(code, reason)
|
||||
}
|
||||
const closeUpstream = (code = 1011, reason = '') => {
|
||||
if (upstream.readyState === WebSocket.OPEN) upstream.close(code, reason)
|
||||
}
|
||||
upstream.on('close', closeBrowser)
|
||||
browser.on('close', closeUpstream)
|
||||
upstream.on('error', () => closeBrowser())
|
||||
browser.on('error', () => closeUpstream())
|
||||
}
|
||||
|
||||
function rejectUpgrade(socket, status) {
|
||||
socket.end(`HTTP/1.1 ${status} Unauthorized\r\nConnection: close\r\n\r\n`)
|
||||
}
|
||||
|
||||
function socketEvent(event, status) {
|
||||
requestEvent(event, status === undefined ? {} : { status })
|
||||
}
|
||||
|
||||
function requestEvent(event, details) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: event.endsWith('open') ? 'info' : 'warn',
|
||||
component: 'remora-web',
|
||||
event,
|
||||
...details,
|
||||
})}\n`,
|
||||
)
|
||||
}
|
||||
149
web/server/security.test.mjs
Normal file
149
web/server/security.test.mjs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { Readable } from 'node:stream'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { validBrowserContext, validUnsafeRequest } from './oauth.mjs'
|
||||
import { ApiProxy } from './proxy.mjs'
|
||||
import {
|
||||
SESSION_COOKIE,
|
||||
expiredSessionCookie,
|
||||
sessionCookie,
|
||||
sessionIdentifier,
|
||||
} from './sessions.mjs'
|
||||
|
||||
const config = {
|
||||
publicOrigin: 'https://remora.example.com',
|
||||
secureCookie: true,
|
||||
}
|
||||
|
||||
test('session cookies are host-only, opaque, secure, and not script-readable', () => {
|
||||
const cookie = sessionCookie('opaque-session', config)
|
||||
assert.equal(
|
||||
cookie,
|
||||
`${SESSION_COOKIE}=opaque-session; Path=/; HttpOnly; Secure; SameSite=Strict`,
|
||||
)
|
||||
assert.doesNotMatch(cookie, /Domain=/)
|
||||
assert.match(expiredSessionCookie(config), /Max-Age=0/)
|
||||
assert.equal(
|
||||
sessionIdentifier({
|
||||
headers: { cookie: `unrelated=1; ${SESSION_COOKIE}=opaque-session` },
|
||||
}),
|
||||
'opaque-session',
|
||||
)
|
||||
})
|
||||
|
||||
test('unsafe requests require both same-origin context and the CSRF value', () => {
|
||||
assert.equal(
|
||||
validUnsafeRequest(
|
||||
{
|
||||
headers: {
|
||||
origin: config.publicOrigin,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-remora-csrf': 'csrf-value',
|
||||
},
|
||||
},
|
||||
config,
|
||||
'csrf-value',
|
||||
),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
validUnsafeRequest(
|
||||
{
|
||||
headers: {
|
||||
origin: 'https://attacker.invalid',
|
||||
'sec-fetch-site': 'cross-site',
|
||||
'x-remora-csrf': 'csrf-value',
|
||||
},
|
||||
},
|
||||
config,
|
||||
'csrf-value',
|
||||
),
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
validUnsafeRequest(
|
||||
{
|
||||
headers: {
|
||||
origin: config.publicOrigin,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-remora-csrf': 'wrong',
|
||||
},
|
||||
},
|
||||
config,
|
||||
'csrf-value',
|
||||
),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('browser WebSocket context must be same-origin', () => {
|
||||
assert.equal(
|
||||
validBrowserContext(
|
||||
{
|
||||
headers: {
|
||||
origin: config.publicOrigin,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
},
|
||||
config,
|
||||
),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
validBrowserContext(
|
||||
{
|
||||
headers: {
|
||||
origin: 'https://attacker.invalid',
|
||||
'sec-fetch-site': 'cross-site',
|
||||
},
|
||||
},
|
||||
config,
|
||||
),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('event ticket requests use the validated public origin upstream', async () => {
|
||||
let upstreamOptions
|
||||
const proxy = new ApiProxy(
|
||||
{
|
||||
...config,
|
||||
apiUpstream: new URL('https://api.remora.invalid/v1/'),
|
||||
eventsUpstream: new URL('wss://api.remora.invalid/v1/events'),
|
||||
},
|
||||
{
|
||||
authenticated: async () => ({
|
||||
rotated: false,
|
||||
session: {
|
||||
accessToken: 'server-token',
|
||||
csrfToken: 'csrf-value',
|
||||
},
|
||||
}),
|
||||
},
|
||||
async (_url, options) => {
|
||||
upstreamOptions = options
|
||||
return new Response('{}', {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
},
|
||||
)
|
||||
const request = Readable.from([])
|
||||
request.method = 'POST'
|
||||
request.headers = {
|
||||
origin: config.publicOrigin,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-remora-csrf': 'csrf-value',
|
||||
}
|
||||
const response = {
|
||||
writeHead: () => undefined,
|
||||
end: () => undefined,
|
||||
}
|
||||
await proxy.route(
|
||||
request,
|
||||
response,
|
||||
new URL(`${config.publicOrigin}/bff/api/event-tickets`),
|
||||
)
|
||||
assert.equal(upstreamOptions.headers.Origin, config.publicOrigin)
|
||||
})
|
||||
89
web/server/sessions.mjs
Normal file
89
web/server/sessions.mjs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
import { createClient } from 'redis'
|
||||
|
||||
export const SESSION_COOKIE = '__Host-remora-session'
|
||||
|
||||
export class SessionStore {
|
||||
constructor(config, client = createClient({ url: config.sessionUrl })) {
|
||||
this.config = config
|
||||
this.client = client
|
||||
}
|
||||
|
||||
async connect() {
|
||||
this.client.on('error', () => undefined)
|
||||
await this.client.connect()
|
||||
}
|
||||
|
||||
get ready() {
|
||||
return this.client.isReady
|
||||
}
|
||||
|
||||
async create(value) {
|
||||
const identifier = randomValue(32)
|
||||
await this.client.set(this.key(identifier), JSON.stringify(value), {
|
||||
EX: this.config.sessionTtlSeconds,
|
||||
})
|
||||
return identifier
|
||||
}
|
||||
|
||||
async read(identifier) {
|
||||
if (!validIdentifier(identifier)) return null
|
||||
const encoded = await this.client.get(this.key(identifier))
|
||||
if (encoded === null) return null
|
||||
try {
|
||||
return JSON.parse(encoded)
|
||||
} catch {
|
||||
await this.destroy(identifier)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async replace(identifier, value) {
|
||||
const replacement = await this.create(value)
|
||||
await this.destroy(identifier)
|
||||
return replacement
|
||||
}
|
||||
|
||||
async destroy(identifier) {
|
||||
if (validIdentifier(identifier)) await this.client.del(this.key(identifier))
|
||||
}
|
||||
|
||||
key(identifier) {
|
||||
return `${this.config.sessionNamespace}${identifier}`
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionIdentifier(request) {
|
||||
const cookies = String(request.headers.cookie || '').split(';')
|
||||
for (const cookie of cookies) {
|
||||
const separator = cookie.indexOf('=')
|
||||
if (separator === -1) continue
|
||||
if (cookie.slice(0, separator).trim() === SESSION_COOKIE)
|
||||
return cookie.slice(separator + 1).trim()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function sessionCookie(identifier, config) {
|
||||
const secure = config.secureCookie ? '; Secure' : ''
|
||||
return `${SESSION_COOKIE}=${identifier}; Path=/; HttpOnly${secure}; SameSite=Strict`
|
||||
}
|
||||
|
||||
export function expiredSessionCookie(config) {
|
||||
return `${sessionCookie('', config)}; Max-Age=0`
|
||||
}
|
||||
|
||||
export function randomValue(bytes = 24) {
|
||||
return randomBytes(bytes).toString('base64url')
|
||||
}
|
||||
|
||||
export function equalSecret(left, right) {
|
||||
const a = Buffer.from(String(left))
|
||||
const b = Buffer.from(String(right))
|
||||
return a.length === b.length && timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
function validIdentifier(value) {
|
||||
return typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value)
|
||||
}
|
||||
Loading…
Reference in a new issue