Skip to main content

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​

  1. A realm — provisioned by us, not self-serve. There is no public signup route for this product.
  2. A client id, and for confidential clients a secret, issued with the realm.
  3. 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 identity product, 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 identity product 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​

MethodPathSummary
GET/.well-known/openid-configurationOIDC discovery document (GROUNDED — live in scaffold)
GET/jwksJSON Web Key Set — public keys for RS256 verification (GROUNDED — live in scaffold)
POST/tokenOAuth2/OIDC token endpoint (GROUNDED — real grant handling via node-oidc-provider)
POST/device/authOAuth 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/healthliveness
GET/metricsPrometheus text exposition
POST/provision/usersProvision a product end-user (internal signup delegation — identity is the SOLE cred store)
POST/provision/users/{id}/platform-principalEnsure (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/passwordChange a product end-user's password (internal delegation)
POST/password-reset/requestBegin a self-service password reset
POST/password-reset/confirmRedeem a reset token and set a new password
POST/email-verification/requestBegin email verification for an address
POST/email-verification/confirmRedeem an email-verification token
GET/signupThe hosted signup page for a registered application
POST/signupBegin (or restart) a registration and mail a proof link
GET/signup/confirmThe confirmation interstitial a proof link opens
POST/signup/confirmConsume the proof and activate the identity
POST/signup/resendRe-send the proof link for a live registration
GET/signup/status/{ref}Resume/support view of one signup transaction
GET/password-policyThe password rules a product renders but does not decide
POST/provision/erase-subjectGDPR Art-17 SUBJECT-scoped erasure — forget a person across all 25 identity tables
POST/provision/erase-orphan-personRemove ONE persons row that no principal references (the governed orphan-person cleanup)
POST/v1/keys/rotateRotate the signing key, keeping the previous one published for an overlap window
GET/v1/keys/statusThe current signing-key rotation state
POST/v1/keys/force-expire-overlapEnd the overlap window immediately, unpublishing the previous key
POST/provision-credentialsIssue a provisioning credential bound to one organization (returns the raw token ONCE)
GET/provision-credentialsList one organization's provisioning credentials (metadata only — never the token or its hash)
POST/provision-credentials/{id}/revokeRevoke a provisioning credential
POST/applicationsRegister an identity application
GET/applicationsList a realm's applications
GET/applications/resolveResolve 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}/hostsRegister a host for an application (realm-scoped)
GET/applications/{id}/hostsList an application's hosts (realm-scoped)
DELETE/applications/{id}/hosts/{host}Unregister a host from an application (realm-scoped)
POST/applications/{id}/hosts/{host}/verificationIssue the DNS TXT challenge for a host (returns the nonce ONCE)
POST/applications/{id}/hosts/{host}/verification/checkComplete the challenge — resolve the TXT record and compare it to the issued nonce
POST/identity-realmsSelf-provision a customer realm (its isolation boundary)
GET/identity-realmsList an organization's realms (owner-scoped)
GET/authorizeOAuth2/OIDC authorization endpoint (authorization_code + PKCE) — GROUNDED
GET/userinfoOIDC UserInfo (bearer access token) — GROUNDED
POST/requestRFC 9126 pushed authorization request (PAR) — GROUNDED
POST/introspectRFC 7662 token introspection (client-authenticated) — GROUNDED
POST/revokeRFC 7009 token revocation (client-authenticated) — GROUNDED
POST/registerOIDC dynamic client registration — disabled v1 compatibility tombstone
GET/session/endOIDC RP-initiated logout (end_session) — GROUNDED
POST/account/mfa/enablebegin TOTP MFA enrollment (returns secret + backup codes ONCE)
POST/account/mfa/activateverify the first TOTP code and activate MFA
POST/account/mfa/verifyverify a TOTP or a one-time backup code
POST/account/mfa/disabledisable MFA for a user
GET/account/mfa/statusMFA status for a user
POST/account/webauthn/register-beginbegin WebAuthn passkey registration (issue challenge)
POST/account/webauthn/register-completecomplete WebAuthn passkey registration
POST/account/webauthn/authenticate-beginbegin WebAuthn assertion (issue challenge)
POST/account/webauthn/authenticate-completecomplete WebAuthn assertion (sign-count anti-clone enforced)
GET/account/webauthn/credentialslist a user's registered passkeys
POST/account/webauthn/credentials-removeremove a user's passkey
POST/account/sso/providersregister an upstream SSO provider (saml
GET/account/sso/providerslist registered SSO providers
PATCH/account/sso/providersupdate an SSO provider's config
GET/account/sso/providerread ONE SSO provider (singular — the sibling of the plural list above)
POST/account/sso/providers/deletedelete an SSO provider
POST/account/sso/initiateinitiate SSO login (SAML redirect / OIDC authorization URL)
POST/account/sso/callbackhandle an SSO callback (SAML response / OIDC code)
POST/account/sso/linklink an upstream SSO identity to a local user
GET/account/sso/linkslist a user's SSO links
POST/account/sso/unlinkremove an SSO link
POST/account/sessions/createcreate a tracked device session
GET/account/sessionslist a user's active sessions
POST/account/sessions/revokerevoke a single session
POST/account/sessions/revoke-allrevoke all sessions (optionally all-except keep_session_id)
POST/account/security/revoke-user-tokensblanket-revoke ALL of a user's tokens (logout-all / compromised account)
POST/account/security/revoke-jtideny a specific token by its jti for a ttl
GET/account/security/token-statuscheck per-user blanket + per-jti revocation status (for consumers enforcing revocation)
GET/account/security/revocationsincremental revocation delta since a monotonic cursor (JTI denylist + user-blanket entries)
POST/account/social/authorizeNOT IMPLEMENTED — no such route; answers 404
POST/account/social/callbackNOT IMPLEMENTED — no such route; answers 404
GET/account/social/linksNOT IMPLEMENTED — no such route; answers 404
POST/account/social/unlinkNOT IMPLEMENTED — no such route; answers 404
POST/account/api-keysissue an org-scoped api-key for a user (returns the raw key ONCE)
GET/account/api-keyslist an org's api-keys (redacted — no hash); optional user_id narrows to one user
POST/account/api-keys/revoke-allrevoke every api-key an organization holds in a realm
POST/account/api-keys/verifyverify 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/contextselect 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/organizationsprovider-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/principalRead the verified caller's current principal activity
GET/me/organizationsthe 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/switchenter an existing same-person organization context (minted for its membership holder, returned as held_by)
POST/me/reauthverified again a privileged operation can proceed — password re-check mints a short-lived reauth assertion (never a session/token)
POST/admin/principal-resolution/transfer-ownershiptransfer an organization's ownership to another human principal (step-up required)
POST/admin/principal-resolution/remove-membershipremove a membership (authority-reducing; step-up optional and recorded)
POST/admin/principal-resolution/suspend-principalsuspend a principal and revoke its api keys, sessions, refresh tokens and issued tokens
POST/admin/principal-resolution/reclassify-subjectdeclare a real subject synthetic (real to synthetic only)
POST/admin/principal-resolution/bulkapply an ordered list of principal-resolution acts all-or-nothing under ONE step-up
POST/widget/tokensmint a widget-scoped bearer/API key for a first-party product surface
GET/oauth/social/{provider}/startbegin a Google or GitHub login that identity brokers for a product's service client
GET/oauth/social/callbackthe upstream provider's return to identity for a brokered login
POST/provision/registrations/login-transactionexchange a completed registration's reference for a one-time login transaction
GET/admin/impersonation-grantslist this organization's impersonation grants (owner/admin)
POST/admin/impersonation-grantsconfer an impersonation grant — the acting human is the impersonator
POST/admin/impersonation-grants/{id}/approveapprove a pending impersonation grant — a different owner/admin, fresh step-up
POST/admin/impersonation-grants/{id}/revokerevoke an impersonation grant — narrows authority, step-up optional and recorded
GET/admin/workload-bindingslist this organization's workload trust bindings and its bootstrap-exception state
POST/admin/workload-bindingsconfer 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}/approveapprove a pending keystone binding — a distinct human holding provider:workload_bindings:approve, with their own fresh step-up
POST/admin/workload-bindings/{id}/rejectreject a pending binding (an approver, or the requester withdrawing it), with a reason
POST/admin/workload-bindings/{id}/revokerevoke 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-allbroad 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-exceptionsdeclare a temporary single-human exception to dual control for this organization, with its retirement predicate and expiry
GET/admin/workload-bindings/grantsthe workload-binding grants (request, approve, bootstrap_exception) held in this organization
POST/admin/workload-bindings/grantsgrant 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/revokerevoke a workload-binding grant held by a human in this organization (narrows authority; requires approve)
POST/admin/workload-bindings/bootstrap-exceptions/{id}/retireretire this organization's open bootstrap exception, with a reason

Schemas​

KeyRotationResult​

FieldTypeDescription
rotatedboolean
new_kidstringthe kid now signing
overlap_ends_atstringwhen the previous key is unpublished
active_kidstring

KeyRotationStatus​

FieldTypeDescription
activeKidstring
previousKid['string', 'null']null when no rotation has happened
overlapActiveboolean
overlapEndsAt['integer', 'null']epoch-ms; null when no overlap is open
currentKeyAgeintegermilliseconds since the active key was created

Jwks​

FieldTypeDescription
keysarray

ProvisionUserRequest​

FieldTypeDescription
realmstringper-brand realm the user belongs to
emailstring
passwordstring
organization_idstringproduct-tier end-customer tenant (I6 — never Paperclip companyId)
name['string', 'null']
rolesarray
principal_classstringADR-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_intentobjectOW-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​

FieldTypeDescription
idstring
emailstring
realmstring
organization_idstring
principal_classstringEchoed 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_loginobjectPresent 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​

FieldTypeDescription
login_transactionstringone-time, short-lived, bound to user/organization/client/state; redeem with grant_type=oauth_login
expires_ininteger

ImpersonationGrant​

FieldTypeDescription
idstring
realmstring
client_idstring
actor_idstring
target_user_idstring
organization_idstring
reasonstring
scopesarray
expires_atinteger
approval_requiredboolean
approved_by['string', 'null']
approved_at['integer', 'null']
created_atinteger
consumed_at['integer', 'null']
revoked_at['integer', 'null']
revoked_by['string', 'null']
revocation_reason['string', 'null']
statusstring

PasswordResetRequest​

FieldTypeDescription
realmstring
emailstring

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.