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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,20 @@ The mock API can change the returned claims, simulate errors, and invalid ID Tok

## AAuth

Mockin also acts as a mock **Person Server** for [draft-hardt-aauth-protocol](https://datatracker.ietf.org/doc/draft-hardt-aauth-protocol/) — useful for testing agent clients without spinning up a real PS. Endpoints include `/aauth/bootstrap`, `/aauth/token`, `/aauth/permission`, `/aauth/audit`, `/aauth/interaction`, plus R3 (Rich Resource Requests) support. Auto-approves all consent steps in default mode. See the [docs](https://www.hello.dev/docs/mockin#aauth-agent-auth) for details.
Mockin also acts as a mock **Person Server** for [draft-hardt-oauth-aauth-protocol](https://datatracker.ietf.org/doc/draft-hardt-oauth-aauth-protocol/) — useful for testing agent clients without spinning up a real PS. Endpoints include `/aauth/bootstrap`, `/aauth/token/person` (`person_token_endpoint`), `/aauth/token/auth` (`auth_token_endpoint`), `/aauth/permission`, `/aauth/audit`, `/aauth/interaction`, plus R3 (Rich Resource Requests) support. Agents should read the endpoint URLs from `/.well-known/aauth-person.json` rather than hard-coding paths. Auto-approves all consent steps in default mode. See the [docs](https://www.hello.dev/docs/mockin#aauth-agent-auth) for details.

The mock API at `PUT /mock/aauth` switches the simulated behaviours:

| Key | Effect |
|-----|--------|
| `requirement` | `interaction` \| `approval` \| `clarification` — defers `/aauth/token/auth` with a `202` |
| `person_requirement` | `interaction` \| `approval` — defers `/aauth/token/person` with a `202` |
| `auto_approve` | `false` makes a deferred `interaction` wait for `GET /aauth/consent?code=…` instead of resolving on the first poll |
| `error` / `error_endpoint` | inject a token endpoint error code, optionally scoped to `token`, `person`, `bootstrap` or `permission` |
| `token_lifetime`, `claims`, `r3_grants`, `tenant` | shape the issued tokens (`r3_grants` takes `{ granted, per_call }`) |
| `require_body_signing` | `false` accepts a body signature that does not cover `content-digest` and `content-type` |

AAuth errors are RFC 9457 problem details — `Content-Type: application/problem+json` with the AAuth error code in `error` and the explanation in `detail`. The OIDC endpoints keep the OAuth 2.0 `{error, error_description}` shape they are specified to use.

## Invite

Expand Down
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": "1.7.0",
"version": "2.0.0",
"description": "Hellō Mock Login OpenID Connect Server",
"engines": {
"node": "~22"
Expand Down
28 changes: 28 additions & 0 deletions src/aauth/algorithms.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// aauth/algorithms.js — the accepted-algorithm table.
//
// Protocol -10/-11 §Signature Algorithms: `alg` is REQUIRED, MUST be fully
// specified, and implementations MUST NOT accept `none`, the polymorphic
// `EdDSA` identifier, or any symmetric algorithm. There is no transition
// allowance, so mockin emits and accepts `Ed25519` only — an `EdDSA` JWT
// is rejected rather than tolerated.
//
// `@hellocoop/httpsig` 2.0 already excludes `EdDSA` from its own
// SignatureAlgorithm table, so this list covers the JWTs mockin verifies
// itself: agent tokens, sub-agent tokens, and resource tokens.

export const SIGNING_ALG = 'Ed25519'

export const ACCEPTED_JWT_ALGS = [SIGNING_ALG]

/**
* jose accepts `EdDSA` and would happily verify it. Reject it (and any
* other unlisted alg) explicitly so the failure names the algorithm.
* @returns {string|null} an error message, or null when acceptable.
*/
export function checkJwtAlg(alg) {
if (!alg) return 'JWT header missing alg'
if (!ACCEPTED_JWT_ALGS.includes(alg)) {
return `unacceptable alg "${alg}": ${ACCEPTED_JWT_ALGS.join(', ')} required (a fully-specified identifier; the polymorphic "EdDSA" MUST NOT be accepted)`
}
return null
}
12 changes: 4 additions & 8 deletions src/aauth/audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,15 @@
//
// Fire-and-forget log endpoint. Mockin acknowledges with 201 and discards.

import { problem } from './problem.js'

export const audit = async (req, reply) => {
const body = req.body || {}
if (!body.action || typeof body.action !== 'string') {
return reply.code(400).send({
error: 'invalid_request',
error_description: 'missing action',
})
return problem(reply, 400, 'invalid_request', 'missing action')
}
if (!body.mission || typeof body.mission !== 'object') {
return reply.code(400).send({
error: 'invalid_request',
error_description: 'missing mission',
})
return problem(reply, 400, 'invalid_request', 'missing mission')
}
return reply.code(201).send()
}
74 changes: 39 additions & 35 deletions src/aauth/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
// don't want to drive the full redirect can flip mock.auto_approve = true
// to short-circuit and pre-mark the pending entry as approved on creation.

import { randomUUID, createHash } from 'crypto'
import { randomUUID } from 'crypto'
import { SignJWT } from 'jose'
import {
verify as httpSigVerify,
Expand All @@ -26,40 +26,43 @@ import {

import { ISSUER } from '../config.js'
import { privateKey, kid } from './keys.js'
import { SIGNING_ALG } from './algorithms.js'
import { directedSub } from './subject.js'
import { getConfig, mockErrorFor } from './mock.js'
import defaultUser from '../users.js'
import { checkBodySigning } from './verify-request.js'
import { problem } from './problem.js'
import { createPending, updatePending } from './state.js'

// -11: a body-carrying request to a PS endpoint covers content-digest and
// content-type on top of the base profile.
const ACCEPT_SIG = generateAcceptSignatureHeader({
label: 'sig',
components: ['@method', '@authority', '@path', 'content-type', 'signature-key'],
components: [
'@method', '@authority', '@path',
'content-type', 'content-digest',
'signature-key',
],
})

// Bootstrap accepts hwk (initial) or jwt (completion announcement).
const ACCEPT_SIG_SCHEME = generateAcceptSignatureSchemeHeader(['hwk', 'jwt'])

const BOOTSTRAP_TOKEN_TTL = 300 // 5 minutes

// Pairwise sub directed at agent_server: hash(user.sub || agent_server).
function directedSub(userSub, agentServer) {
return createHash('sha256')
.update(`${userSub}|${agentServer}`)
.digest('base64url')
}

export async function issueBootstrapToken({ agent_server, ephemeral_jwk }) {
const iat = Math.floor(Date.now() / 1000)
const payload = {
iss: ISSUER,
dwk: 'aauth-person.json',
aud: agent_server,
sub: directedSub(defaultUser.sub, agent_server),
// Same derivation as person and auth tokens — see subject.js.
sub: directedSub(agent_server),
cnf: { jwk: ephemeral_jwk },
iat,
exp: iat + BOOTSTRAP_TOKEN_TTL,
}
const bootstrap_token = await new SignJWT(payload)
.setProtectedHeader({ alg: 'Ed25519', typ: 'aa-bootstrap+jwt', kid })
.setProtectedHeader({ alg: SIGNING_ALG, typ: 'aa-bootstrap+jwt', kid })
.setJti(randomUUID())
.sign(privateKey)
return { bootstrap_token, expires_in: BOOTSTRAP_TOKEN_TTL }
Expand All @@ -85,11 +88,10 @@ export const bootstrap = async (req, reply) => {
if (!sigResult.verified) {
const noSig = !req.headers.signature && !req.headers['signature-input']
if (noSig) {
return reply
.code(401)
reply
.header('Accept-Signature', ACCEPT_SIG)
.header('Accept-Signature-Scheme', ACCEPT_SIG_SCHEME)
.send({ error: 'signature_required' })
return problem(reply, 401, 'signature_required')
}
const headers = {}
if (sigResult.signatureError) {
Expand All @@ -98,10 +100,19 @@ export const bootstrap = async (req, reply) => {
)
}
for (const [k, v] of Object.entries(headers)) reply.header(k, v)
return reply.code(401).send({
error: 'signature_verification_failed',
error_description: sigResult.error,
})
return problem(reply, 401, 'signature_verification_failed', sigResult.error)
}

// -11: the body signature must cover content-digest and content-type.
const bodyFailure = checkBodySigning(req, sigResult)
if (bodyFailure) {
for (const [k, v] of Object.entries(bodyFailure.headers || {})) {
reply.header(k, v)
}
return problem(
reply, bodyFailure.status,
bodyFailure.body.error, bodyFailure.body.detail,
)
}

// Two valid request flavours per the wallet PS pattern:
Expand All @@ -113,18 +124,17 @@ export const bootstrap = async (req, reply) => {
if (sigResult.keyType === 'jwt' && sigResult.jwt) {
const typ = sigResult.jwt.header?.typ
if (typ !== 'aa-agent+jwt') {
return reply.code(401).send({
error: 'invalid_jwt',
error_description: `expected aa-agent+jwt, got ${typ}`,
})
return problem(
reply, 401, 'invalid_jwt', `expected aa-agent+jwt, got ${typ}`,
)
}
return reply.code(204).send()
}
if (sigResult.keyType !== 'hwk' || !sigResult.publicKey) {
return reply.code(401).send({
error: 'invalid_key',
error_description: 'bootstrap requires hwk or jwt Signature-Key scheme',
})
return problem(
reply, 401, 'invalid_key',
'bootstrap requires hwk or jwt Signature-Key scheme',
)
}
// Keep cnf.jwk minimal — { kty, crv, x, alg }. httpsig 2.0 (RFC 9864)
// requires every JWK to carry a fully-specified alg; the hwk scheme now
Expand All @@ -136,18 +146,12 @@ export const bootstrap = async (req, reply) => {

const body = req.body || {}
if (!body.agent_server || typeof body.agent_server !== 'string') {
return reply.code(400).send({
error: 'invalid_request',
error_description: 'missing agent_server',
})
return problem(reply, 400, 'invalid_request', 'missing agent_server')
}

const mockErr = mockErrorFor('bootstrap')
if (mockErr) {
return reply.code(400).send({
error: mockErr,
error_description: `Mock error: ${mockErr}`,
})
return problem(reply, 400, mockErr, `Mock error: ${mockErr}`)
}

// Always go through the deferred flow — that's what the spec mandates
Expand Down
16 changes: 4 additions & 12 deletions src/aauth/consent.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,18 @@
// authorization handle.

import { getPendingByCode, updatePending } from './state.js'
import { problem } from './problem.js'

export const consent = async (req, reply) => {
const { code, callback } = req.query || {}

if (!code) {
return reply.code(400).send({
error: 'invalid_request',
error_description: 'missing code',
})
return problem(reply, 400, 'invalid_request', 'missing code')
}

const entry = getPendingByCode(code)
if (!entry) {
return reply.code(400).send({
error: 'invalid_request',
error_description: 'unknown code',
})
return problem(reply, 400, 'invalid_request', 'unknown code')
}

updatePending(entry.id, { status: 'approved' })
Expand All @@ -36,10 +31,7 @@ export const consent = async (req, reply) => {
try {
safe = new URL(callback).toString()
} catch {
return reply.code(400).send({
error: 'invalid_request',
error_description: 'invalid callback url',
})
return problem(reply, 400, 'invalid_request', 'invalid callback url')
}
return reply.redirect(safe)
}
Expand Down
3 changes: 2 additions & 1 deletion src/aauth/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

export { metadata } from './metadata.js'
export { jwks } from './jwks.js'
export { token } from './token.js'
export { token, tokenPrefix } from './token.js'
export { person } from './person.js'
export { pendingGet, pendingPost, pendingDelete } from './pending.js'
export { permission } from './permission.js'
export { audit } from './audit.js'
Expand Down
9 changes: 5 additions & 4 deletions src/aauth/interaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { ISSUER } from '../config.js'
import { createPending } from './state.js'
import { problem } from './problem.js'

const VALID_TYPES = new Set(['interaction', 'payment', 'question', 'completion'])

Expand All @@ -13,10 +14,10 @@ export const interaction = async (req, reply) => {
const body = req.body || {}

if (!body.type || !VALID_TYPES.has(body.type)) {
return reply.code(400).send({
error: 'invalid_request',
error_description: 'type must be interaction|payment|question|completion',
})
return problem(
reply, 400, 'invalid_request',
'type must be interaction|payment|question|completion',
)
}

if (body.type === 'completion') {
Expand Down
Loading