# Auth & Identity

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](./overview.md): "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`](../../docs/adr/ADR-0003-unified-auth-identity-strategy.md) (unified
auth/identity) and
[`ADR-0010`](../../docs/adr/ADR-0010-shared-identity-and-cross-domain-session-model.md)
(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.

```mermaid
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](../../V1/features/sophia-grounding.md) 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](./persistence-data.md) 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.

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

## Related

- [Platform Overview](./overview.md) — the layered model this page fills in.
- [Shared Libraries](./shared-libraries.md) — the `@oshun/*` infrastructure that
  `identity`/`auth`/`auth-primitives` sit inside.
- [Domain Orchestration](./oshun-domain-libraries.md) — the `libs/oshun`
  services (including `auth-client`) that authorize using this foundation.
- [Contracts](./contracts.md) — the Zod source of truth for session, consent,
  and residency payloads the auth stores project over.
- [BFF & Gateway](./bff-gateway.md) — where `authz.ts` authenticates the session
  and enforces domain scope.
- [Persistence & Data](./persistence-data.md) — the residency, tenant, and
  deletion enforcement the `homeZone`/`tenantId` claims drive.
