Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
76 changes: 76 additions & 0 deletions src/aauth/consent.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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'

Expand All @@ -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(
'<!doctype html><html><body><h1>Connected</h1>' +
'<p>You may close this window.</p></body></html>',
)
}
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) {
Expand All @@ -44,3 +74,49 @@ export const consent = async (req, reply) => {
'</body></html>',
)
}

// 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(
'<!doctype html><html><body>' +
(error
? `<h1>Not connected</h1><p>${String(error)}</p>`
: '<h1>Connected</h1><p>You may close this window.</p>') +
'</body></html>',
)
}
2 changes: 1 addition & 1 deletion src/aauth/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
13 changes: 12 additions & 1 deletion src/aauth/pending.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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' })
Expand Down
45 changes: 45 additions & 0 deletions src/aauth/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
40 changes: 38 additions & 2 deletions src/aauth/verify-resource-token.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,18 +116,54 @@ 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,
agent_jkt: payload.agent_jkt,
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,
}
}
3 changes: 3 additions & 0 deletions src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading