The AuthMethod contract
Experimental. The AuthMethod contract is QAuth’s plugin API, the experimental item
plugin-api. It is public and documented, so others can write methods against it, but it is outside the 1.0 stability promise: it may change in a minor release without a deprecation window. It becomes stable in 1.x, once QAuth’s own plugins have matured on it (ADR-018 §4).
An AuthMethod is a sign-in method that plugs into QAuth’s engine, such as an upstream OpenID
Connect provider. It extends the CredentialProvider
interface of ADR-003:
CredentialProvider stays the verification half, and AuthMethod adds what a method needs beyond
verification. The contract is the npm package @qauth-labs/plugin-sdk (libs/sdk/plugin-sdk/):
a plugin is written against it in its own repository
(below), and the server imports it from there.
What exists today
Section titled “What exists today”The contract and its test kit, in the package @qauth-labs/plugin-sdk, the registry entries, the
route mount, the plugin runtime and the first method on the contract. With the plugin runtime, a deployment installs plugin packages, the
server loads them at boot, and an admin enables, configures and disables each one per realm
(below). The first method is the generic OpenID Connect
upstream client (#601, below): a library that builds one
method per configured provider, not a plugin package, so nothing registers it in a running server
until provider administration (#714). The upstream sign-in flow (#639) offers the step and acts
on its outcome: each plugin a realm has enabled is one of the realm’s upstream providers, and an
account a plugin creates counts as verified. WalletProvider is not an
AuthMethod and stays a plain provider whose verify() rejects; wallet sign-in keeps its own seam.
The contract
Section titled “The contract”libs/sdk/plugin-sdk/src/lib/auth-method.ts (AuthMethod):
| Member | What it is |
|---|---|
type | From CredentialProvider: the registry key, the user_credentials.provider_type value, the source of every attribute, and a segment of the route prefix. |
verify() | From CredentialProvider: turns the result the method’s own callback returned into a VerifiedIdentity, and rejects anything else. |
extractAttributes() | From CredentialProvider: the attributes of a verified identity. |
contract | The contract version the method is written against: qauth-auth-method/1-rc.0. Its presence is what makes a registered provider an AuthMethod. |
id | The stable name of the method’s implementation. Instances of one implementation, one per configured provider, share it; type tells them apart. |
display | Default display data: name (at most 64 characters, under the Interaction API’s display rule) and an optional icon (a PNG of at most 16 KiB). An admin may override both. |
configSchema | A Zod object schema of the method’s config for one realm. A secret member is marked .meta({ writeOnly: true }). |
steps | The Interaction API steps the method contributes, by versioned URN. Today only the upstream step, urn:qauth:ia:upstream:v1-rc.0. |
routes | The routes the method mounts below its prefix. |
start() | Starts an upstream transaction: where the browser goes, the transaction the method needs back, and the binding the provider carries back. |
The type rules are strict because type lands in four places: lower-case ASCII letters, digits
and _, starting with a letter, at most 63 characters. A type with : or / (an ID-JAG issuer
type, oidc_<issuer URL>) never matches, and the types QAuth writes itself (password, wallet,
webauthn, self_reported, totp, recovery_code, email_code) are reserved.
The upstream step
Section titled “The upstream step”engine offers the upstream step ──► person picks the method │ ▼start({ uiLocales }, context) ──► { redirectTo, transaction, binding } │ the engine keeps the transaction and its binding server-side, │ bound to the interaction and its flow cookie, and sends the │ browser on ▼provider ──► GET /upstream/<type>/callback?code=…&state=<binding> │ the host compares the binding with the pending transaction's │ (constant time) BEFORE it takes the transaction; with none, or │ a binding that does not match, it answers transaction_mismatch, │ the transaction stays pending and the method never runs. │ Otherwise it takes the transaction (once) and hands the │ callback the query and the transaction; no header, no cookie ▼callback ──► result | declined | failed (reason) (a throw: a fault) │ ▼engine: verify(result) ──► VerifiedIdentity, checked, assurance capped extractAttributes() ──► attributes, each with source = type- Start. The engine calls
start(). The method answers where the browser goes, the transaction it needs back (a freshstate, anonce, a PKCE verifier) and the binding: a fresh random value the authorization request carries to the provider and the provider carries back, usually thestateitself. The engine checks the answer (startUpstreamStep): the redirect is an absolutehttpsURL with no credentials and no fragment, the transaction is an object of JSON data of at most 4096 bytes, and the binding is 22 to 512 base64url characters (22 is the length of 128 random bits; the check is of length and alphabet, the method makes it random). The host keeps the transaction with its binding (PendingUpstreamTransaction). - Callback. The provider sends the browser back to one of the method’s callback routes. The
host reads the pending transaction the flow cookie names without taking it, and compares the
binding the callback carries with its binding, in constant time (
upstreamBindingMatches). The callback carries it in the route’sbindingParameter,stateunless the route names another: a query parameter of aGETroute, a form member of aPOSTone, exactly once. With no pending transaction (no flow cookie, another interaction, a replay) or a binding that is missing, empty, repeated or another start’s, the host answerstransaction_mismatchitself: the method never runs, so it never spends the code, and the transaction stays pending, so a request from another site cannot cancel the sign-in. Otherwise the host takes the transaction, once, checks the binding of what it took again, and hands the transaction to the route’s handler, which checks the provider’s answer and answers one of three outcomes:result,declined(the person cancelled at the provider,access_denied), orfailedwith a reason from a closed set (transaction_mismatch,provider_error,invalid_response,upstream_unavailable). A handler may comparestatewith its transaction again; it costs nothing. A handler that throws, or a context the host cannot build (anAuthMethodConfigError), is a fault: the mount hands the engine anAuthMethodFaultErrorwith the error as its cause, through the same completion as every other outcome, never a raw 500. - Identity. The engine (
completeUpstreamStep) passes aresulttoverify(), checks the identity it resolves (anexternalSubof 1 to 255 characters of well-formed text with no control character, an assurance level, raw claims of JSON data within 64 KiB, which it copies and freezes; a claim that isundefinedor aDateis not JSON data and makes the answer a fault), then takes the attributes. The raw claims name the issuer that assignedexternalSub, asrawClaims.iss: a subject is unique only within its issuer (OpenID Connect Core 1.0 §5.7), so the upstream sign-in flow (#639) stores the issuer with the credential, refuses a later sign-in under another one, and refuses an identity that names none. The assurance level is capped:completeUpstreamSteptakes{ maxAssurance }and lowers the method’s level to it,lowwhen the caller names none, since an upstream sign-in ranks with a password until an operator raises the ceiling for a provider it trusts more. Every attribute’ssourcemust equal the method’stype: a method cannot write attributes under another source, such aswallet, which outranks every other in claim resolution. An attribute value is well-formed text too (Postgres stores a lone surrogate as U+FFFD, which would make two values one), and the engine builds the checked list and copies eachexpiresAtitself. Averify()rejection ends the step asverification_refused; any other broken answer is anAuthMethodFaultError, and the engine acts on none of it.
The graph of this step is graphs/flows/federation/upstream-step/flow.yaml.
What a method is handed
Section titled “What a method is handed”Plugin code is trusted code that runs in-process
(ADR-019 Decision 12), and
the contract hands it only what it needs. createAuthMethodContext
(libs/sdk/plugin-sdk/src/lib/context.ts) builds the context, with five members:
config: the realm’s config of the method, parsed byconfigSchema, copied and deep-frozen. It must be plain data (objects, arrays and primitives): a parsed config that holds aMap,Set,Dateor other class instance is refused with anAuthMethodConfigErrornaming the member, since none of them freezes;egress:request()over the egress client, so every outbound call is checked (ADR-019 Decision 8). It passes on onlymethod(GETorPOST),url,headers,body,purposeandsource(operator,adminorremote_document, never a destination a client or an end user chose), and adds the realm’s name itself: a method cannot pick another realm’s allowlist entries or lift the client’s caps.operatorandadminare the labels the realm’s allowlist applies to, for non-public addresses, so the adapter keeps them only for a URL whose origin is the origin of a URL among the plain members of the method’s parsed config, never a secret member; any other call goes asremote_document.redirect: 'error'makes a redirect answer reject the call before anything goes to its target (the host client’smaxRedirects: 0, the one cap a method may set, since it can only tighten); a call that carries a credential in its body uses it, or a307would send that body on.follow, the default, follows each redirect after the same checks as the first URL;signer:signClientAssertion()andgetUpstreamJwks()over the signer role, with the realm, the provider and the caller role fixed by the host. Signatures, never a key (ADR-019 Decision 9). An assertion getsiatwhen its claims carry none (a future one is refused), and itsexpis lowered to at most five minutes from now;log: a logger’s four levels, with a fields object and a message. The fields are nested underauthMethod, so a method cannot overwrite the line’sreqIdorrealm, as a copy in whichcode,code_verifier,client_secret,client_assertion,refresh_token,access_token,id_token,nonceandpasswordare redacted at any depth, matched without regard to case,_or-(clientSecretmatchesclient_secret). The last segment of each of the method’s secret paths is redacted the same way, and each secret’s value is cut out of every string and of the message;routeUrl(path): the absolute URL of one of the method’s own routes on the realm host, for aredirect_uri. Only a path the method’sroutesdeclare.
Each member is an adapter the builder writes, not a bound copy of the host’s function, so the host can hand over its whole egress client, signer and logger and the method still reaches nothing more. No database or cache handle, no private key, no Fastify instance, no request header and no cookie.
The plugin runtime builds the context for the request’s realm
(apps/auth-server/src/app/helpers/auth-method-plugins.ts): the realm’s stored config with its
secret members decrypted back into place, the method’s type as the signer’s provider, and the
request’s logger with the plugin’s name. The context signs and decrypts as api: its grant holds
signClientAssertion, and among the stored secrets it may decrypt are a plugin secret’s
auth-method-plugin:<plugin>:<path> contexts, beside a realm’s mail secrets and an upstream
OpenID Connect client’s secret. web, which serves a plugin’s routes, may neither decrypt nor sign
a client assertion, and local would open every stored secret. The admin API seals a plugin
secret as local, as the realm secret store does, and opens a stored or sealed one as api. In a
single deployment every role shares the signer’s process. A deployment that runs the signer apart
(#497) still needs a grant for the admin API, since the signer service takes the role from the
caller’s certificate and never accepts local from a peer; until then a process without the
signer refuses the call.
Routes
Section titled “Routes”A method declares its routes as data and never touches Fastify. authMethodRoutesPlugin
(libs/fastify/plugins/federation/src/lib/auth-method-routes.ts) mounts each registered method’s
routes under its own prefix, /upstream/<type>, as browser routes: they answer on the realm’s
primary host only, where the flow cookie lives. A route path is one to four lower-case segments,
such as /callback; no dot segment, encoded character, parameter or wildcard can match, so a
route cannot leave its prefix, and the mount checks the joined path again. Each provider gets its
own callback URL, so a response meant for one provider cannot be replayed at another.
There are two kinds of route:
- a callback route gets the query, the form and the taken transaction, once its binding
matched, and answers an outcome that goes to the engine. It may name the parameter that
carries the binding back (
bindingParameter,stateby default); - a bounce route gets no transaction and no context, and may only send the browser to one of
the method’s own
GETcallback routes on the same origin, with a query that is a plain object of strings. It is for a provider’sform_post, which arrives cross-site without theSameSite=Laxflow cookie. Anyone can send that POST, so the code it runs holds no egress client, no signer and no config. A bounce that throws or goes elsewhere is a fault, handed to the engine like a callback’s.
A route has no HEAD twin, a POST route reads only a urlencoded form (a JSON body reaches the
method as an empty form), and a method sees at most 64 parameters, each at most 8192 characters,
a repeated one as a list of at most 16, in a frozen object with no prototype
(authMethodParameters; the test kit hands a method the same objects). The request log censors
the value of a code, id_token or access_token parameter in the logged URL: an upstream
authorization code rides there, and a bounce may carry a provider’s tokens on.
The path carries no version: the callback URL is registered at the provider, and a path that changed at 1.0 would break every registration.
Registration
Section titled “Registration”The provider registry holds AuthMethods beside plain providers, in one type space
(libs/server/federation/src/providers/provider-registry.ts). resolveAuthMethod(type) finds
only an AuthMethod, findAuthMethod(type) answers undefined for a type that came from a
request and names none, and authMethods() lists them for the mount. resolve(type) answers a
plain provider as registered, and for an AuthMethod’s type a fail-closed stand-in whose
verify() rejects and extractAttributes() throws: an AuthMethod’s verify() resolves for its
own callback’s results, so it is handed out only to the engine’s upstream step.
The registry keeps a frozen snapshot of each method, read once and validated as kept: the type
it checked, the routes it mounts and the members the engine calls cannot change afterwards. A
provider that carries a contract member is held to the whole contract at registration, so a
method written against another contract version, with a route that could leave its prefix, or
under a reserved type, fails the boot, and so does a duplicate type.
createConfiguredProviders takes the methods to register (authMethods), holds every entry to the
whole contract (one with no contract member too, which the registry alone would take for a plain
provider) and appends them after the built-in providers; leaving them out registers none. The
auth server passes the methods of the plugins it loaded at boot.
Packaging a plugin
Section titled “Packaging a plugin”A plugin is an npm package that exports its method as authMethod
(AUTH_METHOD_PLUGIN_EXPORT; a CommonJS package may put it under default):
// index.ts of @example/qauth-plugin-corp-ssoimport { AUTH_METHOD_CONTRACT, type AuthMethod } from '@qauth-labs/plugin-sdk';
export const authMethod: AuthMethod<CorpSsoConfig, CorpSsoTransaction> = { contract: AUTH_METHOD_CONTRACT, // 'qauth-auth-method/1-rc.0' type: 'corp_sso', id: 'corp-sso', // display, configSchema, steps, routes, start, verify, extractAttributes};One package holds one method. Its id is the name the admin API gives the plugin, and stays the
same across versions of the package; its type is the segment of its routes,
/upstream/<type>/…, and the value written to user_credentials.provider_type.
Taken names. A plugin’s type may not be a type QAuth writes itself
(RESERVED_AUTH_METHOD_TYPES), which the registry and the kit refuse. Nor may it start with
oidc_, and its id may not be oidc: the namespace belongs to the
generic OpenID Connect client. The kit lets these two pass,
since it checks that client’s methods too; the plugin loader refuses them, and the boot fails.
Secret members. A member of the config schema marked .meta({ writeOnly: true }) is a secret,
such as a client secret. The runtime reads the marks from the schema’s JSON Schema, from its input
side and from its output side. A secret is a string, or a nullable one, at any depth of nested
objects, marked on the input side or on the output side of a pipe that keeps its path and its
string. A schema that is not an object, that holds a part JSON Schema cannot describe (a Set, a
Map, a date, a custom check), or that marks a member inside a list, a union, a record or a
recursive part, behind a pipe that reshapes or renames it, or one that is not a string, is refused
at boot: the runtime could not keep it write-only. So is a schema built with a Zod copy that keeps
a metadata registry of its own (Zod 4.6 shares one between copies): build a plugin against the
server’s Zod, as a peer dependency.
Installing and enabling a plugin
Section titled “Installing and enabling a plugin”Installing. The operator installs the package with the deployment, as any other dependency
of the auth server (into the published image), and
lists it in AUTH_METHOD_PLUGINS by package name, comma-separated. A
path, a URL or a file: or git+ specifier is refused: the server never fetches code, and the
admin console never uploads or edits it.
AUTH_METHOD_PLUGINS=@example/qauth-plugin-corp-ssoPLUGIN_API_ENABLED=trueAt boot, a process that serves the sign-in pages or the admin API imports each listed package,
checks the contract version its method declares first (a plugin built for another version says
so, whatever else it breaks), holds the method to the whole contract, reads its secret members and
registers it after the built-in providers. A package that is not installed, throws as it is
imported, exports no authMethod, is written against another contract version, or repeats another
plugin’s id or type stops the boot. The boot logs one line per plugin it loads.
The switch. A plugin outside QAuth’s first-party list loads only while
PLUGIN_API_ENABLED=true, and then the server logs the experimental warning of plugin-api at
every boot; a process that loads plugins and holds the signer role, as a single deployment
does, logs one more line saying that plugin code runs beside the signing keys and the KEK. With it
off, such a package is never imported, and the boot logs a warning saying why.
A plugin on QAuth’s first-party list loads whatever the switch says. The list is fixed in the
server’s code (FIRST_PARTY_AUTH_METHOD_PLUGINS), never a setting, and it is empty: a first-party
plugin released from its own repository, such as the GitHub one, loads like any other package until
it joins the list (ADR-018 §3). Discovery
has no member for plugins, and their routes
are left out of the OpenAPI documents.
Per realm, every plugin starts off. The admin API’s plugin operations, under
/admin/v1-rc.0/realms/{realmId}/plugins, list the installed plugins with the realm’s state of
each (plugins.list, plugins.get), seal a secret value (plugins.secrets.seal), replace a
plugin’s config (plugins.update, POST …/plugins/{plugin}/config) and turn it on and off
(plugins.enable, plugins.disable). {plugin} is the plugin’s id. The three writes change
which upstream credentials sign users in, so each is a
security operation with the effect credential-path: it needs
qauth:admin:security (the permission plugins.security) and an approval, and
qauth:admin:write reaches none of them. Each of the three names in its body the version of
the state it acts on, as its read gave it ({ "version": 1 } for an enable or a disable), so the
approval binds it and the approver sees it, and a state that moved since answers 412: two approved
configures never overwrite each other unseen, and an approved enable never turns on a config its
approver never saw. Each may carry If-Match too.
Secrets. A secret’s value reaches the server in one request only: plugins.secrets.seal
(POST …/plugins/{plugin}/secrets/seal, { path, value }), which encrypts it with the realm’s
data_at_rest key, bound to that plugin and member, and answers the ciphertext. It changes
nothing, so it is a write under plugins.write. A configure request then sends the plain members
under config and names each secret member under secrets, by path: { sealed } sets one to the
sealed value, null clears it, and one it does not name keeps its value, unless the configure
sends the plugin somewhere new: a configure that keeps a stored secret while a member whose value
is an http(s) URL is added or changes origin is refused with 400, naming the secrets to seal
again, so a kept secret never follows a destination it was not set for (as the upstream OpenID
Connect client refuses a changed issuer or token endpoint). So the request an
approval stores until it is used, and the digests it is bound by, hold a ciphertext, never the
value. The handler checks that each sealed value opens for its member of this plugin in this
realm, and the whole config, secrets included, must pass the plugin’s own schema; a plugin is
enabled only with a config that does. A secret member sent under config, however it is spelled,
is refused. No response, audit row, event or log line holds a value: the representation says only
which secret members hold one, and the audit row of a configure names the secrets it set or
cleared. A member that a newer version of a plugin marks secret is never shown or handed to the
plugin from the plain config an older version stored, and leaves it at the next write.
POST /admin/v1-rc.0/realms/{realmId}/plugins/corp-sso/secrets/seal{ "path": "clientSecret", "value": "…" }→ 200 { "path": "clientSecret", "sealed": "v1.…" }
POST /admin/v1-rc.0/realms/{realmId}/plugins/corp-sso/configQAuth-Approval: {approvalId}{ "version": 0, "config": { "issuer": "https://idp.example", "clientId": "qauth" }, "secrets": { "clientSecret": { "sealed": "v1.…" } } }A disabled plugin’s routes answer 404 with the not_found document of a path no route
serves, before the request’s body is read and without the rate limit’s headers; disabling keeps
the config. They are checked after the rate limit, so a flood past it answers 429 where an unknown
path answers 404: that tells a plugin of that type is installed, never which realms have it on.
The generic OpenID Connect client
Section titled “The generic OpenID Connect client”libs/server/upstream-oidc (@qauth-labs/server-upstream-oidc) is the relying-party client QAuth
uses toward an upstream OpenID Provider: createUpstreamOidcMethod({ type, display }) builds one
AuthMethod per configured provider, with the id oidc. It makes no account decision; the
upstream sign-in flow (#639) looks the identity up by (realm, type, externalSub) and decides.
Callback path. A provider’s type is oidc_ and a lower-case alias (oidc_corp), so its
callback is /upstream/oidc_<alias>/callback on the realm host, one per provider, and the
redirect_uri an operator registers at the provider is
https://<realm host>/upstream/oidc_<alias>/callback (upstreamOidcRedirectUri). The same path
answers GET (a query response) and POST (a form_post response, which the bounce sends on to
the GET callback on the same origin, so the browser brings the SameSite=Lax flow cookie).
Config (upstreamOidcConfigSchema): issuer, clientId, clientAuthentication
(client_secret_basic or client_secret_post with a writeOnly clientSecret, or
private_key_jwt with the key’s signingAlgorithm), scopes (with openid; openid email profile by default), responseMode (query or form_post), idTokenSigningAlgorithms
(asymmetric only, never none or HS*), fetchUserinfo, and optional endpoints.
What it does:
- Discovery reads
<issuer>/.well-known/openid-configurationthrough the egress client, as anadmindestination. The document’sissuermust equal the configured one byte for byte, every URL it names is anhttpsURL with no credentials or fragment, and each goes out as aremote_documentdestination, which the egress allowlist never opens: a discovered endpoint never reaches a private or loopback address, whatever the allowlist says. A provider on a private address setsendpointsby hand instead (authorization, token, JWKS, optional userinfo, and whether it sendsiss); those areadmindestinations, which an allowlist entry naming the realm may open, and no discovery document is read. - The authorization request carries
response_type=code, the client id, the provider’s ownredirect_uri, the scopes, a freshstate(the binding), a freshnonceand PKCE with S256. - The callback checks the transaction is this provider’s (its type, issuer and
redirect_uri), checksiss(RFC 9207: a response naming another issuer is refused, and so is one withoutissfrom a provider that says it sends it), ends asdeclinedonaccess_denied, and redeems the code with the verifier. - Client authentication at the token endpoint is
client_secret_basic(each part form-urlencoded first, RFC 6749 §2.3.1),client_secret_post, orprivate_key_jwt: the signer role signs the assertion with the realm’supstream_client_assertionkey for the provider (issandsubare the client id the signer’s upstream configuration names,audthe issuer, with a freshjtiand a 60-secondexp). The method sends the string it gets back and never sees a key. - The ID token is checked against the provider’s key set (fetched through the egress client,
kept at most five minutes, fetched again for an unknown
kidat most once a minute) with an allowlist of algorithms, theniss,aud,azp(required when there are several audiences),exp,iat(at most ten minutes old) andnonce, with 30 seconds of clock tolerance. - Userinfo, when
fetchUserinfois set, must carry the ID token’ssub. The identity’sexternalSubis the provider’ssub, and itsrawClaimsare the ID token’s claims over the userinfo claims,issamong them. The protocol claims (acr,amr,auth_time,sid,nonce, the validity claims and the hashes) come from the signed ID token alone; userinfo’s are dropped.extractAttributes()takes the standard profile claims that are plain text;emailandphone_numberare verified only when the provider’s own*_verifiedclaim istrue. - Every call asks for
redirect: 'error'(no redirect is followed) and has its own deadline and response cap: 5 seconds and 64 KiB for discovery, the key set and userinfo, 10 seconds and 64 KiB for the token request. These only stop the method waiting and reading: they abort no call and limit nothing the egress client fetches. The host egress client’s own limits,totalTimeoutMsandmaxResponseBytes, bound the underlying call.
The stored client secret. sealUpstreamOidcConfig encrypts it with the realm’s
data_at_rest key through the signer role, under the context
upstream-oidc:client-secret:<realm id>:<type>:<destination>, where <destination> is the
SHA-256 of the issuer and, for endpoints set by hand, the token endpoint. So a ciphertext moved to
another provider or realm does not decrypt, and neither does one whose config was pointed at
another host: an admin who cannot read the write-only secret cannot send it elsewhere by changing
issuer or endpoints.token. openUpstreamOidcConfig refuses such a config with
UpstreamClientSecretDestinationChangedError, and provider administration (#714) asks for the
secret again whenever either value changes. The signer lets the api role decrypt a context under
upstream-oidc:client-secret:, and neither web nor delivery. openUpstreamOidcConfig decrypts
the secret for the method’s context, and describeUpstreamOidcConfig is the view an API or event
may carry, with neither the secret nor its ciphertext. Where the stored form lives is provider
administration’s (#714): the client is not a plugin package, so the plugin runtime’s
realm_auth_method_plugins (#600), which seals a plugin’s secrets under contexts of its own, does
not hold it.
How the sign-in flow ties the subject to its issuer. externalSub is the provider’s bare
sub, and the type oidc_<alias> does not name the issuer, while only iss and sub together
identify a person (OpenID Connect Core 1.0 §5.7). rawClaims.iss is always the checked issuer,
and the upstream sign-in flow (#639) stores it with the credential and refuses a later sign-in
under another issuer, so a provider re-pointed at another issuer never hands its subjects the
old accounts. The host opens the stored
config and builds the method’s context as a signer caller that may decrypt the client secret and
sign client assertions: api may do both, and web, which serves the sign-in pages where a
method’s routes mount, may do neither. So in a single deployment the host calls as local, as the
plugin runtime does (above); which role calls once the signer runs
apart is #497’s.
At registration. checkUpstreamOidcProvider(egressClient, realmName, config) runs discovery
and the egress client’s address checks on every endpoint without calling it, and answers one
finding: invalid_config, discovery_unavailable, invalid_document or destination_refused.
Each use checks again, since every call goes through the egress client.
Checking a method: the contract test kit
Section titled “Checking a method: the contract test kit”checkAuthMethod(method, scenario) is the one function a method’s author calls, in any test
runner. The author plays the provider: the scenario gives a config, answers the method’s
outbound calls, and says what the browser comes back with. It is test support: it comes from the
SDK’s /testing entry point, never the main one, and it refuses to run with
NODE_ENV=production, because it replaces the global fetch while it runs.
import { checkAuthMethod } from '@qauth-labs/plugin-sdk/testing';
it('meets the AuthMethod contract', async () => { await checkAuthMethod(myMethod, { config: { clientId: 'kit-client', clientSecret: 'kit-secret' }, egress: (request) => fakeProvider.answer(request), callback: (authorizationRequest) => fakeProvider.signIn(authorizationRequest), declined: (authorizationRequest) => fakeProvider.decline(authorizationRequest), });});It resolves with the checks it ran and rejects with an AuthMethodContractError that lists every
check the method failed:
- the static checks the registry runs, and a config schema that renders as JSON Schema;
start()answers a valid redirect, transaction and binding, and two starts answer different transactions;- two starts answer different bindings, and each sends its binding to the provider, in the redirect or in a body the method posted (as a pushed authorization request does);
- the provider’s callback carries the binding back in the route’s
bindingParameter: the kit compares it as the host does, and calls the handler only when it matches. The callback ends in an identity whoserawClaims.issnames its issuer and whose attributes carry the method’stype, and a second sign-in of the same person resolves the sameexternalSub; - PKCE with S256: an authorization request that asks for a code (
response_type=code) carriescode_challenge_method=S256and acode_challenge, and every token request the callback makes (grant_type=authorization_code) carries thecode_verifierwhose S256 is that challenge; verify()rejects inputs no callback produced: a well-formed identity, a copy of a real result, and a result it has already verified;- a declined callback ends as
declined, when the scenario gives one; - the method never calls the global
fetchwhile the kit runs it (the kit cannot seenode:https,undicior afetchkept from before; the lint ruleqauth/no-direct-networkrefuses those in QAuth’s own code); - no log line carries the value of a
writeOnlyconfig member: any string inside such a member, found throughproperties,items,additionalProperties,anyOf,oneOf,allOfand local$refs, as it is, in base64 or base64url, percent-encoded, or as HTTP Basic credentials with another string of the config, in a field, a message, or an error’s message, stack, cause or own fields. Every secret is looked for, however short, so give the scenario’s secrets a realistic length: a short one can match text that is no leak. It is a check for common leaks, not a proof that none exists.
libs/sdk/plugin-sdk/testing/sample-auth-method.fixture.ts is a sample method that passes;
libs/sdk/plugin-sdk/src/testing/contract-kit.test.ts shows the broken methods the kit refuses,
each with the problems it reports.
Write a plugin in your own repository
Section titled “Write a plugin in your own repository”A plugin needs nothing of QAuth’s own repository. The contract, its errors and static checks, and
the test kit are the npm package @qauth-labs/plugin-sdk, which depends on no other QAuth package.
It is experimental, like the contract it carries. Its README and its api-report.md list what
each entry point exports. libs/sdk/plugin-sdk/examples/minimal-plugin is a complete plugin for an
OAuth 2.0 provider with PKCE: QAuth’s release dry run installs the packed SDK into an empty
project on every pull request, and builds and tests the example there.
For example, QAuth’s GitHub plugin is the example of a plugin in its own repository:
@qauth-labs/plugin-github, from the example repository qauth-labs/plugin-github.
It depends only on the published SDK, runs the kit in its CI, and runs an end-to-end suite against
the released server image with the plugin added on top, as an operator adds it. QAuth makes and
releases it, yet it is not on the server’s first-party list: like any other package, it loads only
while PLUGIN_API_ENABLED is on.
Install the SDK. The package carries QAuth’s own version: install the version of the QAuth
release the plugin is for. A release candidate is on npm’s next dist-tag, the first being
0.2.0-rc.1, and a release is on latest; until the first release, latest holds only a
deprecated 0.0.0 placeholder. Zod is a peer dependency, so install it beside the SDK:
npm install @qauth-labs/plugin-sdk@0.2.0-rc.1 zodExport the method. The package’s entry point exports its method as authMethod
(above):
import { AUTH_METHOD_CONTRACT, type AuthMethod, UPSTREAM_STEP_URN } from '@qauth-labs/plugin-sdk';import { z } from 'zod';
const configSchema = z.object({ clientId: z.string().min(1), clientSecret: z.string().min(1).meta({ writeOnly: true }),});
export const authMethod: AuthMethod<z.output<typeof configSchema>, CorpSsoTransaction> = { contract: AUTH_METHOD_CONTRACT, type: 'corp_sso', id: 'corp-sso', display: { name: 'Corp SSO' }, configSchema, steps: [UPSTREAM_STEP_URN], // routes, start, verify, extractAttributes};AUTH_METHOD_CONTRACT is the contract version of the SDK release you install. A server refuses a
plugin written against another version, so pin the SDK to the QAuth release you build for.
Run the kit. checkAuthMethod comes from @qauth-labs/plugin-sdk/testing and works in any
test runner (above). Under Node’s own:
import { test } from 'node:test';
import { checkAuthMethod } from '@qauth-labs/plugin-sdk/testing';
import { authMethod } from './index.js';
test('meets the AuthMethod contract', async () => { await checkAuthMethod(authMethod, scenario);});Publish. Publish the compiled JavaScript and its declarations: Node does not strip TypeScript
types under node_modules. List @qauth-labs/plugin-sdk under dependencies, because the
contract’s constants are part of the method at run time. List zod under peerDependencies, so
the method’s schema is built with the server’s Zod (secret members):
{ "name": "@example/qauth-plugin-corp-sso", "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, "files": ["dist"], "dependencies": { "@qauth-labs/plugin-sdk": "0.2.0-rc.1" }, "peerDependencies": { "zod": "^4.6.7" }}What the operator does (above):
- installs the package with the deployment, as any other dependency of the auth server: on top of the published image, as Published images shows;
- lists it by package name in
AUTH_METHOD_PLUGINS; - turns on
PLUGIN_API_ENABLED=true. A plugin outside QAuth’s first-party list loads only while the switch is on. With the switch off, the boot logs a warning that names the package and says why it was not loaded; - enables and configures it per realm through the admin API’s plugin operations.
AUTH_METHOD_PLUGINS=@example/qauth-plugin-corp-ssoPLUGIN_API_ENABLED=trueA plugin is trusted code that runs in the server’s process, beside its keys in a single deployment. Install only a package you trust as you trust the server.