Identity (OIDC)
scripts/gen-capability-docs.py. Edit it here; editing the generated page directly is
Quick start
Identity is not called with an API key on the public gateway, and a quickstart that told you to get one would be describing a different product. It is an OIDC issuer: you integrate against it the way you integrate against any OpenID Connect provider, using a realm and a client that were provisioned for you.
Prerequisites
- A realm — provisioned by us, not self-serve. There is no public signup route for this product.
- A client id, and for confidential clients a secret, issued with the realm.
- The issuer:
https://auth.vagarylabs.com
Step 1 — read the discovery document, do not hardcode endpoints
curl -s https://auth.vagarylabs.com/.well-known/openid-configuration | jq
This is the contract. Endpoint paths, supported grants and the JWKS location all come from here, and reading them at startup is the difference between a client that survives an issuer change and one that breaks on a Tuesday. Hardcode the issuer only.
Step 2 — authorization code with PKCE
PKCE is the path to build on. authorization_code and refresh_token are advertised for every
realm; the password grant is not general — it is restricted to a named allowlist and, at the
time of writing, exactly one legacy realm is on it. Verified in the deployed artefact, not inferred:
both replicas serve ROPC_REALMS = ["vagary-voice"]. Assume your realm is not on that list, because
it almost certainly is not, and because the list is shrinking by design.
Step 3 — verify tokens against JWKS, and re-fetch on an unknown kid
Fetch the JWKS URL from discovery and cache it, keyed by kid. On a kid you have not seen,
re-fetch before rejecting the token. Rotation publishes the new key and keeps serving the old one
for an overlap window, precisely so in-flight tokens stay verifiable; a client that caches JWKS
forever and rejects unknown kids will fail intermittently during a rotation, and the failure will
look like a token problem rather than a caching one.
SDK
.github/workflows/sdk-publish.yml. It is documented here as the integration SHAPE, and the paragraph below states it is not yet installable. Remove this marker when the package is
Not yet publicly installable. @vagary/identity is the internal client for this capability; it
is not published to npm today, so npm install @vagary/identity will not resolve. It is shown here
because it is the integration shape we support, and because the disclosure rules below are easy to
get wrong by hand — if you are integrating now, implement the routes directly and mirror this
behaviour. Ask us for the package if you want it ahead of publication.
import { VagaryIdentity } from '@vagary/identity';
const identity = new VagaryIdentity({ baseUrl: 'https://auth.vagarylabs.com', realm: 'acme' });
const issue = await identity.checkPassword(pw); // null = nothing to say
await identity.requestEmailVerification({ email });
await identity.verifyEmail({ token });
await identity.requestPasswordReset({ email });
Three entry points, and the split is structural rather than stylistic: ., ./browser and
./server. The provisioning half is a different module because it holds a credential — so the
bundle you ship to a browser cannot import the code that would need a secret to work. That is
enforced by the package layout, not by a comment asking you not to.
Security
Password policy is fetched, never hardcoded. checkPassword() returns null when there is
nothing to say. Do not reimplement the rules client-side; they change server-side and a copy drifts.
Disclosure is uniform on purpose. requestEmailVerification and requestPasswordReset resolve
the same way whatever address you pass — a registered one, an unregistered one, a malformed one.
This is not a missing error path. Making those responses differ turns the endpoint into an account
enumeration oracle, so if you are tempted to "improve the error handling" here, that is the property
you would be removing.
Errors come in three kinds because a caller retries them differently: an input problem you should
not retry, a credential problem you should surface, and a transport problem you should. The SDK
distinguishes them; collapsing them into one catch loses the distinction that makes retry safe.
Operations
Signing keys rotate with an overlap. A rotation activates a new key immediately, and the previous key keeps being served until the overlap expires, so tokens minted just before the change still verify. Rollback ends the overlap deliberately rather than by waiting.
Rotation is per authorization-server cell: rotating the canonical cell does not disturb a dedicated or private one. If you operate a dedicated cell, its rotation is its own event.
The consequence for integrators is the one in Step 3: cache JWKS, but treat an unknown kid as
"re-fetch", never as "reject".
- Sells as: the
identityproduct, provisioned by sales rather than self-serve — there is no public product route to call. The capability contract below is the whole surface.
The fleet identity provider — one OIDC issuer for all products, so a single account authenticates across the fleet (the cross-product SSO the census found impossible). Signs RS256/JWKS; per-brand realms. Consolidates the 19-service platform auth + voice + bellring (C3). The signing key is resolved server-side and NEVER present in a repo, a consumer, or a facade. Status: OIDC provider LIVE (discovery+JWKS+authorize+token+userinfo+introspect+revoke+ session/end served by node-oidc-provider) + strong-auth surface (MFA/WebAuthn/lockout/enterprise-SSO) ABSORBED from vagary-platform (Track S core-merge, additive). Consolidation remains the staged C3 build: Phase 1 ETL → G0 gate → Phase 2 repoint-with-fallback → Phase 3 per-consumer deletes (voice last).
- Group: Auth & gateway
- Contract:
contracts/identity/v1/openapi.yaml - Console: Manage the
identityproduct in the console → - Runbook: operational checklist for
identity— internal (Vagary Labs ops; not part of this public site):docs/runbooks/capability-operations.md#identity - Public access: none — internal-only capability. It is NOT exposed on the public API gateway (
https://api.vagarylabs.com); it is reachable only inside the fleet (container/tailnet) by first-party callers. There is no customer-facing endpoint to call.
Endpoints
| Method | Path | Summary |
|---|---|---|
GET | /.well-known/openid-configuration | OIDC discovery document (GROUNDED — live in scaffold) |
GET | /jwks | JSON Web Key Set — public keys for RS256 verification (GROUNDED — live in scaffold) |
POST | /token | OAuth2/OIDC token endpoint (GROUNDED — real grant handling via node-oidc-provider) |
POST | /device/auth | OAuth 2.0 Device Authorization endpoint (RFC 8628 §3.1; GROUNDED — served by node-oidc-provider's deviceFlow, enabled for the native CLI clients that opt in via realms.ts deviceFlow: true). The CLI posts client_id + scope here, shows the returned user_code, sends the person to /device on ANY browser, and polls /token with grant_type urn:ietf:params:oauth:grant-type:device_code. First consumer: paperclip vagaris auth login --device (vagris #83). Declared because a served endpoint a consumer depends on is a public-surface commitment, not a side effect of a flag. |
GET | /health | liveness |
GET | /metrics | Prometheus text exposition |
POST | /provision/users | Provision a product end-user (internal signup delegation — identity is the SOLE cred store) |
POST | /provision/users/{id}/platform-principal | Ensure (create-or-reuse) the linked customer-platform principal for this principal's Person |
PATCH | /provision/users/{id} | Reclassify a principal's class (FLEET-ONLY) |
DELETE | /provision/users/{id} | Delete a provisioned principal (FLEET-ONLY — the inverse of provisionUser) |
POST | /provision/password | Change a product end-user's password (internal delegation) |
POST | /password-reset/request | Begin a self-service password reset |
POST | /password-reset/confirm | Redeem a reset token and set a new password |
POST | /email-verification/request | Begin email verification for an address |
POST | /email-verification/confirm | Redeem an email-verification token |
GET | /signup | The hosted signup page for a registered application |
POST | /signup | Begin (or restart) a registration and mail a proof link |
GET | /signup/confirm | The confirmation interstitial a proof link opens |
POST | /signup/confirm | Consume the proof and activate the identity |
POST | /signup/resend | Re-send the proof link for a live registration |
GET | /signup/status/{ref} | Resume/support view of one signup transaction |
GET | /password-policy | The password rules a product renders but does not decide |
POST | /provision/erase-subject | GDPR Art-17 SUBJECT-scoped erasure — forget a person across all 25 identity tables |
POST | /provision/erase-orphan-person | Remove ONE persons row that no principal references (the governed orphan-person cleanup) |
POST | /v1/keys/rotate | Rotate the signing key, keeping the previous one published for an overlap window |
GET | /v1/keys/status | The current signing-key rotation state |
POST | /v1/keys/force-expire-overlap | End the overlap window immediately, unpublishing the previous key |
POST | /provision-credentials | Issue a provisioning credential bound to one organization (returns the raw token ONCE) |
GET | /provision-credentials | List one organization's provisioning credentials (metadata only — never the token or its hash) |
POST | /provision-credentials/{id}/revoke | Revoke a provisioning credential |
POST | /applications | Register an identity application |
GET | /applications | List a realm's applications |
GET | /applications/resolve | Resolve a host to its application and branding |
GET | /applications/{id} | Read one application (realm-scoped) |
PATCH | /applications/{id} | Update an application (realm-scoped) |
DELETE | /applications/{id} | Delete an application (realm-scoped); its hosts go with it |
POST | /applications/{id}/hosts | Register a host for an application (realm-scoped) |
GET | /applications/{id}/hosts | List an application's hosts (realm-scoped) |
DELETE | /applications/{id}/hosts/{host} | Unregister a host from an application (realm-scoped) |
POST | /applications/{id}/hosts/{host}/verification | Issue the DNS TXT challenge for a host (returns the nonce ONCE) |
POST | /applications/{id}/hosts/{host}/verification/check | Complete the challenge — resolve the TXT record and compare it to the issued nonce |
POST | /identity-realms | Self-provision a customer realm (its isolation boundary) |
GET | /identity-realms | List an organization's realms (owner-scoped) |
GET | /authorize | OAuth2/OIDC authorization endpoint (authorization_code + PKCE) — GROUNDED |
GET | /userinfo | OIDC UserInfo (bearer access token) — GROUNDED |
POST | /request | RFC 9126 pushed authorization request (PAR) — GROUNDED |
POST | /introspect | RFC 7662 token introspection (client-authenticated) — GROUNDED |
POST | /revoke | RFC 7009 token revocation (client-authenticated) — GROUNDED |
POST | /register | OIDC dynamic client registration — disabled v1 compatibility tombstone |
GET | /session/end | OIDC RP-initiated logout (end_session) — GROUNDED |
POST | /account/mfa/enable | begin TOTP MFA enrollment (returns secret + backup codes ONCE) |
POST | /account/mfa/activate | verify the first TOTP code and activate MFA |
POST | /account/mfa/verify | verify a TOTP or a one-time backup code |
POST | /account/mfa/disable | disable MFA for a user |
GET | /account/mfa/status | MFA status for a user |
POST | /account/webauthn/register-begin | begin WebAuthn passkey registration (issue challenge) |
POST | /account/webauthn/register-complete | complete WebAuthn passkey registration |
POST | /account/webauthn/authenticate-begin | begin WebAuthn assertion (issue challenge) |
POST | /account/webauthn/authenticate-complete | complete WebAuthn assertion (sign-count anti-clone enforced) |
GET | /account/webauthn/credentials | list a user's registered passkeys |
POST | /account/webauthn/credentials-remove | remove a user's passkey |
POST | /account/sso/providers | register an upstream SSO provider (saml |
GET | /account/sso/providers | list registered SSO providers |
PATCH | /account/sso/providers | update an SSO provider's config |
GET | /account/sso/provider | read ONE SSO provider (singular — the sibling of the plural list above) |
POST | /account/sso/providers/delete | delete an SSO provider |
POST | /account/sso/initiate | initiate SSO login (SAML redirect / OIDC authorization URL) |
POST | /account/sso/callback | handle an SSO callback (SAML response / OIDC code) |
POST | /account/sso/link | link an upstream SSO identity to a local user |
GET | /account/sso/links | list a user's SSO links |
POST | /account/sso/unlink | remove an SSO link |
POST | /account/sessions/create | create a tracked device session |
GET | /account/sessions | list a user's active sessions |
POST | /account/sessions/revoke | revoke a single session |
POST | /account/sessions/revoke-all | revoke all sessions (optionally all-except keep_session_id) |
POST | /account/security/revoke-user-tokens | blanket-revoke ALL of a user's tokens (logout-all / compromised account) |
POST | /account/security/revoke-jti | deny a specific token by its jti for a ttl |
GET | /account/security/token-status | check per-user blanket + per-jti revocation status (for consumers enforcing revocation) |
GET | /account/security/revocations | incremental revocation delta since a monotonic cursor (JTI denylist + user-blanket entries) |
POST | /account/social/authorize | NOT IMPLEMENTED — no such route; answers 404 |
POST | /account/social/callback | NOT IMPLEMENTED — no such route; answers 404 |
GET | /account/social/links | NOT IMPLEMENTED — no such route; answers 404 |
POST | /account/social/unlink | NOT IMPLEMENTED — no such route; answers 404 |
POST | /account/api-keys | issue an org-scoped api-key for a user (returns the raw key ONCE) |
GET | /account/api-keys | list an org's api-keys (redacted — no hash); optional user_id narrows to one user |
POST | /account/api-keys/revoke-all | revoke every api-key an organization holds in a realm |
POST | /account/api-keys/verify | verify a raw api-key (raw key in the body — never a URL); returns org/user/scope context |
PATCH | /account/api-keys/{keyId} | update an org's api-key name and/or scopes (folds platform update_key) |
DELETE | /account/api-keys/{keyId} | revoke an org's api-key by its external handle |
POST | /session/context | select the organisation + application this session operates as — the decision between resolving what a principal can reach and letting it act (ruling §3.10.2). Requires ACCESS (an active membership held by any principal of the caller's person, in any directory, OR an active non-expired delegation FROM the target organisation TO the caller's own) AND ELIGIBILITY (the application's product must be attached to that organisation; a NULL product_id is not product-restricted). Returns the verdict and how access was reached — membership and delegation are reported distinctly and never merged. Deliberately does NOT mint, because selecting across directories would require the target directory's client secret, which this process does not hold. |
GET | /operator/organizations | provider-operable contexts BEYOND membership — organisations this principal may operate without belonging to them (ruling §3.10.2). Requires principal_class=provider AND an explicit provider:organizations:list grant; a class alone is never the authority. Delegated contexts are included only while an ACTIVE, non-expired delegation names the caller's provider org as grantee, and each carries its expiry and the grant's reason. Memberships are deliberately NOT merged in here — /me/organizations answers that, and a console composes the two. |
GET | /me/principal | Read the verified caller's current principal activity |
GET | /me/organizations | the organizations THIS PERSON can reach through any principal they hold (person resolved from the bearer's sub, never a param); each entry names held_by |
POST | /me/organizations/switch | enter an existing same-person organization context (minted for its membership holder, returned as held_by) |
POST | /me/reauth | verified again a privileged operation can proceed — password re-check mints a short-lived reauth assertion (never a session/token) |
POST | /admin/principal-resolution/transfer-ownership | transfer an organization's ownership to another human principal (step-up required) |
POST | /admin/principal-resolution/remove-membership | remove a membership (authority-reducing; step-up optional and recorded) |
POST | /admin/principal-resolution/suspend-principal | suspend a principal and revoke its api keys, sessions, refresh tokens and issued tokens |
POST | /admin/principal-resolution/reclassify-subject | declare a real subject synthetic (real to synthetic only) |
POST | /admin/principal-resolution/bulk | apply an ordered list of principal-resolution acts all-or-nothing under ONE step-up |
POST | /widget/tokens | mint a widget-scoped bearer/API key for a first-party product surface |
GET | /oauth/social/{provider}/start | begin a Google or GitHub login that identity brokers for a product's service client |
GET | /oauth/social/callback | the upstream provider's return to identity for a brokered login |
POST | /provision/registrations/login-transaction | exchange a completed registration's reference for a one-time login transaction |
GET | /admin/impersonation-grants | list this organization's impersonation grants (owner/admin) |
POST | /admin/impersonation-grants | confer an impersonation grant — the acting human is the impersonator |
POST | /admin/impersonation-grants/{id}/approve | approve a pending impersonation grant — a different owner/admin, fresh step-up |
POST | /admin/impersonation-grants/{id}/revoke | revoke an impersonation grant — narrows authority, step-up optional and recorded |
GET | /admin/workload-bindings | list this organization's workload trust bindings and its bootstrap-exception state |
POST | /admin/workload-bindings | confer a workload trust binding — active at once when ordinary (or under a bootstrap exception), pending a distinct approver when keystone |
GET | /admin/workload-bindings/{id} | read one binding in this organization with its event history |
POST | /admin/workload-bindings/{id}/approve | approve a pending keystone binding — a distinct human holding provider:workload_bindings:approve, with their own fresh step-up |
POST | /admin/workload-bindings/{id}/reject | reject a pending binding (an approver, or the requester withdrawing it), with a reason |
POST | /admin/workload-bindings/{id}/revoke | revoke an active binding — the row is kept and federation stops selecting it at once; either binding grant, a reason, and an optional step-up |
POST | /admin/workload-bindings/revoke-all | broad containment — revoke every active binding for a service identity, or for a repository, in the credential's organization, with one fresh step-up |
POST | /admin/workload-bindings/bootstrap-exceptions | declare a temporary single-human exception to dual control for this organization, with its retirement predicate and expiry |
GET | /admin/workload-bindings/grants | the workload-binding grants (request, approve, bootstrap_exception) held in this organization |
POST | /admin/workload-bindings/grants | grant a workload-binding grant to an EXISTING human in this organization — by an approver, or by the owner of an organization that has no approver yet (bootstrap, bootstrap_exception only) |
POST | /admin/workload-bindings/grants/revoke | revoke a workload-binding grant held by a human in this organization (narrows authority; requires approve) |
POST | /admin/workload-bindings/bootstrap-exceptions/{id}/retire | retire this organization's open bootstrap exception, with a reason |
Schemas
KeyRotationResult
| Field | Type | Description |
|---|---|---|
rotated | boolean | |
new_kid | string | the kid now signing |
overlap_ends_at | string | when the previous key is unpublished |
active_kid | string |
KeyRotationStatus
| Field | Type | Description |
|---|---|---|
activeKid | string | |
previousKid | ['string', 'null'] | null when no rotation has happened |
overlapActive | boolean | |
overlapEndsAt | ['integer', 'null'] | epoch-ms; null when no overlap is open |
currentKeyAge | integer | milliseconds since the active key was created |
Jwks
| Field | Type | Description |
|---|---|---|
keys | array |
ProvisionUserRequest
| Field | Type | Description |
|---|---|---|
realm | string | per-brand realm the user belongs to |
email | string | |
password | string | |
organization_id | string | product-tier end-customer tenant (I6 — never Paperclip companyId) |
name | ['string', 'null'] | |
roles | array | |
principal_class | string | ADR-133 B5 discriminator. Omitted defaults to customer-app-user. ELEVATION IS FLEET-ONLY — a customer provisioning credential may name only customer-app-user; anything else is 403 principal_class_forbidden, so a tenant cannot mint the platform's own human root through a route it is entitled to call. anonymous is absent from this enum deliberately: it is the GUEST class derived from kind='guest', and this route only writes kind='user' rows. |
login_intent | object | OW-1142 (c). Ask that this registration end in a login for the named client (this realm's svc client, holding registration_login). Validated before the user is created; a malformed intent is 422 invalid_login_intent and creates nothing. |
ProvisionedUser
| Field | Type | Description |
|---|---|---|
id | string | |
email | string | |
realm | string | |
organization_id | string | |
principal_class | string | Echoed so the caller can VERIFY what it got rather than assume the default held. A silent downgrade to customer-app-user is the failure this field exists to make visible. |
registration_login | object | Present only when login_intent was supplied. ready carries a login transaction (the address is verified or the realm does not require it — IDENTITY_REGISTRATION_LOGIN_REQUIRE_VERIFIED_EMAIL__<REALM>=false); pending_verification carries a registration_ref for POST /provision/registrations/login-transaction. |
LoginTransaction
| Field | Type | Description |
|---|---|---|
login_transaction | string | one-time, short-lived, bound to user/organization/client/state; redeem with grant_type=oauth_login |
expires_in | integer |
ImpersonationGrant
| Field | Type | Description |
|---|---|---|
id | string | |
realm | string | |
client_id | string | |
actor_id | string | |
target_user_id | string | |
organization_id | string | |
reason | string | |
scopes | array | |
expires_at | integer | |
approval_required | boolean | |
approved_by | ['string', 'null'] | |
approved_at | ['integer', 'null'] | |
created_at | integer | |
consumed_at | ['integer', 'null'] | |
revoked_at | ['integer', 'null'] | |
revoked_by | ['string', 'null'] | |
revocation_reason | ['string', 'null'] | |
status | string |
PasswordResetRequest
| Field | Type | Description |
|---|---|---|
realm | string | |
email | string |
Generated by scripts/gen-capability-docs.py from contracts/identity/v1/openapi.yaml — the contract IS the source of truth; edit the contract, not this page. The Edge route table section is synced from the live edge catalog snapshot (v1) — re-sync with python3 scripts/sync-edge-catalog.py.