Integration patterns#
Requirements: INT-001..INT-010. Each section says what to do in your service
and which SDK function does the ProudAuth-specific part. Everything here
builds on resource-servers.md: a valid token
authenticates a caller; your service still decides, per object, what that
caller may do. SDK names: Go proudauth-sdk-go/verifier (verifier.), Node.js
@proudauth/sdk/verifier, /introspection and /bounded-state
(sdk-compatibility.md).
HTTP APIs (INT-001)#
- Bearer tokens in the
Authorizationheader only.verifier.BearerTokenand the Node.js adapters refuse?access_token=with400 invalid_request, so tokens never land in access logs. DPoP-bound tokens (cnf.jkt) are not verified by the SDKs; verify the proof yourself if you require sender-constrained tokens. - 401 vs 403 vs 404.
401withWWW-Authenticate: Bearer(addingerror="invalid_token"for a bad token) when the caller is not authenticated;403(error="insufficient_scope"for a missing scope) when it is but may not act;404for an object of another organization, so its existence is not disclosed;503when the issuer's keys, introspection or revocation state are unavailable. Never fall back to "allow". The adapters produce the authentication cases; your handler produces the rest (resource-servers.md). - Browser callers (CORS). Answer
OPTIONSpreflights before the authentication adapter (they carry no token). Allow only your application's origins, never*together with credentials, and listWWW-AuthenticateinAccess-Control-Expose-Headersif the browser code reads it. Prefer a backend-for-frontend, which keeps tokens out of the browser altogether (backend-for-frontend.md).
GraphQL (INT-002)#
Authenticate the HTTP request once (verifier.Middleware, middleware,
fastifyHook) and put the claims in the resolver context
(verifier.FromContext, getClaims). Then authorize in each resolver
that returns or changes an object, not only in the top-level query:
- check scopes and
claims.RequireOrganization(object.OrgID)against the object loaded from your store, also for nested fields, connections and each item of a batched or aliased operation; - apply list filters by organization in the data loader's query, not after loading;
- for role-based permissions, call
POST /authorization/check-batchonce per request with one check per object (at mostlimits.max_batch_size, 100 by default) instead of one call per field; - bound query depth and cost: a permitted query must not be able to walk to objects the caller cannot reach directly.
gRPC (INT-003)#
- Read credentials from the
authorizationmetadata key only:verifier.AuthenticateMetadata(ctx, authenticator, md)takes the incoming metadata map and requires exactly oneBearervalue. Wrap it in a unary and a stream interceptor (the function's doc comment shows one) and store the claims withverifier.WithClaims. - Keep a per-method table of required scopes or permissions and deny methods
that are missing from it; check objects inside the method as for HTTP.
Return
Unauthenticatedfor authentication failures,PermissionDeniedorNotFoundfor authorization failures andUnavailablewhen verification dependencies are down. - Streams outlive tokens. A stream authenticated at open must end or be
re-authorized when the token expires (
claims.Expiry), and should stop when the session, user, organization or membership is revoked. See long-lived streams.
WebSockets (INT-004)#
- No reusable tokens in URLs. Query strings end up in proxy and access
logs. Authenticate the upgrade request: from a backend-for-frontend's
session cookie (same-site, checked
Origin), or with theAuthorizationheader where the client can send one (AuthenticateMetadataaccepts the upgrade request's header map). If a browser client can do neither, exchange the session for a single-use, short-lived ticket of your own (seconds, bound to the user and the channel) and pass that, never the access token. - Check
Originon the upgrade. - Bind the connection to the subject, organization and scopes that were authorized, and authorize every subscription and every message that acts on an object against that binding; a connection is not a blanket grant.
- Apply the stream rules below.
Long-lived streams (INT-005)#
An initial handshake is not permanent access. For server-sent events, gRPC streams, WebSockets and long polls:
- set a maximum authorization age: at the latest when the token used at
open expires (
claims.Expiry, a few minutes by default), require a fresh token on the connection or close it; - react to revocation in between: with bounded-state verification,
call
BoundedState.Check(claims)(Node.jsstate.check(claims)) on a timer and close the stream onErrRevokedorErrStateStale; with strict-online validation, re-introspect on the same timer. The state is fed by ProudAuth's session-event stream (GET /session-events), whose own consumers are re-checked on every long poll and about every 2 seconds while one waits, so a consumer that loses its session orrealm.session_events.readis cut off with401or403(TEST-062; resource-servers.md); - on renewal, compare the new token's
sub,org_idand scopes with the binding; a different subject means a new connection.
No SDK helper enforces a maximum age; it is your timer.
Background jobs (INT-006)#
Do not store a user's access or refresh token as the job's authority. Store the authorization decision instead:
| Store | Why |
|---|---|
| The action and its parameters, as approved | The job does only that |
The initiating subject, and the actor if a support operator or service acted for them (act) | Attribution and re-checks |
| The organization | Tenant scope of everything the job touches |
| The approval (who, when), if your process needs one | Separation of duties |
| An expiry, and when to re-validate | Long or delayed jobs must not run on stale authority |
Run the job as a service principal of your own (client credentials,
machine-clients.md) limited to what jobs need. Before
acting, and again at checkpoints of long jobs, re-check that the initiating
subject still may: POST /authorization/check with
"subject": {"type": "user", "id": "usr_..."} instead of a subject token.
Naming a subject requires realm.authorization.simulate (a high-sensitivity
permission; grant it through a custom realm role to the job runner only), and
conditions that need session evidence (MFA, authentication age) fail closed
for such checks. ProudAuth's own jobs follow this pattern: a job records its
requester, and for audit and configuration exports, user imports and bulk user
operations the worker re-checks the permission the request needed when it
runs the job, and the download endpoint checks it again before handing out the
result (exit-and-export.md).
Message consumers (INT-007)#
- Authenticate the producer, not the message: broker credentials or ACLs per producing service, or a token of the producer's service principal carried with the message and verified like any other.
- A
user_idin a message body is data, not authority. It names whom the message is about; whether the producer may act for that user follows from the producer's identity and from records you trust (a job row as above), never from the body. - ProudAuth's own webhooks are signed (HMAC over ID, timestamp and body);
verify the signature and timestamp and deduplicate by
Webhook-Id(webhooks.md). Events are notifications, not revocation: stop access through the verification profile, not by waiting for an event.
File and download grants (INT-008)#
Links your service hands out for downloads are your grants, separate from ProudAuth tokens: generate a random, single-use or short-lived secret, bind it to the object and the user it was issued for, store only its digest, check the binding at download, and keep it out of logs (put it in a path segment you redact, or exchange it for a cookie). Never put a ProudAuth access token in a URL.
ProudAuth's own exports work this way: POST /jobs/{id}/downloads returns a
download_token that is single-use, valid 5 minutes, bound to the job and
its requester (and, for users, to recent authentication), and it is redeemed
in the body of POST /job-downloads, not in a URL
(exit-and-export.md).
Multi-issuer applications (INT-009)#
An application that accepts tokens from several realms or deployments registers each issuer explicitly and maps it to its own tenant:
mi, err := verifier.NewMultiIssuer(map[string]verifier.IssuerRegistration{
"https://id.example.com/realms/acme": {Verifier: acmeVerifier, Tenant: "acme"},
"https://id.example.com/realms/globex": {Verifier: globexVerifier, Tenant: "globex"},
})
tc, err := mi.VerifyTenant(ctx, raw) // tc.Tenant, tc.Subject, ...Node.js: new MultiIssuerVerifier({[issuer]: {verifier, tenant}}) and
verifyTenant(raw) → {claims, tenant}. Each registration's verifier must be
configured for exactly that issuer. The token's iss is read unverified only
to pick the registration; that verifier then checks everything, iss
included. A token from any other issuer fails (ErrIssuer,
VerifierError('issuer')) even when it is validly signed and its subject or
email matches a known user. Key your records by (tenant, subject):
subjects are unique per issuer only, and emails are not identifiers. The
multi-issuer verifiers handle JWT access tokens; for introspection, route by
your own tenant routing to one Introspector per issuer.
Contract validation before sensitive online authorization (INT-010)#
Before a service accepts support (impersonation) tokens, or relies on online
validation for sensitive operations, test its integration for: actor
propagation (act kept in your audit), denial (protected operations, writes
without allowlisted_write, tokens of other audiences), expiry, failure
behavior (introspection or audit store down → deny) and revocation (a revoked
grant or session stops the next request). The reference implementation and
its tests are examples/protected-orders-api with SupportGuard, exercised
by TestSupportImpersonation, TestImpersonationDenials and
TestRevocationConsistencyModes; reuse their cases. There is no packaged test
kit for third-party services.
ProudAuth records the outcome on the resource, not in tokens:
impersonation_policy.contract_version names the support contract version
the service was validated against and is required when
impersonation_enabled is true (422 on
impersonation_policy.contract_version otherwise);
impersonation_policy.contract_validated_at is a free-form note for when
that validation happened. The server checks neither value beyond the
presence of contract_version, so keep the evidence of the test run with
your change record. SupportGuard.Validate() only checks that an
introspector and an auditor are configured; it is not a contract test.
Object-level authorization with relationships (AZ-021..AZ-023, AZ-027)#
When access depends on the object (documents shared with a team, a project board an editor was added to, files inherited from a folder), you can keep those relationships in ProudAuth instead of your own tables (relationship-authorization.md):
- At deployment, register your types once (
PUT /resource-types/{name}): relations,implied_by(anowneris also aneditor), links (parent.viewer) and which application permission each relation gives. Register those permissions first (POST /permissions). - When objects change, write the tuples in the same unit of work that
creates, moves or shares the object
(
POST /organizations/{org}/relationships, up to 100 writes, atomic). Use an outbox or retry if your store and ProudAuth can disagree: a missing tuple denies, a stale one keeps access until removed. Remove tuples when an object is deleted; ProudAuth knows nothing about your objects' lifecycle. - On each request, call
/authorization/checkwithresource.type,resource.idand the organization; enforce obligations as usual. Such decisions are never cacheable, so batch them (/check-batch) instead of caching. - For lists, call
/authorization/list-objectsand filter your query by the returned IDs (or skip the filter whenallistrue). Treattruncated: trueas "there may be more": page withnext_cursor, or narrow the query. The list applies no step-up, ABAC policies or hooks, so check an object again before acting on it.
Keep tenant checks in your service as before: tuples live in one organization, and the decision still needs the organization from your own data, never from the request.