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/identityis built and tested but not yet imported by any V1 app surface. ItsJwtService,authenticate()middleware, mTLS verifier, and canonical claim types are the intended token contract (andADR-0003'sOshunJWTPayloadis the spec they encode), but a grep forfrom '@oshun/identity'acrossapps/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/bffimports@oshun/auth-client(the model/contracts) but signs its own HS256 tokens with a hand-rollednode:cryptoHMAC inapps/oshun/bff/src/middleware/authz.ts, backed by an in-memorycustomer-auth-store.ts. This conforms to the same session/claim model but does not yet route throughJwtService/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
nullunless 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:
// 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.
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:
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.
- Customer role tiers —
@oshun/identity'sUserRoleladder (guest → user → creator → pro → team → enterprise → admin → super-admin, 8 tiers) with an inheritingROLE_PERMISSIONSmap (types.ts:352) and hierarchy checks inhasRole. This describes what a customer's plan can do. - Operator roles + scopes —
@oshun/auth-primitives'splatform-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 pureauthorize(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 14SENSITIVE_SCOPES(STEP_UP_REQUIRED) — least privilege by construction (SUPPORTdoes not get PII read by default; leadership grants do not auto-confer day-to-day workspace scopes). - Entitlement tiers —
@oshun/auth-client'sentitlements.ts:free | pro | premium, with a per-domainDEFAULT_OSHUN_ENTITLEMENT_SCHEMAthat names concrete features, minimum tiers, and usage limits/periods (e.g.tara.offline.downloadneedsproand caps at 100/month;veritas.article.readisfreebut 25/day).evaluateDomainAccessandevaluateFeatureAccessreturn structured verdicts (allowedplus a reason ofinsufficient_tier | domain_suspended | feature_not_found | limit_exceeded) and aremainingcount — 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.
Consent, entitlements, and data rights#
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
svsession-version bump andsignOutAllrevoke 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 withsigned_auth_not_configured. - Provider-absent paths fail loud, not fake. No social-OAuth creds, no
SendGrid key, no client cert → the seam refuses (
nullsender,401/403) rather than fabricating a session. - Documentation drift to watch.
ADR-0010citesADR-0009-unified-auth-identity-strategy.md, butADR-0009is actually "Deep-Linking and Cross-Domain Routing"; the unified-auth decision isADR-0003. The@oshun/identityREADME likewise points at "ADR-0004" for the session model when that isADR-0010. The code is the authority here; the ADR numbers in those back-references are stale.
Related#
- Platform Overview — the layered model this page fills in.
- Shared Libraries — the
@oshun/*infrastructure thatidentity/auth/auth-primitivessit inside. - Domain Orchestration — the
libs/oshunservices (includingauth-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.tsauthenticates the session and enforces domain scope. - Persistence & Data — the residency, tenant, and
deletion enforcement the
homeZone/tenantIdclaims drive.