Skip to content

Interaction API

The Interaction API is how a sign-in page reaches the QAuth engine (ADR-019, Decision 6). The reference ceremony app, and any UI that replaces it, reads the current step of an interaction, answers it and follows the result, until the interaction ends in a redirect. It shares its step grammar with the FiPA authorization challenge endpoint, the transport for first-party native apps (ADR-017, Decision 3): a step is the same object on both, and only the envelope differs.

Status: this is the contract, published before the implementation. The auth server serves the document today; the operations it describes arrive with the Interaction API implementation.

The contract is one OpenAPI 3.1 document, openapi.json, and one JSON Schema per step, under /interaction/v1-rc.0/steps/. The auth server serves the same document at /interaction/v1-rc.0/openapi.json. The typed client in libs/shared/interaction-client is generated from it. Both are generated from libs/shared/interaction-contract, and the stable-surface check compares them with the last release (ADR-018 §5).

  • Browser-facing and same-origin. A page calls the API on the realm origin it was served from. A flow cookie (__Host-qauth_flow) and a CSRF value protect it, and the server sends no CORS headers.
  • Typed data from the auth server’s own records, never HTML. Every string a response carries is an enum, a constant or a bounded value with a pattern. A display value has no markup bracket, no control, format, private-use or unassigned character, no line separator, and no space at either end.
  • No URL carries return_to, a redirect or a client parameter. A page reads one opaque interaction id from its own URL, and the client, the redirect target and everything else it shows come from the API.
  • The interaction id alone reads the current step, with no personal data. Personal data, such as the account chooser’s list, needs the flow cookie and a same-origin request. Answering a step or cancelling the interaction needs the flow cookie, the step’s CSRF value and a same-origin request.
  • Every operation answers with Cache-Control: no-store.
  • The path carries the major version. Until QAuth 1.0.0 it is a release candidate, /interaction/v1-rc.<M> (today /interaction/v1-rc.0), and nothing about it is promised: a breaking change raises <M>. The release that ships 1.0.0 serves /interaction/v1.
  • Within a major version, changes are additive only: new operations, new steps, new optional request members, new response members, and new values of an extensible set (step names, problem codes, fonts).
  • A client ignores response members it does not know, and a page shows its error page for a step it does not know. A request with a member the operation does not declare is refused.
  • The published schemas close response objects (additionalProperties: false) to describe this version exactly. A later version may add members, so a client must not validate responses strictly against an older schema, and ignores the members it does not know.
  • Removing or renaming a member, changing a type, tightening validation, changing a status code, removing an operation or a step, and adding to a closed set, such as a step’s errors, need a new major version.
  • A successor major version is served beside the previous one for at least 12 months, and the previous one stays until the next major release of QAuth.
  • A step is named urn:qauth:ia:<name>:v1-rc.<M> until 1.0.0 and urn:qauth:ia:<name>:v1 from then on, with the same release candidate as the path. QAuth never renames a step; if a registry of standard step names appears, its names are added beside QAuth’s.

Every operation sits under /interaction/v1-rc.0. The flow cookie is __Host-qauth_flow. An operation’s success and error responses are listed in the document, each error with the codes it may carry.

OperationRequestFlow cookieCSRF valueSuccessErrors
Read the current stepGET /interaction/v1-rc.0/{interactionId}NoNo200410, 421, 429, 500, 503
Read the personal data of the current stepGET /interaction/v1-rc.0/{interactionId}/personalYesNo200403, 410, 421, 429, 500, 503
Answer the current stepPOST /interaction/v1-rc.0/{interactionId}/answerYesYes200, 303400, 403, 410, 413, 415, 421, 429, 500, 503
Cancel the interactionPOST /interaction/v1-rc.0/{interactionId}/cancelYesYes200, 303400, 403, 410, 413, 415, 421, 429, 500, 503

An answer is application/json or a plain HTML form post (application/x-www-form-urlencoded) with the same members, so the password, email-code and TOTP steps work without JavaScript. A JSON answer gets one of two results:

  • Next step ("result": "step"): the interaction as a step read now returns it. A refused answer, such as a wrong password, gets the same step back with its error set and a new CSRF value.
  • Final redirect ("result": "redirect"): the location the browser goes to, taken from the interaction record and the realm, never from the request.

A form post gets the same as a 303 See Other. Anything the API refuses is an error: a problem document (below).

Each step is named urn:qauth:ia:<name>:v1-rc.0. A step read names the current step (step), the other steps the engine accepts next (alternatives), their params keyed by step name (params) and, after a refused answer, the reason (error): the shape of the FiPA endpoint’s answer, so a new step is a new name and a new member, not a break. A step’s params are what any reader of the interaction id may see; its personal data needs the flow cookie. Each step’s schema file holds the whole step as one record (params, answer, personal data, errors and amr) with its example, which the implementation reuses as a test vector. The params and answer fields keep the step grammar’s names on both transports, which is why they are snake_case while the envelope is camelCase.

StepTransportsAnswer fieldsErrorsamrPersonal data
identifierBothlogin_hint———
passwordBothpassword, login_hintinvalid_credentialspwdidentifier
email_codeBothotpinvalid_credentials, code_expired—identifier
new_passwordBothnew_passwordpassword_rejected—identifier
password_resetBothlogin_hint———
captchaBothcaptcha_tokencaptcha_failed——
webauthn_getBothwebauthn_responseinvalid_credentialspop, mfaidentifier
webauthn_createBothwebauthn_responseinvalid_credentials—identifier, user, excludeCredentials
totpBothotpinvalid_credentialsotpidentifier
recovery_codeInteraction APIrecovery_codeinvalid_credentials—identifier
recovery_codesInteraction APIacknowledged——codes
totp_setupInteraction APIotpinvalid_credentials—identifier, secret, otpauth_uri
consentInteraction APIdecision, remember——identifier
select_accountInteraction APIaccountaccount_unavailable—accounts
device_codeInteraction APIuser_codeinvalid_user_code——
device_confirmInteraction APIdecision——location, network, user_agent
logout_confirmInteraction APIdecision——accounts

“Both” means the Interaction API and the FiPA endpoint. Where a step only the Interaction API serves would come next, the FiPA endpoint answers redirect_to_web.

urn:qauth:ia:identifier:v1-rc.0 — Asks who is signing in.

Identifier-first sign-in (ADR-020 §3). Every identifier, known or unknown, gets the same next step, so the screen reveals nothing about which accounts exist (ADR-017 Decision 4). Offered as an alternative of a later step, it is the account switch (“Not you?”, “Use another account”): answering it ends the interaction and starts a new one for the same authorization request, and the result is a redirect to the ceremony app’s page of the new interaction. Nothing proved for the first account carries over (#592).

urn:qauth:ia:password:v1-rc.0 — Asks for the password.

The password factor. A rejected password, an unknown account and a disabled account give the same invalid_credentials, after the same timing floor (ADR-017 Decisions 3 and 4). Works without JavaScript: the form posts straight to the auth server with the step’s CSRF value (ADR-019 Decision 5).

urn:qauth:ia:email_code:v1-rc.0 — Asks for the code mailed to the account’s address.

An emailed code proves control of a mailbox: it verifies a password account and recovers an account. It is never a sign-in factor and adds no amr value (ADR-017 Decision 5). For recovery, an address that gets no mail (no account, or no password) gets a phantom code: the step comes with the same expires_in and resend_after, and its answers are refused with the same errors at the same times as a real code’s (invalid_credentials for a wrong code, code_expired once expires_in has passed), so neither error tells a known address from an unknown one. An answer with no otp asks for the code again. Works without JavaScript.

urn:qauth:ia:new_password:v1-rc.0 — Asks for a new password.

Sets a password through the one password policy (ADR-017 Decision 8): at registration, at the end of a reset, or when an admin or a breach-list match requires a new one (ADR-020 §3). The reasons of a refusal are returned, never prose. The page marks the field for password managers, with the identifier from the personal data as the username. Works without JavaScript.

urn:qauth:ia:password_reset:v1-rc.0 — Starts a password reset.

A path marker: answering it starts a reset. The next step is email_code with purpose recovery for every address, known or not; mail goes only to a known address with a password. An unknown address gets a phantom code, with the same expires_in and resend_after and the same error sequence as a real one, so the reset says nothing about whether the address has an account. A reset never unlocks an account whose strongest authenticator is stronger than email (ADR-017 Decision 8).

urn:qauth:ia:captcha:v1-rc.0 — Asks for a bot challenge answer.

A bot challenge, when a soft threshold requires one; the pending step resumes after it (ADR-017 Decisions 4 and 7). By default the built-in proof of work, whose script the ceremony app serves from the realm origin. An operator may name a hosted provider for the sign-up and sign-in pages only (ADR-019 Decision 5). A required challenge needs JavaScript and is never skipped.

  • Params: provider, challenge, difficulty, or provider, name, site_key
  • Schema: steps/captcha.json

urn:qauth:ia:webauthn_get:v1-rc.0 — Asks for a passkey.

Passkey sign-in: the WebAuthn Level 3 request options as JSON, for PublicKeyCredential.parseRequestOptionsFromJSON(). Discoverable credentials only: the options never carry allowCredentials, so the step read reveals no account’s passkeys (ADR-017 Decision 4, ADR-019 Decision 6). The engine checks that the passkey belongs to the transaction’s account. The RP ID is the realm’s host (ADR-019 Decision 2). Needs JavaScript.

urn:qauth:ia:webauthn_create:v1-rc.0 — Offers or asks to create a passkey.

Passkey enrolment. options is the WebAuthn Level 3 creation options as JSON without user and excludeCredentials, which are the account’s own data and come with the personal data; the page joins the two for PublicKeyCredential.parseCreationOptionsFromJSON(). The RP ID is the realm’s host and locks at the first enrolment (ADR-019 Decision 2). Enrolment adds no amr value. Needs JavaScript.

urn:qauth:ia:totp:v1-rc.0 — Asks for the code of the authenticator app.

A TOTP second factor (RFC 6238) with the fixed parameters of ADR-020 §3: SHA-1, six digits, 30 seconds, one step of tolerance. An accepted code is never accepted twice (ADR-017 Decision 8). Works without JavaScript.

urn:qauth:ia:recovery_code:v1-rc.0 — Asks for one of the account’s recovery codes.

A recovery code stands in for a second factor after the first, or for the passkey of a passkey-only account. Only the hosted pages redeem one; the FiPA endpoint answers redirect_to_web for that path. It adds no amr value and never mfa, so a policy that needs a second factor makes the person bind a new one before the transaction completes (ADR-017 Decisions 4 and 8). Works without JavaScript.

urn:qauth:ia:recovery_codes:v1-rc.0 — Shows a new set of recovery codes once.

A new recovery-code set: issued at the first enrolment of a second factor, at the registration of a passkey-only account, and when the person regenerates the set (ADR-017 Decision 8). The codes are personal data: readable with the flow cookie while this step is current, and never after. The answer confirms the person saved them.

urn:qauth:ia:totp_setup:v1-rc.0 — Sets up an authenticator app.

TOTP enrolment, with the fixed parameters of ADR-020 §3. The secret is personal data: readable with the flow cookie while this step is current, and never after. It is bound only after a first valid code (ADR-017 Decision 8). A password-only account’s first binding also needs a code mailed to its address in the same transaction, and an unclaimed account never reaches this step. Enrolment adds no amr value. The page shows the secret as a QR code and as text.

urn:qauth:ia:consent:v1-rc.0 — Asks the person to allow or deny what the client asks for.

The OAuth consent decision (#629). The interaction’s client names the client, verified domain first (ADR-019 Decision 6). The grant is exactly the scope set shown here, which the interaction holds: the answer cannot add a scope. A dangerous scope and prompt=consent always reach this step; a first-party client may skip it only through the administrative waiver (ADR-017 Decision 9). Works without JavaScript.

urn:qauth:ia:select_account:v1-rc.0 — Asks which signed-in account to continue with.

The account chooser for prompt=select_account, when the browser holds sessions for several accounts of the realm (ADR-019 Decision 3, ADR-020 §3). The list is personal data. “Use another account” answers the identifier step, offered beside this one: that starts a new interaction for the same authorization request, and the result is a redirect to its page (#592). Needs JavaScript, to read the list.

urn:qauth:ia:device_code:v1-rc.0 — Asks for the code the device shows.

Code entry for the OAuth 2.0 device authorization grant (RFC 8628 §3.3). Unknown, expired and used codes give the same invalid_user_code. Entry is rate-limited per code, per session and per address. A person who arrives through verification_uri_complete skips this step and still confirms on device_confirm (ADR-020 §3). Works without JavaScript.

urn:qauth:ia:device_confirm:v1-rc.0 — Asks the person to confirm the device in front of them.

The explicit confirmation of a device authorization, always asked, also after verification_uri_complete (ADR-020 §3). The screen warns that nobody else should have given the person the code, and shows where the request came from: the requesting device’s approximate location, network and user agent, which are personal data, and its start time. The consent step always follows, even for a client that may skip consent elsewhere. The device details come with the personal data, so this step needs JavaScript: a page shows them before it offers confirm.

urn:qauth:ia:logout_confirm:v1-rc.0 — Asks the person to confirm signing out.

The confirmation of an RP-initiated logout (ADR-019 Decision 4). The interaction’s client names the app that asked, when the request identified one. A browser may hold several accounts’ sessions, so the person signs out of this account or of every account on the browser (ADR-019 Decision 3). Works without JavaScript, without the account list.

Every step read carries the realm’s brand: the theme tier of ADR-019 Decision 5. It holds a logo an admin uploaded (served from the realm origin), light and dark colours, a font from the safe list (system, inter, noto_sans, atkinson_hyperlegible, ibm_plex_sans, source_sans_3), links to the terms, the privacy notice and help, the realm’s strings by locale, and whether dark mode is enabled. There are no uploaded templates, CSS or script. A realm that set nothing gets the defaults: system and dark mode enabled, and every member left out keeps the ceremony app’s own value.

An error is an RFC 9457 problem details document (application/problem+json) with a stable code and a trace_id, as on the other API families (API conventions). Its type and title are the code’s entry on Problem types. It carries no detail, instance or errors list: a page shows its own text for the code, with the trace id. A page treats a code it does not know by its HTTP status.

CodeStatusMeaning
bad_request400The request is malformed and no more specific code applies.
invalid_answer400The answer does not match the schema of the step it names, names a step the interaction does not offer, or repeats a field. The engine was not called.
forbidden403The request is not same-origin, or its CSRF value is missing, already used or not the current step’s. The engine was not called.
interaction_expired410The interaction is unknown, expired or finished, or the flow cookie does not belong to it. Every reason gets this one answer.
content_too_large413The request body is larger than the server accepts.
unsupported_media_type415The request body is neither application/json nor application/x-www-form-urlencoded.
misdirected_request421The server does not answer for the host the request names.
too_many_requests429Too many requests. Retry-After says when to try again.
internal_error500The server failed. trace_id finds the failure in the server log.
service_unavailable503The server cannot answer now, for example while a store the engine needs is down. It fails closed.
  • The prefix /interaction/v1-rc.0/wallet/ is reserved for the wallet return leg (ADR-013). No operation of this document is served under it.
  • The auth server serves this document at /interaction/v1-rc.0/openapi.json.