Shared Platform · Auth & Identity

Auth & Identity

The four packages are real, tested, and importable — not scaffolds.

10sections12 minread1diagrams

On this page

Auth & Identity is the foundation that answers a single question for every Oshun surface: who is this caller, and what may they do here? It is deliberately not nine per-product login systems but one identity spine that the whole stack borrows. Concretely it is four library trees — @oshun/identity (libs/shared/identity), @oshun/auth-primitives (libs/shared/auth-primitives, 13 source modules), @oshun/auth (libs/shared/auth), and the client-side @oshun/auth-client (which lives at libs/oshun/auth, 28 source modules; note the package name is auth-client, not @oshun/auth) — plus the auth surface each gateway mounts. Between them they define the token and claim shapes, the JWT signer/verifier, refresh-token rotation, password hashing, TOTP step-up, the operator role/scope catalogue, tenant isolation, the cross-domain SSO coordinator, the entitlement schema, and the consent/data-rights model. The split mirrors the platform's dependency rule: identity and auth-primitives are domain-agnostic substrate, auth is a framework-agnostic service over them, and auth-client is the customer-facing client that the shells and the BFF consume.

This page is the identity slab of the layered model in the platform overview: "the BFF authenticates, the domain libs authorize, the data layer enforces residency." It traces each of those verbs to real code, distinguishes what is wired live in V1 from what is built-but-latent, and reconciles the prose against the two decisions that govern it — ADR-0003 (unified auth/identity) and ADR-0010 (the cross-domain session model, now superseded by ADR-0072).

What ships, honestly#

The four packages are real, tested, and importable — not scaffolds. @oshun/auth is a 933-line AuthService with register / login / refreshTokens / logout / logoutAll / requestPasswordReset / resetPassword / changePassword / verifyEmail / oauthLogin / verifyToken (libs/shared/auth/src/service.ts), and it is wired into live app surfaces: apps/tara/api mounts it across its auth routes, middleware, service, and repositories, and apps/kalika/bff consumes it too. @oshun/auth-primitives is live in apps/iris/api. @oshun/auth-client is the most heavily consumed of the four, imported by ~91 non-test files across the web/mobile shells and the BFF.

Three honest seams temper the "it all unifies" story:

  • @oshun/identity is built and tested but not yet imported by any V1 app surface. Its JwtService, authenticate() middleware, mTLS verifier, and canonical claim types are the intended token contract (and ADR-0003's OshunJWTPayload is the spec they encode), but a grep for from '@oshun/identity' across apps/ returns nothing today. It is canonical substrate awaiting its consumers, not a live dependency.
  • The Oshun BFF runs a parallel customer-auth path rather than composing the shared AuthService. apps/oshun/bff imports @oshun/auth-client (the model/contracts) but signs its own HS256 tokens with a hand-rolled node:crypto HMAC in apps/oshun/bff/src/middleware/authz.ts, backed by an in-memory customer-auth-store.ts. This conforms to the same session/claim model but does not yet route through JwtService/AuthService. So "unification" (ADR-0003) is partially realized: the model is shared, the runtime is not yet single.
  • OAuth providers, email verification, and mTLS are provider-gated. Social sign-in, the verification-email sender (SendGrid, fail-closed to null unless configured), and forwarded-client-cert verification all stand on real seams that refuse rather than fake when their dependency is absent.

There is no standalone apps/oshun/auth-service (the ADR-0003 plan named one); in V1 the BFF is the customer-auth host. Where this page describes a flow that is specified but not live, it says so.

The four packages and their boundaries#

Package Path Role Live consumers
@oshun/identity libs/shared/identity Canonical token/claim types, JwtService, authenticate() middleware, mTLS, V2 account binding Substrate only (no V1 app imports yet)
@oshun/auth-primitives libs/shared/auth-primitives JWT, sessions, refresh rotation, password, API keys, OAuth client registry + RFC 7009 revoke, TOTP, tenant isolation, operator roles apps/iris/api, re-exported by @oshun/auth
@oshun/auth libs/shared/auth Framework-agnostic AuthService + RBAC middleware (requireRole, requirePermissions) apps/tara/api, apps/kalika/bff
@oshun/auth-client libs/oshun/auth Customer session model, AuthClient, OshunSsoCoordinator, entitlements, consent, data-rights ~91 files: web/mobile shells, apps/oshun/bff

@oshun/identity is the server-side identity vocabulary. Its types.ts declares the five token types (access, refresh, api-key, service, invite) and the claim shapes for each; jwt.ts is the signer; and middleware.ts is the verifier-and-authorizer. @oshun/auth-primitives is the toolbox the auth service is assembled from — and it re-exports cleanly, so @oshun/auth's barrel (libs/shared/auth/src/index.ts) re-publishes JwtService, PasswordHasher, SessionManager, TokenRefreshManager, ApiKeyManager, and the rest under one import. @oshun/auth adds the orchestration (lockout, audit hooks, event emitters, OAuth login) and the Express-style middleware factories. @oshun/auth-client is the only one that runs in a browser or React Native runtime; it never holds a signing key.

The token and claim model#

JwtService (libs/shared/identity/src/jwt.ts) signs and verifies with the Web Crypto crypto.subtle API across HS256, RS256, and ES256. Two security properties are worth surfacing because they are easy to get wrong: HMAC verification goes through crypto.subtle.verify (constant-time — the README notes prior === comparisons were removed), and verify() rejects any token whose three segments are not canonical base64url before it trusts the payload (isCanonicalBase64Url, jwt.ts:548). The access-token claim set is the live realization of ADR-0003's OshunJWTPayload:

ts
// libs/shared/identity/src/types.ts:64
interface AccessTokenClaims extends OshunTokenClaims {
  type: 'access';
  email: string;
  email_verified: boolean;
  role: UserRole; // guest → super-admin (8 tiers)
  permissions: string[];
  sid: string; // session id
  tid?: string; // tenant id
  mfa_verified?: boolean;
  sv: number; // session version, for revocation-by-bump
}

Defaults (DEFAULT_AUTH_CONFIG, types.ts:394) are a 15-minute access token and a 7-day refresh token with rotation enabled and a 5-minute pre-expiry rotation threshold — squarely inside ADR-0010's normative "10–15 minute access token, rotating refresh family." Refresh tokens carry a rotation family: RefreshTokenClaims add fam (family id) and gen (generation). TokenRefreshManager.refresh (libs/shared/auth-primitives/src/token-refresh.ts:79) implements the reuse detector the ADR requires — on presentation of a revoked token whose family is known, it calls store.revokeFamily(family, 'Reuse detected'), nuking every sibling, because replay of a rotated-out generation is the canonical token-theft signal. The BFF's own store mirrors this with a 30-second grace window (DEFAULT_REFRESH_ROTATION_GRACE_MS) so that legitimately concurrent refreshes — multiple tabs, a retried request — are not misread as an attack.

The sv (session version) claim is the immediate-revocation lever: a sessionValidator passed to validateAccessToken (jwt.ts:283) compares the token's sv against the live session version, so bumping the stored version invalidates every previously issued access token for that session without waiting for the 15-minute expiry.

Beyond bearer tokens, @oshun/identity mints API-key tokens (JWTs with type: 'api-key', carrying scopes, allowed_ips, rate_tier) and service tokens (type: 'service', carrying a targets allow-list) so the same authenticate() entry point covers browser, partner-integration, and service-to-service callers; the type discriminator means a leaked user token can never be replayed as a service credential. For high-trust boundaries, mtls.ts pins forwarded client certs on any combination of SPIFFE URI, SHA-256 fingerprint, CN, or DNS SAN (verifyForwardedClientCert).

How identity threads through the stack#

The overview's one-line summary becomes three concrete code seams.

sequenceDiagram participant S as Surface (shell/web/mobile) participant B as BFF (authz.ts) participant D as Domain lib (oshun/* ) participant R as Data layer (data-residency) S->>B: request + Bearer access token B->>B: createAuthPreHandler() → OshunAuthContext{userId,scopes,homeZone,tenantId} B->>B: createDomainAuthorizationPreHandler() → scope domain:* or domain:{id}, else 403 B->>D: authenticated call (auth context attached) D->>D: authorize() / evaluateDomainAccess() (operator scope · entitlement tier) D->>R: read/write under residency + tenant + consent rules R-->>S: result (or fail-closed denial)

The BFF authenticates the session. createAuthPreHandler (apps/oshun/bff/src/middleware/authz.ts) requires a Bearer token, resolves it, and attaches an OshunAuthContext to the request:

ts
interface OshunAuthContext {
  userId: string;
  scopes: string[];
  exp: number | null;
  sessionId?: string;
  homeZone?: OshunResidencyZone; // V1-PRIV-018 — threads residency
  tenantId?: string;
}

Tokens come in three flavours the resolver distinguishes: signed HS256 JWTs (issued only when OSHUN_BFF_JWT_SECRET ≥ 32 chars is configured), dev.-prefixed unsigned tokens, and tenant.-prefixed tenant-admin tokens — the latter two env-gated off unless dev tokens are explicitly allowed, so a development convenience cannot leak into production. A missing or malformed token is a fail-closed 401 (missing_bearer_token / invalid_token / expired_token).

The domain layer authorizes. Once authenticated, createDomainAuthorizationPreHandler checks the principal carries domain:* or domain:{domainId} and otherwise returns 403 { reason: 'domain_scope_missing' } — the same fail-closed posture the Sophia routes use. Finer decisions are pure functions in the libraries: the operator authorize() in platform-roles.ts and the entitlement evaluators in auth-client's entitlements.ts (below).

The data layer enforces residency and consent. The homeZone claim is not decoration — libs/shared/data-residency consumes it (resolveHomeZoneFromClaim, ResidencyEnforcementService) to keep a principal's data inside its home zone, and TenantIsolationGuard (below) ensures a token for tenant A cannot touch tenant B's rows even when RBAC would otherwise allow the operation. Consent gates ride the same authContext.userId (apps/oshun/bff/src/routes/consent.ts). The Persistence & Data page details the residency and deletion machinery this layer owns.

The cross-domain session model (ADR-0010)#

The customer-facing realization of ADR-0010 is @oshun/auth-client. OSHUN_CUSTOMER_AUTH_MODEL (customer-auth-model.ts) declares one authority (oshun-customer-auth) spanning the six customer domains — OSHUN_DOMAIN_IDS = ['tara','veritas','nyx','arete','nisaba','metis'] (the code has grown past the ADR's original "four domains: Tara, Veritas, Nyx, Arete") — with a 10–15 minute access window, a rotating-family refresh policy, and a per-device session registry. The storage rules are encoded per surface, exactly matching ADR-0010's normative table:

Surface Access token Refresh token API boundary
web memory-only HttpOnly cookie same-origin BFF session
pwa memory-only HttpOnly cookie same-origin BFF session
mobile secure-device-storage secure-device-storage bearer access token

The runtime that makes "log in once, use every domain" real is OshunSsoCoordinator (sso-coordinator.ts). It wraps an AuthClient, holds a single OshunSsoState (unauthenticated | authenticated | refreshing), and fans state changes across tabs/surfaces through a pluggable SsoSyncAdapter — either an in-process channel or a BroadcastChannel adapter. When one surface signs out or has its session revoked, the coordinator publishes a session-cleared event and peers clear locally; a session-updated event propagates a fresh session. getDomainSession(domainId) is the per-domain launch primitive: it ensures a fresh session and hands back a Bearer <accessToken> authorization header scoped for that domain — the "domain launch requests carry authenticated shell context" clause of the ADR.

The AuthClient itself (auth-client.ts) is careful where it matters: refresh() is single-flight (a refreshInFlight promise dedupes concurrent refreshes), ensureFreshSession proactively refreshes inside a configurable window, and a refresh that fails with a revoked-session signal clears the store and throws SESSION_REVOKED rather than looping. Device management (listSessions, revokeSession, revokeAllSessions, signOutAll) implements the ADR's "view active sessions / revoke one / logout all" requirement; the session error taxonomy (unauthorized, session_revoked, refresh_failed, step-up) is the standardized one the ADR mandates.

Authorization: three orthogonal models#

A subtlety worth stating plainly, because conflating these is a real bug class: Oshun runs three authorization vocabularies, on purpose.

  1. Customer role tiers@oshun/identity's UserRole ladder (guest → user → creator → pro → team → enterprise → admin → super-admin, 8 tiers) with an inheriting ROLE_PERMISSIONS map (types.ts:352) and hierarchy checks in hasRole. This describes what a customer's plan can do.
  2. Operator roles + scopes@oshun/auth-primitives's platform-roles.ts: 8 operator roles (platform.customer … platform.admin_leadership) and a closed-world catalogue of 32 <workspace>:<resource>:<action> scopes across seven admin workspaces (support, review, moderation, privacy, model, persona, leadership). Its header is explicit that this is orthogonal to the customer ladder — "those describe customers, this describes operators; both can be present on a principal at once." Authority here is a partial order, not a ladder: a privacy operator is not above or below a model operator. The pure authorize(req) function unions explicit scopes with role defaults, rejects unknown scopes (UNKNOWN_SCOPE), enforces workspace match, and requires a recent MFA assertion for any of the 14 SENSITIVE_SCOPES (STEP_UP_REQUIRED) — least privilege by construction (SUPPORT does not get PII read by default; leadership grants do not auto-confer day-to-day workspace scopes).
  3. Entitlement tiers@oshun/auth-client's entitlements.ts: free | pro | premium, with a per-domain DEFAULT_OSHUN_ENTITLEMENT_SCHEMA that names concrete features, minimum tiers, and usage limits/periods (e.g. tara.offline.download needs pro and caps at 100/month; veritas.article.read is free but 25/day). evaluateDomainAccess and evaluateFeatureAccess return structured verdicts (allowed plus a reason of insufficient_tier | domain_suspended | feature_not_found | limit_exceeded) and a remaining count — this is billing/plan gating, not security.

The step-up primitive these share is TOTP: auth-primitives/totp.ts is a real RFC 6238 / RFC 4226 implementation (hotp/totp, provisioning URIs, hashed backup codes), and isStepUpFresh enforces a TTL on a step-up assertion so a sensitive scope demands recent MFA, not merely enrolled MFA.

Tenant isolation#

TenantIsolationGuard (auth-primitives/tenant-isolation.ts) is the object-level backstop. It reconciles up to four tenant signals on a request — the token's tenantId claim, an X-Tenant-Id header, a route/path tenant, and a resource owner tenant — and denies on any mismatch with a structured code (TENANT_MISMATCH_HEADER | _ROUTE | _RESOURCE, NO_TENANT_CLAIM, INVALID_TENANT_ID). Resource scope wins the "effective tenant" resolution because the strictest object-level check is the one downstream code must apply. A configurable superTenantIds set may cross tenants (admin acting on a partner tenant), but every such crossing fires an audited onImpersonation event — the guard never silently allows a super-tenant to act elsewhere. This is the mechanism that makes the BFF's tenantId auth-context field load-bearing rather than advisory.

ADR-0003's GDPR/SOC2 commitments live mostly in @oshun/auth-client's compliance stores, which are client projections over BFF-canonical state. The consent surface tracks 45 named consent flows (customer-consent-store.ts), from memory_sensitive_health and voice_own_clone through avatar_likeness_capture, synthetic_tenant_publication, education_standards_reporting, and cross_region_transfer — each with a status lifecycle (pending | granted | denied | withdrawn | revoked | expired | superseded) and a canonical audit timeline fetched per flow. Alongside it, OshunDataRightsStore, customer-data-export-store, and customer-data-deletion-store model the data-portability and right-to-erasure UX that ADR-0003 requires across all linked domains. The store headers are candid that the BFF owns the source of truth (@oshun/contracts' CustomerConsentSnapshot) and these stores are the observable client cache the settings UI subscribes to — network access is injected so they unit-test cleanly.

The V2 (game-platform) variant of the same idea is identity/v2-account-binding.ts: a V2OshunIdentityAccountState binds a single canonical Oshun account to PSN / Xbox Live / Nintendo / Steam credentials, with a 90-day region-change cooldown and a 30-day platform-rebind cooldown, and gates sensitive actions (account-merge, region-change, dsr-request) behind verified two-factor and a ready recovery method.

Edge cases and failure modes#

  • Refresh reuse vs. legitimate concurrency. A replayed rotated-out generation triggers family-wide revocation; the BFF's 30-second grace window keeps honest multi-tab refreshes from tripping it. The two together are the difference between "secure" and "logs everyone out constantly."
  • Revocation is two-tier. Short access-token TTL bounds exposure passively; the sv session-version bump and signOutAll revoke actively and immediately.
  • Dev/tenant tokens are environment-gated. Unsigned dev./tenant. tokens resolve only where explicitly allowed; production demands a ≥32-char signing secret or rejects with signed_auth_not_configured.
  • Provider-absent paths fail loud, not fake. No social-OAuth creds, no SendGrid key, no client cert → the seam refuses (null sender, 401/403) rather than fabricating a session.
  • Documentation drift to watch. ADR-0010 cites ADR-0009-unified-auth-identity-strategy.md, but ADR-0009 is actually "Deep-Linking and Cross-Domain Routing"; the unified-auth decision is ADR-0003. The @oshun/identity README likewise points at "ADR-0004" for the session model when that is ADR-0010. The code is the authority here; the ADR numbers in those back-references are stale.
  • Platform Overview — the layered model this page fills in.
  • Shared Libraries — the @oshun/* infrastructure that identity/auth/auth-primitives sit inside.
  • Domain Orchestration — the libs/oshun services (including auth-client) that authorize using this foundation.
  • Contracts — the Zod source of truth for session, consent, and residency payloads the auth stores project over.
  • BFF & Gateway — where authz.ts authenticates the session and enforces domain scope.
  • Persistence & Data — the residency, tenant, and deletion enforcement the homeZone/tenantId claims drive.