From 57b6cb7537441fb5286d54c2a7d0f7fea4f6e32c Mon Sep 17 00:00:00 2001 From: dickhardt Date: Wed, 9 Sep 2026 16:45:51 +0100 Subject: [PATCH] The connection ceremony MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A resource that fronts an upstream the person must link mints a CONNECTION-ONLY resource token: no `scope`, no `r3_*`, and an `interaction_code` naming the pending record it is holding. The PS issues nothing for it. It puts the person in front of the resource's own published `interaction_endpoint` with that code and a callback, and the record terminates when the resource bounces the browser back — `connection_established`, and no auth token. mockin had none of it. A scope-less token was read as `scope: ''` and got an auth token, so an agent driving a connect against mockin was told it had connected while no upstream OAuth had happened at all. Hellō's Wallet shipped the ceremony in #4269; the reference PS never learned it, which left the whole feature testable only against beta. - `verify-resource-token.js`: absent `scope` (distinct from `''` — an existing R3 test mints an empty one) plus `interaction_code` is a connection; `r3` without scope is rejected; the retired nested `interaction` object is rejected. - `token.js`: a connection creates a `kind: 'connection'` pending and answers 202 `requirement=interaction; code="…"` — code only, no `url=`, because the recipient composes it from the resource's `interaction_endpoint` (which must be published, and https). - `consent.js`: a connection is not the PS's to approve — it redirects to `{interaction_endpoint}?code={interaction_code}&callback={ISSUER}/aauth/bounce/{code}`. - `GET /aauth/bounce/:code`: the resource returning the browser, which is what terminates the record. `?error=` fails it instead. - `pending.js`: an approved connection polls `200 { status: 'connection_established' }`. Eight tests in `test/aauth/connection.spec.js`. 241 passing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CRdau7tgZa1tPVzUdyNrHc --- package-lock.json | 4 +- package.json | 2 +- src/aauth/consent.js | 76 +++++++++++++ src/aauth/index.js | 2 +- src/aauth/pending.js | 13 ++- src/aauth/token.js | 45 ++++++++ src/aauth/verify-resource-token.js | 40 ++++++- src/api.js | 3 + test/aauth/connection.spec.js | 164 +++++++++++++++++++++++++++++ test/aauth/helpers.js | 9 +- 10 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 test/aauth/connection.spec.js diff --git a/package-lock.json b/package-lock.json index 4cb5170..cafd891 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hellocoop/mockin", - "version": "3.1.1", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hellocoop/mockin", - "version": "3.1.1", + "version": "3.2.0", "license": "MIT", "dependencies": { "@fastify/cors": "^11.3.0", diff --git a/package.json b/package.json index 6b82fb1..d0e8d0e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hellocoop/mockin", "private": false, - "version": "3.1.1", + "version": "3.2.0", "description": "Hellō Mock Login OpenID Connect Server", "engines": { "node": ">=22" diff --git a/src/aauth/consent.js b/src/aauth/consent.js index 1e68960..7a8f0f1 100644 --- a/src/aauth/consent.js +++ b/src/aauth/consent.js @@ -1,4 +1,5 @@ // aauth/consent.js — GET /aauth/consent?code=…&callback=… +// GET /aauth/bounce/:code // // User-facing consent endpoint. The agent directs the user's browser here // after receiving requirement=interaction; mockin auto-approves the @@ -9,6 +10,7 @@ // navigation, not a signed agent call. The single-use `code` is the // authorization handle. +import { ISSUER } from '../config.js' import { getPendingByCode, updatePending } from './state.js' import { problem } from './problem.js' @@ -24,6 +26,34 @@ export const consent = async (req, reply) => { return problem(reply, 400, 'invalid_request', 'unknown code') } + // A connection is not the PS's to approve. The person has to link an + // account at the resource, so send them to the resource's own + // interaction_endpoint with the code the resource is holding, and a + // callback to bounce back to. The record terminates on that bounce — + // the resource finishing is the event, not the person arriving here. + if (entry.kind === 'connection') { + if (entry.status === 'approved') { + reply.header('Content-Type', 'text/html') + return reply.send( + '

Connected

' + + '

You may close this window.

', + ) + } + const target = new URL(entry.interaction_endpoint) + target.searchParams.set('code', entry.interaction_code) + target.searchParams.set('callback', `${ISSUER}/aauth/bounce/${entry.code}`) + // A callback supplied here is where the AGENT wants the person to end + // up; remember it so the bounce can forward once the resource is done. + if (callback) { + try { + updatePending(entry.id, { agent_callback: new URL(callback).toString() }) + } catch { + return problem(reply, 400, 'invalid_request', 'invalid callback url') + } + } + return reply.redirect(target.toString()) + } + updatePending(entry.id, { status: 'approved' }) if (callback) { @@ -44,3 +74,49 @@ export const consent = async (req, reply) => { '', ) } + +// GET /aauth/bounce/:code +// +// Where the resource sends the person's browser once its own ceremony is +// finished — the `callback` the consent redirect above handed it. This, not a +// visit to /aauth/consent, is what terminates a connection record: the resource +// completing is the event that says the account is linked. +// +// Unauthenticated by design, like /aauth/consent: a browser navigation whose +// single-use code is the handle. An `error` query parameter is the resource +// reporting that the person abandoned or the upstream refused. +export const bounce = async (req, reply) => { + const { code } = req.params || {} + const { error } = req.query || {} + + const entry = getPendingByCode(code) + if (!entry) { + return problem(reply, 400, 'invalid_request', 'unknown code') + } + if (entry.kind !== 'connection') { + return problem( + reply, 400, 'invalid_request', + `pending ${entry.id} is a ${entry.kind} record, not a connection`, + ) + } + + if (error) { + updatePending(entry.id, { status: 'error', error: String(error) }) + } else { + updatePending(entry.id, { + status: 'approved', + connection_established: true, + }) + } + + if (entry.agent_callback) return reply.redirect(entry.agent_callback) + + reply.header('Content-Type', 'text/html') + return reply.send( + '' + + (error + ? `

Not connected

${String(error)}

` + : '

Connected

You may close this window.

') + + '', + ) +} diff --git a/src/aauth/index.js b/src/aauth/index.js index e87842a..35c3805 100644 --- a/src/aauth/index.js +++ b/src/aauth/index.js @@ -9,6 +9,6 @@ export { permission } from './permission.js' export { audit } from './audit.js' export { interaction } from './interaction.js' export { bootstrap } from './bootstrap.js' -export { consent } from './consent.js' +export { consent, bounce } from './consent.js' export { verifyPreHandler } from './verify-request.js' export { get as mockGet, put as mockPut, resetConfig as mockReset } from './mock.js' diff --git a/src/aauth/pending.js b/src/aauth/pending.js index fa5bf31..4c8d639 100644 --- a/src/aauth/pending.js +++ b/src/aauth/pending.js @@ -152,7 +152,11 @@ export const pendingGet = async (req, reply) => { // entry stays pending — unless mock.auto_approve pre-marked it // approved at creation, which is the default and what every // auto-approve test relies on. Bootstrap works the same way. - if (entry.kind === 'bootstrap' || entry.requirement === 'interaction') { + if ( + entry.kind === 'bootstrap' || + entry.kind === 'connection' || + entry.requirement === 'interaction' + ) { const location = `${ISSUER}/aauth/pending/${entry.id}` reply.code(202) reply.header('Location', location) @@ -188,6 +192,13 @@ export const pendingGet = async (req, reply) => { return reply.code(200).send(issued) } + // A connection ends with no token at all: the person linked an account at + // the resource, and that is the whole answer (§the connection ceremony). + if (entry.kind === 'connection') { + deletePending(entry.id) + return reply.code(200).send({ status: 'connection_established' }) + } + if (entry.kind === 'permission') { deletePending(entry.id) return reply.code(200).send({ permission: 'granted' }) diff --git a/src/aauth/token.js b/src/aauth/token.js index 94b6029..f92182a 100644 --- a/src/aauth/token.js +++ b/src/aauth/token.js @@ -138,6 +138,51 @@ export const token = async (req, reply) => { return problem(reply, ERROR_STATUS[presented.code] || 400, presented.code, presented.error) } + // ── The connection ceremony ─────────────────────────────────────── + // A connection-only resource token asks for no token at all: the person + // has to link an upstream account at the resource. The PS holds a pending + // record, sends the person to the resource's own interaction_endpoint with + // the code the resource is holding, and the ceremony ends when the resource + // bounces the browser back — `connection_established`, no auth token. + if (rt.connection_only) { + if (!canDriveInteraction(params, { requireDeclared: cfg.require_capabilities })) { + return problem( + reply, 403, 'user_unreachable', + 'a connection requires user interaction and the agent did not declare the interaction capability', + ) + } + const interactionEndpoint = rt.resource_metadata?.interaction_endpoint + if (typeof interactionEndpoint !== 'string' || !interactionEndpoint) { + return problem( + reply, 400, 'invalid_resource_token', + `resource ${rt.resource_url} publishes no interaction_endpoint, so its connection code cannot be delivered`, + ) + } + if (new URL(interactionEndpoint).protocol !== 'https:') { + return problem( + reply, 400, 'invalid_resource_token', + `resource interaction_endpoint must be https, got ${interactionEndpoint}`, + ) + } + const { id, code } = createPending({ + kind: 'connection', + agent_id: aauth.agent_id, + resource_url: rt.resource_url, + interaction_endpoint: interactionEndpoint, + interaction_code: rt.interaction_code, + account: rt.account || null, + requirement: 'interaction', + params, + }) + const location = `${ISSUER}/aauth/pending/${id}` + reply.code(202) + reply.header('Location', location) + reply.header('Retry-After', '0') + reply.header('Cache-Control', 'no-store') + reply.header('AAuth-Requirement', `requirement=interaction; code="${code}"`) + return reply.send({ status: 'pending', location }) + } + let r3 = null if (rt.r3) { const fetched = await fetchR3Document({ diff --git a/src/aauth/verify-resource-token.js b/src/aauth/verify-resource-token.js index 8e25db9..ce651a7 100644 --- a/src/aauth/verify-resource-token.js +++ b/src/aauth/verify-resource-token.js @@ -116,10 +116,47 @@ export async function verifyResourceToken( } } + // ── The connection ceremony ─────────────────────────────────────── + // A resource that fronts an upstream the person must link mints a + // CONNECTION-ONLY resource token: no `scope`, no `r3_*`, and an + // `interaction_code` naming the pending record the resource is holding. + // The PS never issues a token for one — it puts the person in front of + // the resource's own interaction_endpoint and the ceremony ends with the + // connection established. + // + // Absent and empty are different: an existing R3 test mints `scope: ''`, + // and that is a scoped token asking for nothing, not a connection. + const scope = typeof payload.scope === 'string' ? payload.scope : null + const connectionOnly = scope === null + const interactionCode = + typeof payload.interaction_code === 'string' && payload.interaction_code + ? payload.interaction_code + : null + if (connectionOnly) { + if (!interactionCode) { + return { + error: 'resource_token has no scope and no interaction_code: a connection-only token must carry the code the resource is holding', + } + } + if (r3Uri) { + return { error: 'resource_token carries r3_uri without scope' } + } + } + // The nested `interaction: { url, code }` object was retired in favour of + // the flat `interaction_code` — the recipient composes the URL from the + // resource's published interaction_endpoint. + if (payload.interaction !== undefined) { + return { + error: 'resource_token carries the retired nested `interaction` object: emit interaction_code and publish interaction_endpoint', + } + } + return { resource_url: resourceUrl, resource_metadata: entity.metadata, - scope: typeof payload.scope === 'string' ? payload.scope : '', + scope: scope ?? '', + connection_only: connectionOnly, + interaction_code: interactionCode, ps: payload.ps, sub: payload.sub, presented_jti: presentedJti, @@ -127,7 +164,6 @@ export async function verifyResourceToken( mission_s256: payload.mission_s256 || null, tenant: payload.tenant || null, account: payload.account || null, - interaction: payload.interaction || null, r3: r3Uri ? { uri: r3Uri, s256: r3S256 } : null, } } diff --git a/src/api.js b/src/api.js index bb82448..5607cdf 100644 --- a/src/api.js +++ b/src/api.js @@ -93,6 +93,9 @@ export default function (fastify) { // AAuth: user-facing consent (browser navigation, no signing) fastify.get('/aauth/consent', aauth.consent) + // Where a resource returns the browser once its own ceremony is done — + // this is what terminates a connection record. + fastify.get('/aauth/bounce/:code', aauth.bounce) // Invite endpoints (mirrors wallet's external contract) fastify.get('/invite', invite.entry) diff --git a/test/aauth/connection.spec.js b/test/aauth/connection.spec.js new file mode 100644 index 0000000..463a440 --- /dev/null +++ b/test/aauth/connection.spec.js @@ -0,0 +1,164 @@ +// The connection ceremony — a resource that fronts an upstream the person +// must link. +// +// The resource mints a CONNECTION-ONLY resource token: no `scope`, no `r3_*`, +// and an `interaction_code` naming the pending record it is holding. The PS +// issues nothing. It puts the person in front of the resource's own published +// `interaction_endpoint` with that code and a callback, and the record +// terminates when the resource bounces the browser back — +// `connection_established`, no auth token. + +import { expect } from 'chai' +import Fastify from 'fastify' + +import api from '../../src/api.js' +import { ISSUER } from '../../src/config.js' +import { + installMocks, + postAuthToken, + getPersonToken, + mintResourceToken, + signedRequest, + RESOURCE_SERVER_URL, +} from './helpers.js' + +const fastify = Fastify() +api(fastify) + +const INTERACTION_CODE = 'ABCD-1234' + +// A connection-only exchange, up to the PS's 202. +async function startConnection(overrides = {}) { + const { person_token, agentToken } = await getPersonToken(fastify) + const resourceToken = await mintResourceToken({ + presentedToken: person_token, + scope: null, + interaction_code: INTERACTION_CODE, + ...overrides, + }) + const response = await postAuthToken(fastify, { + body: { resource_token: resourceToken, presented_token: person_token }, + agentToken, + }) + return { response, agentToken, person_token } +} + +// The pending code off the 202's AAuth-Requirement header. +const codeOf = (response) => + /code="([^"]+)"/.exec(response.headers['aauth-requirement'])?.[1] + +describe('AAuth auth_token_endpoint — the connection ceremony', function () { + beforeEach(async function () { + await installMocks(fastify) + }) + + it('answers 202 requirement=interaction with a code and no url', async function () { + const { response } = await startConnection() + expect(response.statusCode).to.equal(202) + const requirement = response.headers['aauth-requirement'] + expect(requirement).to.match(/^requirement=interaction/) + // The URL is composed by the recipient from the resource's published + // interaction_endpoint; the header carries the code alone. + expect(requirement).to.not.include('url=') + expect(codeOf(response)).to.be.a('string') + expect(response.headers.location).to.include('/aauth/pending/') + expect(response.json().auth_token).to.be.undefined + }) + + it('sends the person to the resource interaction_endpoint with the code and a callback', async function () { + const { response } = await startConnection() + const consent = await fastify.inject({ + method: 'GET', + url: `/aauth/consent?code=${codeOf(response)}`, + }) + expect(consent.statusCode).to.equal(302) + const target = new URL(consent.headers.location) + expect(target.origin + target.pathname).to.equal( + `${RESOURCE_SERVER_URL}/oauth/start`, + ) + // The code the RESOURCE is holding, not the PS's pending code. + expect(target.searchParams.get('code')).to.equal(INTERACTION_CODE) + expect(target.searchParams.get('callback')).to.include(`${ISSUER}/aauth/bounce/`) + }) + + it('terminates on the bounce, not on the person arriving at consent', async function () { + const { response, agentToken } = await startConnection() + const path = new URL(response.headers.location).pathname + const poll = async () => { + const { headers } = await signedRequest({ method: 'GET', path, agentToken }) + return fastify.inject({ method: 'GET', url: path, headers }) + } + + // The person has been sent to the resource but it has not finished. + await fastify.inject({ method: 'GET', url: `/aauth/consent?code=${codeOf(response)}` }) + expect((await poll()).statusCode).to.equal(202) + + // The resource returns the browser. + const bounce = await fastify.inject({ + method: 'GET', + url: `/aauth/bounce/${codeOf(response)}`, + }) + expect(bounce.statusCode).to.equal(200) + + const done = await poll() + expect(done.statusCode).to.equal(200) + expect(done.json()).to.deep.equal({ status: 'connection_established' }) + expect(done.json().auth_token).to.be.undefined + }) + + it('a bounce carrying an error fails the record', async function () { + const { response, agentToken } = await startConnection() + await fastify.inject({ method: 'GET', url: `/aauth/consent?code=${codeOf(response)}` }) + await fastify.inject({ + method: 'GET', + url: `/aauth/bounce/${codeOf(response)}?error=access_denied`, + }) + const path = new URL(response.headers.location).pathname + const { headers } = await signedRequest({ method: 'GET', path, agentToken }) + const poll = await fastify.inject({ method: 'GET', url: path, headers }) + expect(poll.statusCode).to.equal(403) + expect(poll.json().error).to.equal('access_denied') + }) + + it('rejects a scope-less token that carries no interaction_code', async function () { + const { response } = await startConnection({ interaction_code: null }) + expect(response.statusCode).to.equal(400) + expect(response.json().error).to.equal('invalid_resource_token') + expect(response.json().detail).to.include('interaction_code') + }) + + it('rejects r3 without scope', async function () { + const { response } = await startConnection({ + r3_uri: 'https://rs.example/r3/abc', + r3_s256: 'x'.repeat(43), + }) + expect(response.statusCode).to.equal(400) + expect(response.json().detail).to.include('r3_uri without scope') + }) + + it('an empty scope is a scoped token, not a connection', async function () { + // An existing R3 test mints `scope: ''`. Absent and empty differ. + const { response } = await startConnection({ scope: '', interaction_code: null }) + expect(response.statusCode).to.equal(200) + expect(response.json().auth_token).to.be.a('string') + }) + + it('403 user_unreachable when the agent cannot drive an interaction', async function () { + const { person_token, agentToken } = await getPersonToken(fastify) + const resourceToken = await mintResourceToken({ + presentedToken: person_token, + scope: null, + interaction_code: INTERACTION_CODE, + }) + const response = await postAuthToken(fastify, { + body: { + resource_token: resourceToken, + presented_token: person_token, + capabilities: ['payment'], + }, + agentToken, + }) + expect(response.statusCode).to.equal(403) + expect(response.json().error).to.equal('user_unreachable') + }) +}) diff --git a/test/aauth/helpers.js b/test/aauth/helpers.js index 4af2ee5..8ef082d 100644 --- a/test/aauth/helpers.js +++ b/test/aauth/helpers.js @@ -79,6 +79,8 @@ export async function installMocks(fastify) { jwks_uri: `${RESOURCE_SERVER_URL}/.well-known/jwks.json`, name: 'Mock Resource Server', scope_descriptions: { whoami: 'Read identity' }, + // Where the person goes for any ceremony the resource owns. + interaction_endpoint: `${RESOURCE_SERVER_URL}/oauth/start`, }, jwks: { keys: [resourceServer.publicJwk] }, }, @@ -138,6 +140,10 @@ export async function mintResourceToken({ account = null, r3_uri = null, r3_s256 = null, + // The connection ceremony: pass `scope: null` for a connection-only token + // (no scope claim at all) and an interaction_code naming the pending + // record the resource is holding. + interaction_code = null, ttl = 300, // The token the agent presented to the resource: a person token or an // auth token. `personToken` is the older name for the same option. @@ -172,7 +178,7 @@ export async function mintResourceToken({ sub, person_token_jti, agent_jkt, - scope, + ...(scope === null ? {} : { scope }), iat: now, exp: now + ttl, jti: randomUUID(), @@ -189,6 +195,7 @@ export async function mintResourceToken({ if (account) payload.account = account if (r3_uri) payload.r3_uri = r3_uri if (r3_s256) payload.r3_s256 = r3_s256 + if (interaction_code) payload.interaction_code = interaction_code return await new SignJWT(payload) .setProtectedHeader({ alg: 'Ed25519', typ: 'aa-resource+jwt', kid: resourceServer.kid }) .sign(resourceServer.privateKey)