@idp.global/sdk
Reusable TypeScript SDK for building against idp.global from both sides of the wire: browser apps that need SSO, JWT housekeeping, transfer-token handoff, and typed IdP requests; and server apps that need explicit account storage, local password auth, optional idp.global password verification, and confidential OIDC membership introspection.
The package is intentionally split by runtime. Use @idp.global/sdk/browser in browser bundles, @idp.global/sdk/server in Node.js services and @idp.global/sdk/relyingparty for a server application that signs its users in through idp.global. The root @idp.global/sdk export does not expose application APIs.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
What You Get
| Runtime | Import | What it does |
|---|---|---|
| Browser | @idp.global/sdk/browser |
SSO client, JWT and refresh-token storage, login redirects, logout, transfer-token exchange, and typed request shortcuts for user, organization, billing, OIDC, and admin flows. |
| Browser, relying-party sign-in only | @idp.global/sdk/browser/bearer |
RelyingPartyBearerClient and the bearer contract types, without the rest of the browser client; for applications whose server runs @idp.global/sdk/relyingparty in bearer mode. |
| Server | @idp.global/sdk/server |
NoSQL-backed account store, scrypt password hashing, local/idp.global account authentication, a server-side typed-socket client for idp.global password login, and confidential OIDC introspection. |
| Relying party | @idp.global/sdk/relyingparty |
The OIDC relying-party session layer: authorization code flow with PKCE on openid-client, login attempts and HttpOnly cookie sessions with CSRF tokens on NoSQL, sealed provider tokens rotated under a cross-replica lock, live organization authority, HTTP login routes, a TypedRouter actor scope and TypedSocket session binding. |
Install
pnpm add @idp.global/sdk
The server and relying-party subpaths additionally require the peer dependency
@lossless.org/client, which provides the SmartdataDb the account store and the
session models persist through:
pnpm add @lossless.org/client
This is an ESM TypeScript package. Published builds expose declaration files and runtime JavaScript through package exports.
Version 17 requires an IdP listener using TypedSocket 8 and TypedRequest 8
(TypedServer 11). Earlier listener and SDK transport majors cannot share the
WebSocket handshake. Upgrade the IdP app, its browser bundle, and services using
IdpGlobalServerClient together; this includes dcrouter's IdP password-login
flow. HTTP typed requests also use TypedRequest 8 response identities. The
confidential OIDC introspection client continues to use its separate HTTP API.
Browser SDK
Import the browser client from the browser subpath:
import { IdpClient } from '@idp.global/sdk/browser';
Create one client per app shell. The receptionBaseUrl is the typed RPC origin or full /typedrequest URL.
The production typed RPC surface is private at https://app.idp.global; public relying parties should use dynamic OIDC discovery at https://idp.global/.well-known/openid-configuration.
Use loginBaseUrl to send interactive login redirects to public https://login.idp.global.
const idp = new IdpClient(
'https://app.idp.global',
{ appUrl: 'https://app.example.com/' },
{ loginBaseUrl: 'https://login.idp.global' },
);
await idp.enableTypedSocket();
const loggedIn = await idp.determineLoginStatus(true);
if (loggedIn) {
const whoIsResult = await idp.whoIs();
console.log(whoIsResult.user);
}
When determineLoginStatus(true) cannot recover a valid JWT from browser storage, a refresh token, or a transfertoken query parameter, it redirects the user to the configured loginBaseUrl with the minimal { appUrl } transfer target encoded as transfertarget.
transport: 'websocket' is the default and serves the private application API.
The hosted login UI uses transport: 'http', which creates same-origin HTTP TypedRequests and does not open a WebSocket.
Both transports use the same typed request factories.
Token Handling
The browser client stores auth state in @push.rocks/webstore using the idpglobalStore store and main database.
| Method | Purpose |
|---|---|
setJwt(jwt) / getJwt() / deleteJwt() |
Manage the current JWT. |
setRefreshToken(token) / getRefreshToken() / deleteRefreshToken() |
Manage the refresh token. |
clearAuthState() |
Remove both JWT and refresh token. |
getJwtData() |
Decode the current JWT payload without verification through @push.rocks/smartjwt/web. |
performJwtHousekeeping() |
Refresh a JWT after its refreshFrom timestamp or recover from an expired JWT through the refresh token. |
refreshJwt(refreshToken?) |
Exchange a refresh token for a fresh JWT and update stored auth state. |
checkJwtPresent() |
Return whether a usable JWT can be found or refreshed. |
Login With Username And Password
The browser request wrappers are typed request factories.
Call enableTypedSocket() before firing them only when the client uses the default WebSocket transport.
await idp.enableTypedSocket();
const loginResponse = await idp.requests.loginWithUserNameAndPassword.fire({
username: 'developer@example.com',
password: 'correct horse battery staple',
});
if (loginResponse.twoFaNeeded) {
throw new Error('Two-factor authentication is required for this account.');
}
if (loginResponse.refreshToken) {
await idp.setRefreshToken(loginResponse.refreshToken);
await idp.refreshJwt(loginResponse.refreshToken);
}
Transfer Tokens
Transfer tokens are the handoff mechanism for moving a logged-in user between apps without exposing refresh tokens to URLs.
await idp.getTransferTokenAndSwitchToLocation();
On the receiving app, determineLoginStatus() calls processTransferToken() as part of its normal recovery flow. You can also call processTransferToken() directly when you own the route handling.
The SDK accepts only absolute, credential-free HTTPS transfer targets and uses
the same configured target for token exchange and navigation. The
transfertarget query value is a request, not proof of authorization: the IdP
must match it against an exact registered redirect target and bind it to the
single-use transfer token before issuing that token.
Logout
await idp.logout();
logout() clears local auth state and, when a refresh token is available on the IdP origin, asks idp.global to revoke the session before returning to the IdP origin. It never navigates to a URL-derived transfer target.
Browser API Surface
| API | Description |
|---|---|
new IdpClient(receptionBaseUrl, transferTarget?, options?) |
Configure the RPC URL, minimal { appUrl } return target, transport, and login base URL. |
createTypedRequest(method) |
Create a request over the configured HTTP or WebSocket transport. |
enableTypedSocket() / stop() |
Start the WebSocket transport, or stop the client and close any initialized browser auth-store connection. A later storage access reopens the persisted store. |
determineLoginStatus(requireLogin?) |
Resolve login state from JWT, refresh token, transfer token, or optional redirect. |
statusObservable |
RxJS subject that emits idp.global login status changes during refresh flows. |
whoIs() |
Resolve the current user through the active JWT. |
getRolesAndOrganizations() |
Fetch roles and organizations for the current user. |
createOrganization(name, slug, mode) |
Check organization slug availability or manifest an organization. |
updatePaddleCheckoutId(orgId, checkoutId) |
Send a Paddle checkout ID as the org payment method update. |
getTransferToken() |
Request a transfer token for the configured transfer target. |
getTransferTokenAndSwitchToLocation() |
Request a transfer token and navigate to the same configured target with transfertoken attached. |
processTransferToken() |
Consume a transfertoken from the current URL and refresh local auth state. |
logout() |
Revoke and clear the current login flow. |
RelyingPartyBearerClient |
The bearer hand-off client of a relying party's own web and popup logins: PKCE start URL, the redirect and popup receivers, claim, refresh single-flight across tabs through Web Locks, and logout, with the token pair in an application storage adapter (IRelyingPartyBearerTokenStorage). createPkce() and claimCode(pkce, code) claim a code delivered another way, such as a device pairing, without a window. See Bearer Hand-off. |
Typed Request Shortcuts
idp.requests exposes typed request factories backed by @idp.global/interfaces. The request and response payload types come from that package, so your editor can guide exact payload shapes.
SDK 11 adopts interfaces v13. JWT verification-key distribution is keyset-only
and uses the plural getPublicKeysForValidation and
pushPublicKeysForValidation contracts. Transfer-token exchange accepts only
the minimal transferTarget: { appUrl } destination. OIDC app creation requires explicit client
type, grant types, callback and post-logout URIs, and token lifetime settings.
Public clients do not receive a client secret; confidential client secrets are
returned only once when created or rotated. The unsupported
client_credentials grant and API-token login are not exposed.
getUserActivity and exportUserActivity return structured activity events
with finite action, outcome, target-type, reason-code, and allowlisted-fact
fields; persistence IDs, free text, network data, integrity material, and sink
delivery state are not part of the SDK contract.
| Area | Request getters |
|---|---|
| Registration and login | firstRegistration, afterRegistrationEmailClicked, setData, finishRegistration, loginWithUserNameAndPassword, loginWithEmail, loginWithEmailAfterToken, resetPassword, setNewPassword, obtainDeviceId, attachDeviceId |
| Tokens and OIDC | obtainJwt, obtainOneTimeToken, prepareOidcAuthorization, completeOidcAuthorization |
| MFA and passkeys | getMfaStatus, startTotpEnrollment, finishTotpEnrollment, disableTotp, regenerateBackupCodes, verifyMfaChallenge, startPasskeyRegistration, finishPasskeyRegistration, revokePasskey, startPasskeyLogin, finishPasskeyLogin, startPasskeyMfa, finishPasskeyMfa |
| User profile and sessions | getUserSessions, revokeSession, getUserActivity, exportUserActivity, updateProfile, deleteAccount |
| Organizations, members, and apps | getOrganizationById, updateOrganization, deleteOrganization, exportOrgData, getOrgRoleDefinitions, upsertOrgRoleDefinition, deleteOrgRoleDefinition, createInvitation, getOrgInvitations, getOrgMembers, cancelInvitation, resendInvitation, removeMember, updateMemberRoles, transferOwnership, getInvitationByToken, acceptInvitation, bulkCreateInvitations, getGlobalApps, getAppConnections, toggleAppConnection, updateAppRoleMappings |
| Billing | getBillingPlan, getBillingPlansForOrganizationId, getPaddleConfig |
| Passport and alerts | createPassportEnrollmentChallenge, completePassportEnrollment, getPassportDevices, revokePassportDevice, createPassportChallenge, approvePassportChallenge, rejectPassportChallenge, registerPassportPushToken, listPendingPassportChallenges, getPassportChallengeByHint, markPassportChallengeSeen, getPassportDashboard, listPassportAlerts, getPassportAlertByHint, markPassportAlertSeen, dismissPassportAlert, upsertAlertRule, getAlertRules, deleteAlertRule |
| SSO and SCIM | getSsoConnections, createSsoConnection, updateSsoConnection, deleteSsoConnection, getSsoSpDetails, verifySsoDomain, createScimToken, getScimTokens, revokeScimToken, discoverSsoForEmail |
| Global administration | getGlobalUsers, getGlobalOrgStats, setUserSuspension, setGlobalCapabilityAssignments, deleteSuspendedUser, setOrganizationSuspension, checkGlobalAdmin, getGlobalAppStats, createGlobalApp, updateGlobalApp, deleteGlobalApp, regenerateAppCredentials, rotateOidcSigningKey, approveOidcSigningKeyRotation |
Server SDK
Import server-side primitives from the server subpath:
import {
AccountAuthService,
IdpGlobalServerClient,
SmartdataAccountStore,
} from '@idp.global/sdk/server';
The server SDK is deliberately explicit: it never auto-creates accounts during authentication. Your application decides when an account exists, which auth sources it may use, and whether it is an admin or user.
Account Store
SmartdataAccountStore persists accounts through @lossless.org/client/nosqldb
and the exported IdpSdkAccountDoc collection.
@lossless.org/client is a required peer dependency in majors 1 and 2. The
SmartdataDb you pass must come from that package: the account document is
decorated by the very same module, so a SmartdataDb from any other package is
rejected at compile time and cannot resolve the model at runtime. Existing SDK
account documents, their id_1 identity index and their scrypt:v1 password
hashes retain their exact stored shape; this dependency migration does not
transform stored accounts.
import * as nosqldb from '@lossless.org/client/nosqldb';
import { SmartdataAccountStore } from '@idp.global/sdk/server';
const smartdataDb = new nosqldb.SmartdataDb({
mongoDbUrl: process.env.MONGODB_URL!,
mongoDbName: 'my-app',
});
await smartdataDb.init();
const accountStore = new SmartdataAccountStore({ smartdataDb });
if (!(await accountStore.hasActiveAdminAccount())) {
await accountStore.createAccount({
email: 'admin@example.com',
name: 'Admin User',
role: 'admin',
authSources: ['local'],
password: process.env.INITIAL_ADMIN_PASSWORD!,
});
}
Account emails are trimmed and normalized to lowercase for lookups. Local passwords are hashed with Node.js crypto.scrypt, a random salt, and timing-safe verification.
| Method | Purpose |
|---|---|
createAccount(options) |
Persist an explicitly created account. Requires a password when authSources includes local. |
getAccountByEmail(email) |
Find an account by normalized email. |
getAccountById(id) |
Find an account by UUID. |
listAccounts() |
Return all persisted accounts. |
hasActiveAdminAccount() |
Return whether at least one active admin account exists. |
verifyLocalPassword(account, password) |
Validate a local password for an active local account. |
updateLoginState(accountId, patch) |
Update lastLoginAt, updatedAt, and optionally idpSubject. |
normalizeEmail(email) |
Normalize an email exactly like the store does internally. |
Account Authentication
AccountAuthService authenticates only existing active accounts. It supports local passwords, idp.global passwords, or auto mode.
import {
AccountAuthService,
IdpGlobalServerClient,
SmartdataAccountStore,
} from '@idp.global/sdk/server';
const accountStore = new SmartdataAccountStore({ smartdataDb });
const idpClient = new IdpGlobalServerClient();
const authService = new AccountAuthService({
store: accountStore,
idpClient,
});
const authResult = await authService.authenticate({
email: 'admin@example.com',
password: 'correct horse battery staple',
authSource: 'auto',
});
if (!authResult) {
throw new Error('Invalid login.');
}
console.log(authResult.account.role, authResult.authSource);
In auto mode, the service tries local auth first when the account allows local; if that fails and the account allows idp.global, it uses the configured IdpGlobalServerClient. IdpGlobalServerClient defaults to the hosted https://app.idp.global TypedSocket endpoint; pass { baseUrl } only for self-hosted or staging IdP instances. Pass { backendToken } when a backend service should fetch JWT validation data or subscribe to key/blocklist pushes. idp.global authentication is accepted only when the returned user email matches the local account email and the returned user ID matches the stored idpSubject when one is already set.
Backend services register for JWT validation updates by constructing a server client with a backend token. The SDK authenticates every physical connection and reconnect through TypedSocket's bounded restoration callback before the connection becomes available:
const idpClient = new IdpGlobalServerClient({
baseUrl: process.env.IDP_URL,
backendToken: process.env.IDP_BACKEND_TOKEN,
});
idpClient.onPublicKeysPush((publicKeys) => {
// cache every current or retiring key by kid
});
idpClient.onBlocklistPush((blockedJwtIds) => {
// replace the local JWT ID blocklist with this complete persisted snapshot
});
const publicKeys = await idpClient.getPublicKeysForValidation();
const blockedJwtIds = await idpClient.getJwtIdBlocklist();
registerBackendService() remains available for an explicit registration. A
successful explicit token becomes the client's restoration state, so every
later physical reconnect authenticates with that token before the connection
becomes available. Both paths fire the exact registerBackendServiceConnection
request; clients never assign the protected backend-service role tag
themselves.
Verification is keyset-only. Replace the cached snapshot on every push, select
the verification key by the JWT header kid, and reject unknown key IDs.
Live Organization Authority
OidcIntrospectionClient calls the public issuer's /oauth/introspect endpoint
with confidential OAuth Basic authentication. Keep the client secret and access
token on the server. The token must belong to that same OAuth client and include
openid organizations; use the exact subject from the validated OIDC login and
the canonical organization ID.
import { OidcIntrospectionClient } from '@idp.global/sdk/server';
const introspection = new OidcIntrospectionClient({
issuer: 'https://idp.global',
clientId: process.env.IDP_OIDC_CLIENT_ID!,
clientSecret: process.env.IDP_OIDC_CLIENT_SECRET!,
});
const authority = await introspection.introspect({
token: serverSession.accessToken,
subject: serverSession.idpSubject,
organizationId: requestedOrganizationId,
});
if (!authority.active) throw new Error('Organization access denied.');
const currentRoles = authority.idp_global_authority.roles;
Every call generates a fresh nonce and validates the exact issuer, client,
subject, organization, response shape, revisions, and current roles. The default
request deadline is five seconds and the response limit is 16 KiB; redirects
are rejected. OidcIntrospectionError exposes only a sanitized code:
invalid_request, invalid_response, or unavailable. Deny the operation when
introspection fails.
An issuer under a private CA — @idp.global/devidp, which returns its CA as
caCertificatePem, or a self-hosted idp.global on a LAN — is reached through
its own transport instead of the process-wide NODE_EXTRA_CA_CERTS:
import { createTrustedCaFetch, OidcIntrospectionClient } from '@idp.global/sdk/server';
const introspection = new OidcIntrospectionClient({
issuer: 'https://idp.lan.example',
clientId: process.env.IDP_OIDC_CLIENT_ID!,
clientSecret: process.env.IDP_OIDC_CLIENT_SECRET!,
fetch: createTrustedCaFetch(caCertificatePem),
});
createTrustedCaFetch(pem | pem[]) parses every certificate up front and
returns a TOidcFetch over node:https whose own agent trusts those CAs and
nothing else. It requests https: URLs only, never follows a redirect, and
leaves the global fetch, its dispatcher and the default trust store untouched.
An active result observes current authority while the provider holds its
mutation fences. It is not a cached membership grant: membership can change
after that observation. exp bounds token, session, and refresh-family expiry;
it does not promise membership remains valid until then. Keep the application's
existing login and principal binding and make a fresh observation for each
operation requiring live authority. The response contains no identity profile
or email-based organization inference.
AuthorityObserver in @idp.global/sdk/relyingparty follows this contract by
default: positiveTtlMs and negativeTtlMs are 0, so every operation is
observed freshly. An application may opt into reusing a positive observation for
a bounded time (positiveTtlMs) and, separately, a refusal (negativeTtlMs),
per login session and organization. The trade-off is explicit: a membership
removed, a role lowered or an app connection revoked at idp.global takes effect
in that application only once the reused answer expires — up to the configured
TTL — while ending the login session in the application drops its reused answers
at once. A failure to observe is never reused.
idp.global answers an introspection inactive for a login that ended as well as
for a membership that is gone. Only a refresh tells them apart: idp.global
refuses the refresh of an ended login (invalid_grant), and also when the user
can no longer sign in or an organization of the login is no longer active, but
rotates the token of a member who was removed from one organization. So an
inactive answer is not yet a refusal. AuthorityObserver first rotates the
session's provider token (liveProviderToken(sessionId, principalId, { rejectedAccessToken })) and introspects again with the new token:
- A refused rotation is the ended login. It ends the session
(
refresh_token_refused, throughonSessionEnded) and throwsAuthorityTokenErrorauthority_login_required. No membership is refused. - Only an inactive answer on a token from a rotation that started after the
observation's own inactive answer throws
AuthorityRefusedErroridp_membership_absent. The source marks such a tokenrotatedAfterRejection, and the observer orders inactive answers and rotation starts by one sequence; it compares no tokens. - Every other inactive answer throws
authority_unavailableand keeps nothing: when the rotation started before the answer, when another replica holds the session's rotation, when a rotation was already running, and when the session has no refresh token. - An observer rotates a session's token for an inactive answer at most once per
AUTHORITY_FORCED_REFRESH_INTERVAL_MS(60 s). An inactive answer within that time of the rotation isauthority_unavailable, as is every one while a rotation that failed stands. After it the next inactive answer rotates and decides. - A deactivated application is not a refusal either. idp.global answers its
refresh with
invalid_client, which reads asauthority_unavailable. - idp.global counts refused token requests per client: after 10 in 10 minutes without a successful one in between, it blocks the client's token requests for 15 minutes. A rotation only refuses earlier what the session's next refresh would refuse, once per session.
- A token source of an application's own (
IAuthorityTokenSource) must rotate when it is givenrejectedAccessTokenand mark the token. One that never rotates or marks, as a 19.3.0 source does, can never refuse: its inactive answers areauthority_unavailable.
Server-held OIDC UserInfo
Use OidcUserInfoClient to obtain current profile and organization picker data
with an access token held by your application's server session:
import { OidcUserInfoClient } from '@idp.global/sdk/server';
const userInfoClient = new OidcUserInfoClient({ issuer: 'https://idp.global' });
const claims = await userInfoClient.getUserInfo({
token: serverSession.accessToken,
subject: serverSession.idpSubject,
});
const ownerOrganizations = claims.organizations?.filter((organization) =>
organization.roles.includes('owner')) ?? [];
The expected subject must come from the verified OIDC login. Keep the token
server-side; the SDK sends it only in the bearer header to the issuer's
/oauth/userinfo endpoint, rejects redirects, and validates the returned subject
and documented claims. Unknown top-level OIDC extension claims are omitted.
The client retains no access token or claim cache. Calls have a five-second
deadline, including body reads, and a 64 KiB response limit. Options allow a
deadline up to 30 seconds and a response limit up to 1 MiB.
Organization claims are display data within the token's original organization
grant. Newly granted organizations require a new OIDC login; removed memberships
or app connections disappear from subsequent responses. Authorize each protected
operation separately with fresh OidcIntrospectionClient introspection. UserInfo
does not turn a picker selection or an observed role into an authorization grant.
OidcUserInfoError.code is invalid_request, invalid_response,
unauthenticated, or unavailable. Provider 401/403 responses indicate that the
server-held token is no longer accepted. Errors omit token and provider content.
Server API Surface
| API | Description |
|---|---|
SmartdataAccountStore |
NoSQL-backed account persistence and local password verification. |
AccountAuthService |
Existing-account authentication orchestration for local, idp.global, and auto. |
IdpGlobalServerClient |
Typed-socket client for idp.global password login, JWT refresh, whoIs lookup, backend JWT validation fetches, and JWT key/blocklist pushes. |
OidcIntrospectionClient |
Confidential server client for an exact live organization authority observation. |
OidcIntrospectionError |
Sanitized introspection failure with a stable error code. |
createTrustedCaFetch() |
A TOidcFetch whose connections trust the given PEM CA certificates only; https-only, no redirects, global fetch untouched. |
OidcUserInfoClient |
Subject-bound profile and organization display claims using a server-held OIDC token. |
OidcUserInfoError |
Sanitized UserInfo failure with a stable error code. |
IdpMfaRequiredError |
Password-login error carrying the MFA challenge token and available factor methods. |
defaultIdpGlobalBaseUrl |
Default hosted server origin: https://app.idp.global. |
PasswordHasher |
Static hashPassword() and verifyPassword() helpers using scrypt:v1. |
IdpSdkAccountDoc |
@lossless.org/client/nosqldb document class backing persisted SDK accounts. |
setAccountDocSmartdataDb() |
Configure the active SmartdataDb for IdpSdkAccountDoc. Usually handled by SmartdataAccountStore. |
Types
The server export includes these TypeScript types:
| Type | Values or purpose |
|---|---|
IOidcIntrospectionClientOptions |
Exact HTTPS issuer, confidential client credentials, optional deadline/response limits, and an optional fetch. |
TOidcFetch, IOidcFetchInit |
The fetch an OIDC client sends its requests through, and the request options it passes. |
IOidcIntrospectionClientRequest |
Server-held access token, exact subject, and canonical organization ID. |
IOidcUserInfoClientOptions |
Exact HTTPS issuer and optional deadline/response limits. |
IOidcUserInfoClientRequest |
Server-held access token and the subject established by verified OIDC login. |
TIdpAccountAuthSource |
`'local' |
TIdpAccountRole |
`'admin' |
TIdpAccountStatus |
`'active' |
IIdpSdkAccount |
Persisted account shape. |
ICreateIdpSdkAccountOptions |
Input for account creation. |
IAuthenticateAccountOptions |
Input for AccountAuthService.authenticate(). |
IAuthenticatedAccountResult |
Successful auth result with account, auth source, and optional idp.global tokens. |
IIdpGlobalServerClientOptions |
Server client configuration with optional baseUrl and backendToken; defaults to https://app.idp.global. |
IIdpPasswordAuthResult |
Privacy-safe current-user DTO, JWT, and refresh-token result. |
Relying-Party Sessions
@idp.global/sdk/relyingparty is the session layer of a server application that
signs its users in through idp.global. The application supplies its database,
its keys and what a verified subject means to it; the module runs everything in
between.
import * as nosqldb from '@lossless.org/client/nosqldb';
import {
AuthorityObserver,
createRelyingPartyModels,
IdpOidcProvider,
RelyingPartyRoutes,
RelyingPartySessions,
decodeSecretKey,
} from '@idp.global/sdk/relyingparty';
const provider = new IdpOidcProvider({
issuer: new URL('https://idp.global'),
clientId: process.env.IDP_CLIENT_ID!,
clientSecret: process.env.IDP_CLIENT_SECRET!,
redirectUri: new URL('/auth/callback', 'https://app.example'),
});
await provider.init();
const models = createRelyingPartyModels({ db: smartdataDb });
await models.Session.ensureInitialized();
await models.LoginAttempt.ensureInitialized();
let authority: AuthorityObserver<'manage' | 'read'>;
const sessions = new RelyingPartySessions({
models,
provider,
publicOrigin: 'https://app.example',
sessionEncryptionKey: decodeSecretKey(process.env.SESSION_ENCRYPTION_KEY!, 'SESSION_ENCRYPTION_KEY'),
csrfKey: decodeSecretKey(process.env.CSRF_HMAC_KEY!, 'CSRF_HMAC_KEY'),
principals: {
// create or update the local user; throw RelyingPartyRefusal to refuse the login
admit: async (completion) => ({ principalId: await users.upsertFromIdp(completion) }),
isActive: async (principalId) => users.isActive(principalId),
},
hooks: {
onSessionEnded: ({ sessionId, revokedHere }) => {
authority.forgetSession(sessionId);
if (revokedHere) socketGate.closeSession(sessionId);
},
},
});
authority = new AuthorityObserver({
provider,
tokens: sessions,
project: (roles) => (roles.includes('owner') ? 'manage' : roles.includes('viewer') ? 'read' : undefined),
});
const routes = new RelyingPartyRoutes({ sessions, failureLocation: '/?auth=error' });
typedServer.addRoute('/auth/*', 'ALL', routes.handle);
What it does:
- Login:
GET /auth/login?return=/pathstores one login attempt (state and browser binding stored as SHA-256, PKCE verifier sealed) and redirects to the provider.GET /auth/callbackconsumes the attempt exactly once, exchanges the code, reads UserInfo with a subject check, callsprincipals.admitand opens a session; a providererrorabandons the attempt.GET /auth/finalizereleases the return path only to the browser holding the session that attempt created. Return paths stay on the public origin and outside the login routes. Expired attempts are swept when the next login starts. - Starting before discovery:
provider.init()runs discovery. An application that must not depend on idp.global to start calls it in the background until it succeeds. Until then, a login start fails and stores no attempt, and introspection and refresh throwOidcAuthorityUnavailableError, which fails closed. - Intents and prompts:
sessions.startLogin(returnPath, { intent, prompt })is what an application calls from a login entry of its own (an invitation it accepts, an account it links, a client it authorizes).intentis the application's text of up to 4,096 characters, sealed into the login attempt like the verifier; it never travels through the browser or the provider, andprincipals.admit(completion, { intent, loginAttemptId })receives it when exactly this login comes back.prompt(login,none,consent) goes to the authorization endpoint:loginmakes the person authenticate again before something long-lived is made. A refusal fromadmitmay name where in the application the browser goes (new RelyingPartyRefusal(code, status, { location: '/#link/<token>' })); the routes follow it only when it is a plain path on the public origin outside the login routes, exactly as given, and otherwise answer as if it named none. - Sessions: an HttpOnly,
SameSite=Laxcookie whose token is stored as a hash, touched at most once a minute, with a CSRF token derived per request as an HMAC of the cookie token.GET /auth/sessionanswers the session andPOST /auth/logoutneeds the public origin and the CSRF token; its body is cancelled as soon as it passesmaxBodyBytes. Every end of a session is one conditional update; its provider tokens go back to idp.global within a three-second budget afterwards, and so do the tokens of a login that failed after the code exchange.openSession({ principalId, maxAgeSeconds })opens a session the application authenticated itself, optionally shorter thansessionMaxAgeSeconds(the cookie with it), and without provider tokens. - Provider tokens: sealed with AES-256-GCM (
SecretBox) and rotated shortly before they expire, once per process and under a lock stored on the session, so two replicas never spend one refresh token. A refused refresh ends the session; an unreachable provider fails closed. - Live authority:
AuthorityObserver.observe({ loginSessionId, principalId, idpOrganizationId })introspects the organization with the session's live token and projects the role keys through the application's table. It answers a grant or throwsAuthorityRefusedError(withfirstRefusalonce per transition, for an audit trail) orAuthorityTokenError. An inactive answer rotates the token before it refuses, and a login that ended at idp.global ends the session (authority_login_required); see the authority contract above. - Transports:
createActorScope()gives a TypedRouter request scope (addRequestScope) that runs every dispatch as the actor its trusted local data names.RelyingPartySocketBindingregisters a TypedSocket connection from its upgradeOriginandCookieplus the page's CSRF token, pins it to the session, re-validates it per request and finds a session's connections by a server-owned tag. Connection limits and sweeps stay with the application.
Nothing runs on a timer, and no token, cookie or sealed value ever reaches a log line or an error message.
onSessionEnded names the session, the reason and whether this call revoked
it (revokedHere); when it did, it also names the session's principal
(principalId), and when the session was over already it does not.
onProviderSessionOpened receives the session and, for a bearer login, its
delivery: the client kind, the return target and the client's state. A
cookie login has no delivery.
Application hooks never stand in the way of that cleanup. A throwing (or
rejecting) onSessionEnded or onEvent is reported as a hook_failed event —
onEvent's own failure on console.error — and the provider tokens go back
regardless. A throwing onProviderSessionOpened fails the login: the session it
opened is revoked, its tokens are handed back and the hook's error is the
login's cause. Custom cookieNames with a __Host- or __Secure- prefix need
an https publicOrigin; on http the constructor refuses them, because browsers
would silently drop those cookies.
Adopting existing session data. The persisted fields and index declarations
are those nevermind.finance has stored since mainapp 0b693cb, and the session
model adds an expiresAt index, built on first use. An application that
already holds such collections binds the models to them and passes its own
cookie names and CSRF label, so live sessions and sealed tokens keep working:
const models = createRelyingPartyModels({
db: smartdataDb,
collectionNames: { sessions: 'SessionDoc', loginAttempts: 'LoginAttemptDoc' },
});
const sessions = new RelyingPartySessions({
models,
cookieNames: { session: 'nm_session', intent: '__Host-nm_oidc_intent' },
csrfLabel: 'nevermind.finance:csrf:v1\u0000',
// …
});
A deployment without a provider (provider omitted) keeps the same session
store for sessions it opens itself with openSession({ principalId }), for
example after a local password check.
Nothing runs on a timer. A login start first removes up to 100 login attempts
and up to 100 sessions that ended more than an hour ago, and a bearer login
start also its bearer hand-offs and token generations; openBearerHandoff
removes the bearer state and the sessions, openSession the sessions only.
Removing an ended session fires no end hook and calls no provider.
Deleting an account. The sweeps above run only when a session is created,
so an ended session can stay stored for long on a quiet service.
eraseSessionsOfPrincipal(principalId) removes everything of one principal at
once: an application calls it when it deletes the account, after its own
erasers and before its user record, so a failure can be retried.
await accountData.eraseAll(userId); // the application's own records
const erasure = await sessions.eraseSessionsOfPrincipal(userId);
await users.delete(userId);
- It ends every session of the principal that is not revoked yet, an expired
one too, as
endSessiondoes: its tokens stop working at once,onSessionEndedfires (reasonlogout, withprincipalId), and its provider tokens go back to idp.global, which a deleted row could no longer do. - Then it deletes every session row of the principal, live or ended, and with each the login attempt a provider session came out of and, in bearer mode, its hand-offs (claimed or not) and its token generations. The session row goes last: it is the only link from the principal to the others, so an erase that fails half way leaves a retry everything it needs.
- It answers what it removed (
IRelyingPartySessionsErasure:endedSessions,deletedSessions,deletedLoginAttempts,deletedBearerHandoffs,deletedBearerTokens); a second call finds nothing. - A session opened by a login already past
admitwhen the erase runs is not covered. It keeps the principal id until it expires and a later sweep removes it. A seconderaseSessionsOfPrincipal(principalId), run after the user record is deleted, removes it. A token generation issued by a refresh already under way is not covered either; it outlives its session row as described below. - A token generation, and for at most a minute a hand-off, outlives the row of its session when that session was revoked and swept before its expiry. It holds only hashes, the session id and times, the bearer sweep removes it an hour after the session would have expired, and the erase cannot attribute it to the principal any more.
Building before the database connects. db may be a function that answers
the database once it exists. It is called on every use of a model, the way
@Collection(() => db) resolves it, and never while the models,
RelyingPartySessions, RelyingPartyRoutes or RelyingPartySocketBinding are
constructed — none of them does I/O in its constructor. A use before the
function answers a SmartdataDb (or while it throws) rejects with
RelyingPartyDatabaseUnavailableError, whose cause is the function's error:
const models = createRelyingPartyModels({ db: () => appDatabase.db });
const sessions = new RelyingPartySessions({ models, /* … */ });
// later, at startup
await appDatabase.init();
await models.Session.ensureInitialized();
await models.LoginAttempt.ensureInitialized();
Reading the principal once. principals.isActive answers a yes or no, so a
request that needs the user reads it a second time. Principals may give
resolve instead: it answers the principal when it may act right now and
null otherwise, and every live session carries that answer as principal —
on resolveCookieSession, revalidateLiveSession, RelyingPartySocketBinding.revalidate
and the routes' sessionAnswer. The principal type is inferred from resolve;
with isActive it is undefined. Give exactly one of the two; the constructor
refuses both or neither.
const sessions = new RelyingPartySessions({
// …
principals: {
admit: async (completion) => ({ principalId: await users.upsertFromIdp(completion) }),
resolve: async (principalId) => {
const user = await users.get(principalId);
return user?.status === 'active' ? user : null;
},
},
});
const resolved = await sessions.resolveCookieSession(cookieHeader);
resolved?.principal.displayName; // typed as the user
Recording a logout. RelyingPartyRoutes takes
onLogout({ session, endedLiveSession }). It runs only for a POST <prefix>logout that passed the origin and CSRF checks, after the session was
ended, and the answer waits for it, so an audit record is written before the
browser learns the logout is done. endedLiveSession is true only when this
call ended a live session: a logout that lost a race to another end gets
false, and a repeated logout finds no session and does not call it. A hook
that throws or rejects never keeps the session alive and never changes the
answer — the session is already over — and is reported as a hook_failed
route event (hook: 'onLogout') through onRouteEvent. The hook runs without
a deadline of its own; bound a slow audit store inside it.
Issuers under a private CA. IdpOidcProvider takes trustedCaPem — one
PEM string or several, e.g. @idp.global/devidp's caCertificatePem or a LAN
deployment's root. Discovery, token, UserInfo, refresh, revocation and
introspection then go through one node:https agent of this provider that
trusts those CAs and nothing else; the global fetch, its dispatcher and
NODE_EXTRA_CA_CERTS are not involved, and the issuer must still be https:.
An unreadable PEM is refused by the constructor.
const provider = new IdpOidcProvider({
issuer: new URL('https://idp.lan.example'),
clientId: process.env.IDP_CLIENT_ID!,
clientSecret: process.env.IDP_CLIENT_SECRET!,
redirectUri: new URL('/auth/callback', 'https://app.example'),
trustedCaPem: caCertificatePem,
});
Advanced: fetch (a TOidcFetch) replaces the transport of every provider
request instead, for a proxy or a client certificate; the trust decisions are
then the application's. The two options are not combined.
Socket peers. RelyingPartySocketBinding.tagConnection and upgradeOf take
the connection wrapper or the bare SmartServe peer (TRelyingPartySocketPeer),
the two forms TypedSocket.setServerTag accepts. The binding names no
TypedSocket or SmartServe version: its transport, connection and peer types are
only what it reads (setServerTag, findAllTargetConnectionsByTag, a peer's
upgrade headers), so the application's own server TypedSocket fits whichever
major it runs. Name the application's connection type as the second type
parameter and connectionsOfSession answers it:
import type { ISmartServeConnectionWrapper } from '@api.global/typedsocket';
const binding = new RelyingPartySocketBinding<IUser, ISmartServeConnectionWrapper>({
sessions,
transport: () => typedserver.typedsocket,
});
for (const connection of await binding.connectionsOfSession(sessionId)) {
typedserver.removeServerTag(connection, binding.tagOf(sessionId));
}
Bearer Hand-off
Cookies serve the application's own pages. Clients of its own that hold no
cookie of this origin — a desktop app, a native app, a page framed on another
origin — sign in through the same idp.global login and receive the relying
party's own access and refresh tokens instead. The bearer option turns this
on beside the cookie login; no bearer route reads or sets a cookie.
const models = createRelyingPartyModels({ db: smartdataDb });
await models.Session.ensureInitialized();
await models.LoginAttempt.ensureInitialized();
await models.BearerHandoff.ensureInitialized();
await models.BearerToken.ensureInitialized();
const sessions = new RelyingPartySessions({
models,
provider,
// … publicOrigin, keys, principals and hooks as above
bearer: {
returnTargets: {
web: ['/signed-in'],
loopback: ['http://127.0.0.1/callback', 'http://[::1]/callback'],
custom_scheme: ['com.example.app:/oauth/callback'],
popup: ['https://workspace.example'],
},
accessTokenMaxAgeSeconds: 900, // the default
},
});
The flow, with the routes under the default /auth/ prefix:
- The client makes a PKCE verifier and its S256 challenge (RFC 7636) and
opens
GET /auth/bearer/login?client_kind=<kind>&return_target=<target>&code_challenge=<challenge>&code_challenge_method=S256&state=<state>.stateis optional: the client's own value of 1 to 256 characters ofA-Z a-z 0-9 . _ ~ -, handed back with the outcome. A malformed request answers 400invalid_bearer_request, a target that is not configured for the kind 400return_target_not_allowed; neither stores anything. - The relying party runs the idp.global login (authorization code with PKCE,
as for cookies). Its
GET /auth/callbackopens the session and delivers a hand-off code to the return target. The code is no token: it is valid for one claim withinBEARER_HANDOFF_MAX_AGE_SECONDS(60), bound to the challenge, and only its hash is stored.web,loopbackandcustom_schemeget a redirect withcodeandstatein the query.popupgets a page whose one nonce script posts{ type: RELYING_PARTY_BEARER_MESSAGE_TYPE, code, state }towindow.openerwith the registered origin as the target origin — never'*'— and closes the window. A login that fails deliverserrorinstead ofcode:access_deniedwhen the provider came back without a code, the code of aRelyingPartyRefusal(itslocationis not followed), orauthentication_failed. - The client claims with
POST /auth/bearer/claim{ code, codeVerifier }. The answer, as forPOST /auth/bearer/refresh{ refreshToken }, is{ tokenType: 'Bearer', accessToken, accessTokenExpiresAt, expiresIn, refreshToken, refreshTokenExpiresAt, session: { sessionId, principalId, expiresAt } }(expiresInin seconds, counted on the relying party's clock). Authorization: Bearer <accessToken>authenticates the client:GET /auth/bearer/sessionanswers{ session }(nullwithout a live one), and the application's own routes callsessions.resolveBearerSession(authorizationHeader)for the live session.POST /auth/bearer/logoutends the session of that access token, or of the body's{ refreshToken }once the access token has expired, and answers{ endedLiveSession }.
Return targets are an exact allow-list per kind, compared by equality:
web: paths on the public origin outside the login routes.loopback:http://127.0.0.1/<path>orhttp://[::1]/<path>, configured without a port. The client names its ephemeral port, and any port matches (RFC 8252 §7.3);localhostis not accepted.custom_scheme: whole URIs of a private-use scheme with a dot in it (RFC 8252 §7.1).popup: bare origins.
The constructor refuses an entry that is not in its canonical form, carries a
query or a fragment, or contains a *: nothing is a prefix and nothing is a
wildcard.
Tokens and their ends:
- An access token lives
accessTokenMaxAgeSecondsand never past its session; the refresh token lives until the session ends, and no refresh extends it. - A refresh spends its refresh token and answers a new pair; the old access
token stops working at once. A spent refresh token that comes back ends the
whole session (
refresh_token_reused), so a client serializes its refreshes, and a client that lost a refresh answer signs in again. A refused refresh answers 400invalid_bearer_grant. - The first claim consumes the code, whatever its verifier. A wrong verifier
ends the session (
handoff_verifier_mismatch), a claimed code that comes back ends it too (handoff_code_reused, RFC 6749 §4.1.2), and a code that expired unclaimed ends its session when it comes back or when the next bearer login sweeps it (handoff_unclaimed). Each answers 400invalid_bearer_handoff; reuse is also reported as abearer_credential_reusedevent. - A bearer login is a provider login like a cookie session:
onSessionEnded,endSessionsOfPrincipalandAuthorityObserverapply as they are. (A session opened by pairing a device has no provider login of its own; see "Pairing a device" below.) A refresh rotates the provider token when it is due and ends the session when idp.global has ended the login (invalid_bearer_grant); an unreachable provider answers 503authority_unavailableand spends nothing. Until then an access token works for its remaining lifetime. An application that must see an idp.global logout sooner observes authority per request (authority.observe({ loginSessionId: live.session.id, … })), which ends the session as soon as idp.global has ended the login.
The four API routes answer CORS, including the preflight, for the popup origins exactly and never allow credentials; every other origin gets no CORS header.
The client side:
- A web client reads
codeandstatefrom its return path, removes them withhistory.replaceStateand claims at once. Every delivery is sent withReferrer-Policy: no-referrer. - A popup client opens the login with
window.open(withoutnoopener) and accepts only the message of the popup it opened, from the relying party's public origin, with its ownstate. The opener page must keep its opener link to the popup:Cross-Origin-Opener-Policy: same-originsevers it, whilesame-origin-allow-popupskeeps it. - Every client claims a code once and never retries a claim or a refresh. Its refreshes run one at a time across everything that shares the pair, and a rotated pair is stored, all of it at once, before its access token is first used: a pair that is lost after the relying party rotated it cannot be recovered, and its spent refresh token coming back ends the session. Native clients implement this side themselves.
The HTTP answers are exported as types (IRelyingPartyBearerTokenAnswer,
IRelyingPartyBearerSessionAnswer, IRelyingPartyBearerLogoutAnswer,
IRelyingPartyBearerErrorAnswer with TRelyingPartyBearerErrorCode, and the
popup's IRelyingPartyBearerMessage), and so are the requests:
IRelyingPartyBearerLoginQuery (the bearer/login query by its parameter
names), IRelyingPartyBearerClaimRequest ({ code, codeVerifier }),
IRelyingPartyBearerRefreshRequest ({ refreshToken }) and
IRelyingPartyBearerLogoutRequest ({ refreshToken? }). They come from a
module that imports nothing, so @idp.global/sdk/browser and
@idp.global/sdk/browser/bearer share them with the server.
The browser client. RelyingPartyBearerClient from @idp.global/sdk/browser
is that client side for web and popup logins. The application owns where the
token pair lives: it passes a storage adapter, typically over IndexedDB, and the
client keeps nothing of its own across reloads.
import { RelyingPartyBearerClient, type IRelyingPartyBearerTokenStorage } from '@idp.global/sdk/browser';
const storage: IRelyingPartyBearerTokenStorage = {
get: () => tokenStore.get('bearer'), // the stored pair, or null
set: (tokens, { signal }) => tokenStore.put('bearer', tokens, { signal }), // resolves once durable, writes nothing once aborted
clear: ({ signal }) => tokenStore.delete('bearer', { signal }), // resolves once durable, removes nothing once aborted
};
const client = new RelyingPartyBearerClient({ origin: 'https://app.example', storage });
// web: start, and receive on the return target page
const login = await client.createLogin({ clientKind: 'web', returnTarget: '/signed-in' });
sessionStorage.setItem('bearer-login', JSON.stringify(login));
location.assign(login.url);
// … on /signed-in
const result = await client.completeRedirectLogin(JSON.parse(sessionStorage.getItem('bearer-login') ?? 'null'));
// result.tokens is the stored pair; result.previousSession says what became of the one it replaced
// popup: make the login first, open it in the click handler itself
const popupLogin = await client.createLogin({ clientKind: 'popup', returnTarget: 'https://workspace.example' });
button.onclick = () => client.openPopupLogin(popupLogin).then(showWorkspace, showSignInError);
// every call
const accessToken = await client.accessToken(); // null without a session
// after the relying party refused one
await client.refresh(rejectedAccessToken);
await client.logout();
createLoginmakes the verifier, its S256 challenge and the state from 32 random bytes each and answers them with the start URL. The pending login is the application's to keep until it comes back; it holds the verifier.completeRedirectLogin(login)answersnullon a page without a delivery. It removescode,errorandstatefrom the address first, then refuses a state that is not the login's (state_mismatch, nothing claimed) and claims the code once (already_receivedafter that).- A received login (
IRelyingPartyBearerLoginResult) replaces the stored pair. After the claim, under the lock, the client stores the new pair and then ends the session of the pair it replaced throughbearer/logout, with that pair held in memory.previousSessionisno_previous_session,same_session(nothing to end),previous_session_ended, orprevious_session_not_revokedwithpreviousSessionError. In the last case that session lives on at the relying party until it expires. A failed claim leaves the stored pair and its session as they were. createPkce()makes a verifier and its S256 challenge from 32 random bytes, andclaimCode(pkce, code)claims a code bound to that challenge which reached the client another way than its own login, such as a device pairing ("Pairing a device" below). It is received like a login: one claim, never retried, underrequestTimeoutMs, and the pair replaces the stored one under the lock (IRelyingPartyBearerLoginResult). A pair is claimed with once: a second call with it, after a failed claim as well, isalready_receivedand sends nothing. A pair that is not one (a malformed verifier, a challenge that is not its S256) or a code that is not text throws before anything is sent. Neither needs a window.openPopupLogin(login, { timeoutMs?, signal?, windowFeatures? })callswindow.openbefore it returns, so a browser lets it through within the click. It takes only a message from the relying party's exact origin, from this popup, ofRELYING_PARTY_BEARER_MESSAGE_TYPEand with the login's state; it ignores every other. It ends withpopup_blocked,popup_closed,popup_timeout(10 minutes by default) or the signal's reason.accessToken()answers the stored access token while it is fresh, and otherwise rotates it first.refreshSkewMs(one minute by default, at most half the lifetime) is the margin.refresh(rejectedAccessToken?)rotates now. Both answernull, and clear the storage, once the session is over (invalid_bearer_grant).- Every refresh, logout and store of a claimed pair holds one Web Lock
(
lockName; by default per origin), so the tabs of an origin rotate one at a time. Inside the lock the pair is read from the storage again. A pair another tab rotated is used as it is, so concurrent refreshes make one request. A rotated pair is stored before the lock is released and before its access token is answered; whenstorage.setfails, the new pair is never used and the session is lost. requestTimeoutMs(30 seconds by default, more than 0 and at most two minutes; any other value throws) is the deadline of each operation: the claim of a login's code, a refresh with the write of its pair, the store of a claimed pair with the revoke of the one it replaced, and a logout. When it fires, the request is aborted, the signal a storage write got aborts, an answer that still arrives is dropped unstored, the lock is released for the other tabs and the call fails withrequest_failed; a login whose pair is stored by then stands, withprevious_session_not_revoked. A claim or refresh the relying party carried out before the deadline is lost with its answer. A timed-out claim fails the login, and the user signs in again; after a lost refresh, the next refresh ends the session (refresh_token_reused) and the user signs in again. Strict rotation costs that.storage.set(tokens, { signal })andstorage.clear({ signal })either honour the signal, so a write it aborted is not made and the call rejects, or complete synchronously, as an adapter overlocalStoragedoes. The lock is never held past the deadline, so an adapter that ignores the signal can finish a write after another tab took the lock. A latesetoverwrites what that tab stored or cleared, and the session can then end (refresh_token_reused); a lateclearremoves the pair that tab stored. Either way the user signs in again.logout()clears the storage and then ends the session at the relying party with the pair it held. When that request fails, is refused or reaches the deadline, it throwssession_not_revokedwith the request's error as itscause: the device is signed out, the session may live on at the relying party until it expires, and the application can tell the user they are signed out on this device only. A deadline that fires before the storage is cleared throwsrequest_failedand leaves the pair.- Failures are
RelyingPartyBearerClientErrors with acode. A refusal of the relying party isrequest_refusedwith itsstatusandanswerError; an unreachable one isrequest_failed, which leaves the stored pair as it was, except inlogout()(session_not_revoked). - The client needs the Web Locks API and Web Crypto, which a browser offers only in a secure context, and has no fallback for either. Every request is sent without credentials, cache or redirects.
- It runs in a Manifest V3 extension's service worker. That was checked in
Chromium 149: a module service worker imported
@idp.global/sdk/browser/bearerand rancreatePkce,claimCodeagainst the bearer routes and a rotation throughaccessToken(), with the lock held at each write. Other browsers' extension workers were not checked. The extension needs a host permission for the relying party's origin: the routes answer CORS for popup origins only, so without one the browser's preflight is refused and the claim fails withrequest_failedbefore it is sent.
Pairing a device. A person who is signed in can sign a second device of theirs in without a login on it, such as a TV or a browser extension. The device makes a PKCE pair and shows the application its challenge, for example together with a short code that the person confirms in a session where they are signed in. The application's server then opens the device's session from the session that confirmed:
// the device polls; the person has confirmed the pairing in one of their sessions
const { code, expiresAt } = await sessions.openBearerHandoff({
openerSessionId: pairing.confirmedInSessionId, // written by the confirm handler, never taken from the device
codeChallenge: pairing.codeChallenge, // the device's S256 challenge
maxAgeSeconds: 8 * 60 * 60, // optional: 1 to sessionMaxAgeSeconds, which is the default
});
// answer the poll with `code`; the device claims it at POST /auth/bearer/claim with its verifier
// the device, with the browser client (a native client claims at bearer/claim itself)
const pkce = await client.createPkce();
await showPairingRequest(pkce.codeChallenge);
const result = await client.claimCode(pkce, await pollForCode());
- The opener.
openerSessionIdmust name a live provider login: not ended and not expired, with an active principal and a provider login of its own (idpSubject). A session fromopenSessionor fromopenBearerHandoffdoes not qualify, so a paired device cannot pair another one. Any other opener throwsRelyingPartyError('invalid_handoff_opener')(400). A challenge that is not one throwsinvalid_bearer_request, and amaxAgeSecondsoutside 1 tosessionMaxAgeSecondsthrows aRangeError. Nothing is stored for any of these. Withoutbearerthe call throwslogin_method_unavailable; in bearer-only mode it works, with a bearer login as the opener. The check reads this relying party's state of the opener; observe authority with the opener first to ask idp.global itself. - The pairing record. It keeps the id of the session that confirmed, written by the confirm handler and never taken from the device. That opener must still be live when the poll delivers the code. Open one hand-off per confirmed pairing, in the poll that delivers it; then the code is never stored, and only the public challenge sits in the record. Only server code opens a hand-off, and no route does. Guard the confirmation against a code the person did not start themselves (RFC 8628 §5.4).
- The code is a bearer login's code: one claim within
BEARER_HANDOFF_MAX_AGE_SECONDS(60) and never past the session, bound to the challenge, stored only as a hash. A claimed code that comes back, a wrong verifier or a code nobody claims ends the session. - The session acts as the opener's principal and has no
idpSubjectorloginAttemptId. It livesmaxAgeSecondsfrom the opening, the same rule as every session, and no refresh extends it. It refreshes without asking the provider. Its expiry,endSession,endSessionsOfPrincipal, the device'sbearer/logoutand the reuse of its code or refresh token end it; an inactive principal resolves it to nothing. - What does not reach it. It holds no provider token, so an idp.global
logout or a change of the person's memberships never reaches it, and
AuthorityObserver.observerefuses it (authority_login_required) without ending it. So the application:- grants a paired session (
session.idpSubjectabsent) no idp.global organization authority and does not observe it. An application that ends sessions onauthority_login_requiredmust not observe paired sessions at all; - sees idp.global end a login only as
onSessionEndedwith the reasonrefresh_token_refusedfor one of the person's provider sessions. It may answer that by ending the person's paired sessions:endSessionfor thosesessionsOfPrincipallists withoutidpSubject, orendSessionsOfPrincipalto end every session of the person. The hook names the principal of a session it revoked (principalId), so the application needs no table of sessions and principals of its own; - ends paired sessions itself when the person signs out everywhere, and
erases them with every other session when the account goes
(
eraseSessionsOfPrincipal, see "Deleting an account"); - chooses
sessionMaxAgeSeconds, ormaxAgeSecondsper pairing, knowing that a paired device lives that long without a check at idp.global.
- grants a paired session (
Bearer-only mode. An application whose every client is a bearer client turns
the cookie login off with cookieSessions: false. No route then reads or sets a
cookie, and that includes the intent cookie a cookie-mode callback clears. It
takes no csrfKey, and it refuses csrfKey, csrfLabel and cookieNames,
because nothing would use them; bearer and provider are required.
const sessions = new RelyingPartySessions({
cookieSessions: false,
models,
provider,
publicOrigin: 'https://app.example',
sessionEncryptionKey: decodeSecretKey(process.env.SESSION_ENCRYPTION_KEY!, 'SESSION_ENCRYPTION_KEY'),
principals,
bearer: { returnTargets: { web: ['/signed-in'], popup: ['https://workspace.example'] } },
});
/auth/login, /auth/finalize, /auth/session and /auth/logout answer
null, like every path the routes do not serve. startLogin,
completeLogin, abandonLogin, finalizeLogin, openSession,
clearSessionCookie and clearIntentCookie throw login_method_unavailable,
and resolveCookieSession answers null. openBearerHandoff works, with a
bearer login as the opener. A callback that belongs to no bearer
login redirects to failureLocation when the provider sent an error, and
otherwise answers 400 authentication_failed. RelyingPartySocketBinding
registers a browser by its cookie session, so it binds no connection in this
mode; an application with sockets authenticates them itself, for example with
resolveBearerSession.
Listing a principal's sessions. sessionsOfPrincipal(principalId, { limit?, cursor? }) answers one page of the principal's live sessions, newest first,
cookie and bearer alike: { sessions, nextCursor? }. limit is 1 to 1,000
(100 by default). nextCursor is there while more may follow. A session live
throughout the walk appears on exactly one page; one opened during the walk is
newer than every cursor and appears in the next walk. The cursor is opaque; a malformed one
throws invalid_sessions_cursor (400). An inactive principal has no live
session.
let page = await sessions.sessionsOfPrincipal(principalId, { limit: 50 });
while (page.nextCursor) {
page = await sessions.sessionsOfPrincipal(principalId, { limit: 50, cursor: page.nextCursor });
}
Testing an Application
@idp.global/sdk/relyingparty/testing exports RelyingPartyProviderDouble, an
in-process idp.global implementing IOidcProvider for an application's own
tests; it belongs in test code only.
import { RelyingPartyProviderDouble } from '@idp.global/sdk/relyingparty/testing';
const idp = new RelyingPartyProviderDouble({ now: clock.now });
idp.addPersona({
subject: 'sub-owner',
displayName: 'Olga Owner',
email: 'olga@example.com',
organizations: [{ id: 'idp-org-1', name: 'Example GmbH', slug: 'example', roles: ['owner'] }],
});
idp.signInNextAs('sub-owner');
// the application's GET /auth/login answers with the authorization URL …
const callback = idp.callbackUrlFor(authorizationUrl, new URL('/auth/callback', publicOrigin));
// … and the browser comes back to the application's callback with it
idp.setOrganizationRoles('sub-owner', 'idp-org-1', ['viewer']); // what introspection answers from now on
idp.endLoginsOf('sub-owner'); // every token of the persona stops working
Codes are single use and bound to their login's state and nonce; access tokens
introspect for their subject while they live; refresh tokens rotate, and a
superseded one coming back revokes its whole family, as idp.global's reuse
detection does. Every call is recorded (authorizations, completions,
revocations, introspections, refreshes), and each step can be made to
fail (completionFailure, introspectionFailure, refreshFailure,
revocationFailure) or held open (onIntrospect, onRefresh). With
discoveryFailure set, an init before one has succeeded fails with it, and
until an init succeeds the double answers as IdpOidcProvider does before
discovery. Once one has succeeded, discovery stays done, as with
IdpOidcProvider; discoveries counts the calls. The claims of a login are
those of its sign-in; introspection reads the persona as it is now.
For bearer clients, createBearerPkcePair() makes a verifier and its S256
challenge, and readBearerDelivery(response) reads a callback answer the way
its client would: the code, error and state of a redirect's location
(target is the location without its query), or a popup page's posted
message and its target origin.
import { createBearerPkcePair, readBearerDelivery } from '@idp.global/sdk/relyingparty/testing';
const pkce = createBearerPkcePair();
const start = await fetch(`${appOrigin}/auth/bearer/login?client_kind=web&return_target=/signed-in&code_challenge=${pkce.challenge}&code_challenge_method=S256`, { redirect: 'manual' });
const callback = idp.callbackUrlFor(new URL(start.headers.get('location')!), new URL('/auth/callback', appOrigin));
const delivery = await readBearerDelivery(await fetch(callback, { redirect: 'manual' }));
// delivery.code is claimed with pkce.verifier at /auth/bearer/claim
Relying-Party API Surface
| API | Description |
|---|---|
IdpOidcProvider |
Discovery, PKCE authorization URL, code exchange with UserInfo, refresh, revocation and organization introspection for idp.global. Implements IOidcProvider. trustedCaPem or fetch for an issuer under a private CA. |
createRelyingPartyModels() |
Binds the session, login-attempt, bearer hand-off and bearer token models to one SmartdataDb, or to a function asked for it on every use, optionally under existing collection names. |
RelyingPartySessions<TPrincipal> |
Login start (with an application intent and a prompt)/complete/abandon/finalize, openSession (with a shorter lifetime), resolveCookieSession, revalidateSession, revalidateLiveSession, isSessionLive, validateCsrf, logout/endSession, endSessionsOfPrincipal, eraseSessionsOfPrincipal (deleting an account), sessionsOfPrincipal (paged), liveProviderToken (with rejectedAccessToken). Principals give isActive or resolve (IRelyingPartyPrincipals, IRelyingPartyPrincipalResolver<TPrincipal>); with resolve, live sessions carry principal. With bearer: startBearerLogin, bearerDeliveryOf, completeBearerLogin, abandonBearerLogin, openBearerHandoff (pairing a device from a live provider login), claimBearerHandoff, refreshBearerTokens, resolveBearerAccessToken, resolveBearerSession, endBearerSession; cookieSessions: false (IRelyingPartyBearerOnlySessionsOptions) runs bearer hand-off alone, without cookies or a CSRF key. |
AuthorityObserver, AUTHORITY_FORCED_REFRESH_INTERVAL_MS |
Live organization authority with opt-in positive and negative reuse. An inactive answer rotates the session's token, at most once per interval, before it refuses. |
RelyingPartyRoutes |
handle(ctx) for /auth/login, /auth/callback, /auth/finalize, /auth/session and /auth/logout; null for anything else. Takes a typedserver route context. onLogout is awaited after a CSRF-checked logout ended the session. In bearer mode also /auth/bearer/login, /auth/bearer/claim, /auth/bearer/refresh, /auth/bearer/session and /auth/bearer/logout, with CORS for the popup origins only; in bearer-only mode /auth/login, /auth/finalize, /auth/session and /auth/logout answer null. |
createActorScope() |
AsyncLocalStorage actor context with a TypedRouter request scope. |
RelyingPartySocketBinding |
TypedSocket browser registration, per-request re-validation and session tags; takes a connection wrapper or its peer. |
SecretBox, deriveCsrfToken(), decodeSecretKey(), parseCookies(), serializeCookie(), sanitizeReturnPath() |
The primitives underneath. |
pkceChallengeOf(), verifyPkceChallenge(), parseBearerAuthorization(), isBearerReturnTargetAllowed(), RELYING_PARTY_BEARER_MESSAGE_TYPE, BEARER_HANDOFF_MAX_AGE_SECONDS |
The bearer primitives: S256, the Authorization header, the return-target match and the popup message type. |
IRelyingPartyBearerTokenAnswer, IRelyingPartyBearerSessionAnswer, IRelyingPartyBearerLogoutAnswer, IRelyingPartyBearerErrorAnswer, TRelyingPartyBearerErrorCode, IRelyingPartyBearerMessage |
The HTTP answers of the bearer routes and the popup message, shared with @idp.global/sdk/browser. |
IRelyingPartyBearerLoginQuery, IRelyingPartyBearerClaimRequest, IRelyingPartyBearerRefreshRequest, IRelyingPartyBearerLogoutRequest |
The requests of the bearer routes: the bearer/login query and the JSON bodies, shared with @idp.global/sdk/browser. |
IRelyingPartyBearerHandoffOpening |
What openBearerHandoff answers: the paired session, its code and when the code expires. |
IRelyingPartySessionsErasure |
What eraseSessionsOfPrincipal answers: how many sessions it ended, and how many sessions, login attempts, bearer hand-offs and token generations it deleted. |
RelyingPartyError, RelyingPartyRefusal, LoginCallbackError, AuthorityTokenError, AuthorityRefusedError, RelyingPartyDatabaseUnavailableError |
Errors with stable codes; none carries token material. A RelyingPartyRefusal may name the application's location for the browser. |
RelyingPartyProviderDouble (/relyingparty/testing) |
An in-process idp.global for an application's tests: personas, live organization roles, single-use codes, rotating and reuse-detecting refresh tokens, recorded calls and injectable failures. createBearerPkcePair() and readBearerDelivery() for bearer clients. |
Runtime Notes
Use the explicit subpath imports. They keep browser bundles free of Node-only code and keep server processes free of browser storage dependencies.
import { IdpClient } from '@idp.global/sdk/browser';
import { AccountAuthService } from '@idp.global/sdk/server';
The SDK uses typed sockets and typed requests from @api.global/*, shared idp.global request contracts from @idp.global/interfaces, NoSQL persistence from @lossless.org/client/nosqldb, and focused Push Rocks utilities for URL handling, JSON/base64 encoding, observables, JWT decoding, browser storage, and promise coordination.
Testing
pnpm test
The test suite covers the server account store and auth service, browser transfer-token handling, typed administrative request names, the exact backend JWT keyset and blocklist contracts, and the relying-party layer: its primitives, the OIDC provider against a fetch-level issuer double, sessions and two-replica token rotation on an in-memory NoSQL engine with an injected clock, the persisted shape, the authority observer, the HTTP routes, login intents and prompts, the actor scope, the socket binding and the provider double.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license.md file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.