@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.
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.

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 within major 1. 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.

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=/path stores one login attempt (state and browser binding stored as SHA-256, PKCE verifier sealed) and redirects to the provider. GET /auth/callback consumes the attempt exactly once, exchanges the code, reads UserInfo with a subject check, calls principals.admit and opens a session; a provider error abandons the attempt. GET /auth/finalize releases 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 throw OidcAuthorityUnavailableError, 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). intent is 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, and principals.admit(completion, { intent, loginAttemptId }) receives it when exactly this login comes back. prompt (login, none, consent) goes to the authorization endpoint: login makes the person authenticate again before something long-lived is made. A refusal from admit may 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=Lax cookie 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/session answers the session and POST /auth/logout needs the public origin and the CSRF token; its body is cancelled as soon as it passes maxBodyBytes. 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 than sessionMaxAgeSeconds (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 throws AuthorityRefusedError (with firstRefusal once per transition, for an audit trail) or AuthorityTokenError.
  • Transports: createActorScope() gives a TypedRouter request scope (addRequestScope) that runs every dispatch as the actor its trusted local data names. RelyingPartySocketBinding registers a TypedSocket connection from its upgrade Origin and Cookie plus 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.

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. 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.

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));
}

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.

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 and login-attempt 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, liveProviderToken. Principals give isActive or resolve (IRelyingPartyPrincipals, IRelyingPartyPrincipalResolver<TPrincipal>); with resolve, live sessions carry principal.
AuthorityObserver Live organization authority with opt-in positive and negative reuse.
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.
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.
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.

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.

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.

S
Description
No description provided
Readme
3.2 MiB
Languages
TypeScript 100%