# @oshun/identity — Cross-domain auth flows

Shared identity primitives used by every Oshun capability domain (Yemaya,
Lilith, Isis, Sophia, Hathor, Bellona, Aphrodite, Aja). Token issuance is owned
by the auth service; every other domain consumes this library to validate
tokens, read canonical claims, and enforce role/permission decisions.

This document is the library-level companion to ADR-0003 (Unified auth identity
strategy) and ADR-0004 (Shared identity and cross-domain session model). Read
those for the **why**; this README is the **how**.

## What this library ships

| Module                  | Purpose                                                                                                                                   |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `types.ts`              | Canonical claim shapes (`AccessTokenClaims`, `RefreshTokenClaims`, `ApiKeyTokenClaims`, `ServiceTokenClaims`) + role/permission enums     |
| `jwt.ts`                | `JwtService` — signs (HS256/RS256/ES256) and validates tokens via Web Crypto. HMAC comparison is constant-time via `crypto.subtle.verify` |
| `middleware.ts`         | `authenticate()` pipeline for HTTP handlers. Extracts bearer/API-key/service tokens, validates, checks role/permission                    |
| `mtls.ts`               | `verifyForwardedClientCert()` for service-to-service mTLS; pins on fingerprint / SPIFFE ID / CN / DNS SAN                                 |
| `v2-account-binding.ts` | V2 account-state contract for 2FA, recovery, region binding, and PSN / Xbox Live / Nintendo / Steam linked credentials                    |

## 1. User auth flow (browser → domain API)

```
┌──────────┐  1. POST /login           ┌──────────┐
│ Browser  │ ───────────────────────▶ │  auth-svc │
│          │                           └────┬──────┘
│          │                                │ 2. issue access + refresh tokens
│          │ ◀──────────────────────────────┘    (JwtService.createTokenPair)
│          │
│          │  3. GET /api/… w/ Bearer <access>
│          │ ─────────────────────────────────────▶ ┌──────────────┐
│          │                                         │ gateway      │
│          │                                         │ (Traefik)    │
│          │                                         └──────┬───────┘
│          │                                                │ 4. forward-auth → auth-svc/verify
│          │                                                │    populates X-User-ID / X-User-Role
│          │                                                ▼
│          │                                         ┌──────────────┐
│          │                                         │ domain API   │
│          │                                         │ (lilith-bff, │
│          │                                         │  sophia-api, │
│          │                                         │  ...)        │
│          │                                         └──────┬───────┘
│          │                                                │ 5. authenticate(req, jwt)
│          │                                                │    → AuthContext { userId, role, permissions }
│          │ ◀──────────────────────────────── 6. response ─┘
└──────────┘
```

Step 5 uses `authenticate()` from `middleware.ts`:

```ts
import { JwtService, authenticate } from '@oshun/identity';

const jwt = new JwtService({
  algorithm: 'RS256',
  issuer: 'oshun-auth',
  audience: ['oshun'],
  publicKey: process.env.AUTH_PUBLIC_KEY_PEM,
});

app.addHook('preHandler', async (req, reply) => {
  const result = await authenticate(
    jwt,
    {
      authorization: req.headers.authorization,
      'x-api-key': req.headers['x-api-key'] as string | undefined,
      'x-service-token': req.headers['x-service-token'] as string | undefined,
    },
    {
      required: true,
      requireVerifiedEmail: route.auth === 'verified-email',
      requireMfa: route.auth === 'mfa',
    }
  );
  if (!result.success) {
    const status =
      result.errorCode === 'INSUFFICIENT_ROLE' ||
      result.errorCode === 'INSUFFICIENT_PERMISSIONS'
        ? 403
        : 401;
    return reply.status(status).send({ error: result.error });
  }
  req.auth = result.context;
});
```

For services that use the Node `http` module directly, the
`@oshun/identity/node-http` subpath ships `enforceHttpAuth()` which wraps the
dance above (header extraction + status mapping + `WWW-Authenticate` header) for
you.

## 2. API-key flow (external integrations, partner APIs)

API keys are JWTs with `type: 'api-key'`, signed by the auth service the same
way access tokens are signed. That means:

- The same `JwtService.validate()` path validates them.
- Revocation uses a shared `jti` deny-list honoured by the sessionValidator
  hook.
- `rate_tier`, `allowed_ips`, and `scopes` claims are enforced at the gateway
  (rate tier) and inside `authenticate()` (scopes → permissions).

Create a key:

```ts
const token = await jwt.createApiKeyToken({
  keyId: 'key_01HA2Z…',
  ownerId: 'u_123',
  name: 'Build bot',
  scopes: ['assets:read', 'builds:write'],
  rateTier: 'enterprise',
  allowedIps: ['10.0.1.0/24'],
  expiresIn: 60 * 60 * 24 * 90, // 90 days
});
```

Consume a key in a domain API — exactly the same entry point as bearer tokens,
differentiated by the `X-API-Key` header:

```ts
const result = await authenticate(
  jwt,
  { 'x-api-key': req.headers['x-api-key'] as string | undefined },
  {
    allowApiKey: true,
    requiredPermissions: ['builds:write'],
  }
);
```

## 3. Service-to-service flow (trust boundary between domains)

Two supported options, picked per-environment:

### 3a. Short-lived service tokens (default)

```ts
// On the calling side (e.g. Bellona → Isis)
const svcToken = await jwt.createServiceToken({
  serviceName: 'bellona-builder',
  instanceId: process.env.HOSTNAME,
  targets: ['isis-orchestrator'],
  expiresIn: 60, // 1-minute TTL; refresh per request
});
await fetch('http://isis-orchestrator/v1/jobs', {
  headers: { 'x-service-token': svcToken },
});

// On the receiving side
import { authenticateService } from '@oshun/identity';

const result = await authenticateService(
  jwt,
  req.headers['x-service-token'] as string,
  {
    expectedService: 'bellona-builder',
    expectedTarget: 'isis-orchestrator',
  }
);
if (!result.valid) {
  return reply.status(401).send({ error: result.error ?? 'unauthorised' });
}
```

Tokens are minted using the same symmetric/asymmetric secret as user tokens but
carry `type: 'service'` so a leaked user access token can't be used as a service
credential (and vice versa).

### 3b. mTLS (high-trust boundaries, partner networks)

When Traefik / Envoy terminates TLS with client-cert verification, it forwards
the cert on `X-Client-Cert`. Use `verifyForwardedClientCert` to pin the allowed
callers:

```ts
import { verifyForwardedClientCert } from '@oshun/identity';

const result = verifyForwardedClientCert(req.headers['x-client-cert'], {
  allowedSpiffeIds: ['spiffe://oshun/ns/bellona/sa/builder'],
  allowedCommonNames: ['bellona-builder.internal'],
  allowedFingerprints256: [process.env.BELLONA_CERT_FP],
});
if (!result.allowed) return reply.status(401).send({ error: result.reason });
```

The policy can pin on any combination of SPIFFE URI, fingerprint, CN, or DNS
SAN. Fingerprint pinning is the strongest and should be preferred in
ambient-mesh deployments where identities rotate.

## 4. Session revocation

Access tokens are short-lived (default 15 min). If you need immediate
revocation, wire a session validator:

```ts
await jwt.validateAccessToken(token, {
  sessionValidator: async (sessionId, sessionVersion) => {
    const current = await redis.get(`session:${sessionId}:version`);
    return Number(current) === sessionVersion;
  },
});
```

Incrementing `session:${sessionId}:version` invalidates every access token that
was issued for that session prior to the bump, without waiting for expiry.

## 5. Token rotation

Refresh tokens form a rotation family. Every successful refresh increments `gen`
and issues a new family member; reuse of an old `gen` is a leak signal. Detect
and handle at the refresh endpoint:

```ts
const result = await jwt.validate<RefreshTokenClaims>(refreshToken);
if (!result.valid) return reply.status(401).send();
const seen = await redis.get(`refresh:${result.claims.fam}:seen`);
if (seen && Number(seen) >= result.claims.gen) {
  // reused an older generation → assume leak, revoke the family
  await redis.del(`refresh:${result.claims.fam}:active`);
  return reply.status(401).send({ error: 'replay detected' });
}
await redis.set(`refresh:${result.claims.fam}:seen`, result.claims.gen);
const pair = await jwt.createTokenPair({
  /* … */ family: result.claims.fam,
  generation: result.claims.gen + 1,
});
return reply.send(pair);
```

## 6. Cross-domain token compatibility

All domains share the same issuer (`iss: 'oshun-auth'`) and a shared audience
set. Domain APIs accept any `aud` that includes their own name. The `tid`
(tenant) claim is forwarded unchanged so multi-tenant requests reach the right
partition without every domain re-implementing tenant resolution.

## Security notes

- **HMAC signatures**: we verify with `crypto.subtle.verify`, which is
  constant-time. Previous `===` comparisons were removed in the 11.5.3 fix.
- **Service tokens**: mint with the shortest TTL that lets a single RPC complete
  (default 60 s). Do not reuse across requests.
- **mTLS**: always combine fingerprint/SPIFFE pinning with CA-level verification
  at the proxy; this library only enforces the identity policy, not the trust
  chain.
- **Secrets**: never import `secret`, `privateKey`, or `publicKey` from
  user-controlled config. Load from the secret store at boot.
