Skip to content

Identity service (the OIDC IdP)

The central Identity & Tenancy service is the single login for every client and the owner of the cross-product authorization graph. It is the only service that issues tokens; the two product APIs merely verify them. Same lineage as the rest of the platform — Express 5 + TypeScript + Mongoose, DDD per module.

Repo: ~/Developer/xifrasoft/identity · Dev port: 8082

What it owns, in one line

Users, workspaces, agencies, the membership/grant graph between them, stateful sessions, and the RS256 keys that sign every token on the platform.

Modules

Each module is split domain / application / infrastructure in the usual way.

Module Owns
users User — a person; profile and account-status lifecycle.
workspaces Workspace + its members. The canonical workspaceId.
agencies Agency, its members, and the AgencyGrants over workspaces.
sessions Stateful Mongo sessions, JWT issuance, email-verification tokens, the hosted login/register/verify pages.
oidc Authorization-code + PKCE: the /oidc/authorize and /oidc/token endpoints, single-use codes, scope filtering, discovery + JWKS.
oauth-clients The OAuth client registry (public/confidential, redirect-URI allowlists, scopes, grant types, hashed secrets).
keys RS256 key material: load the private key, derive the public JWK, compute the kid.
shared Errors, middleware (auth, IP rate-limit, DTO validation), cookies, open-redirect guards, the Resend email transport.

The tenancy graph

This is the heart of the service: people, the two kinds of org they belong to, and the grant that bridges an agency to a customer workspace.

%%{init: {'theme':'base','themeVariables':{'fontFamily':'Inter, system-ui, sans-serif','fontSize':'14px','lineColor':'#6f8180','primaryColor':'#e7efee','primaryTextColor':'#211915','primaryBorderColor':'#5f9b9a'}}}%%
erDiagram
  USER ||--o{ WORKSPACE_MEMBER : "is"
  WORKSPACE ||--o{ WORKSPACE_MEMBER : "has"
  USER ||--o{ AGENCY_MEMBER : "is"
  AGENCY ||--o{ AGENCY_MEMBER : "has"
  AGENCY ||--o{ AGENCY_GRANT : "holds"
  WORKSPACE ||--o{ AGENCY_GRANT : "granted to"

  USER {
    string sub PK "= local _id"
    string email UK
    enum role "admin | user"
    enum accountStatus "created…active…deleted"
  }
  WORKSPACE {
    string workspaceId PK
    string name
  }
  WORKSPACE_MEMBER {
    string userId FK
    enum role "owner | admin | member"
  }
  AGENCY {
    string agencyId PK
    string name
  }
  AGENCY_MEMBER {
    string userId FK
    enum role "accountant | admin"
  }
  AGENCY_GRANT {
    string workspaceId FK
    enum scope "read | manage"
  }

Data model detail

~/identity/src/modules/users/...

Field Type Notes
identifier string Mongo _id; surfaces as the sub claim.
email string Unique.
passwordHash string bcrypt; never leaves the IdP.
name, firstSurname, secondSurname, nif string Spanish-style name + tax id.
role admin \| user Platform role, not a tenancy role.
accountStatus enum created (unverified) · awaiting_password (invited) · active · suspended · deleted.
preferredLocale string From Accept-Language at signup; picks the email template language.

~/identity/src/modules/workspaces/...

Field Type Notes
identifier string Mongo _id = the canonical workspaceId.
name string 1–120 chars.
members[] embedded { userId, name, email, role: owner\|admin\|member, joinedAt }. Name+email are denormalised for cheap reads.

Indexed on members.userId to find a user's workspaces.

~/identity/src/modules/agencies/...

Field Type Notes
identifier string Mongo _id = the canonical agencyId.
name string
members[] embedded { userId, name, email, role: accountant\|admin, joinedAt }.
grants[] embedded { workspaceId, scope: read\|manage, grantedAt }.

Indexed on members.userId and grants.workspaceId.

~/identity/src/modules/sessions/...

Field Type Notes
userId string The session owner.
accessToken { value, expiresAt } Current access JWT.
refreshToken { value, expiresAt } Current refresh JWT.
expiresAt Date = refreshToken.expiresAt; backs a TTL index so Mongo reaps dead sessions.
userAgent, ip string Stored for audit; not used to bind/verify tokens.

Deleting this document is the whole revocation mechanism (see revocation).

The minted ids unify across the platform: User._id becomes the JWT sub, Workspace._id the workspaceId claim, Agency._id the agencyId claim. Product APIs reuse those ids verbatim — see id-unification.

Login: OIDC authorization-code + PKCE

A client never sees a password. It bounces the browser to the IdP, the IdP runs its own hosted login on its own origin, and the client gets back a one-time code it exchanges for tokens. PKCE binds the code to the client that started the flow.

%%{init: {'theme':'base','themeVariables':{'fontFamily':'Inter, system-ui, sans-serif','fontSize':'13.5px','actorBkg':'#dCe9e8','actorBorder':'#4f8f8d','actorTextColor':'#15302f','actorLineColor':'#9fb0af','signalColor':'#5a6b6a','signalTextColor':'#33403f','noteBkgColor':'#eaf0dc','noteBorderColor':'#7c9a47','noteTextColor':'#27300f','sequenceNumberColor':'#ffffff'}}}%%
sequenceDiagram
  autonumber
  participant U as Browser
  participant F as Product front
  participant IdP

  U->>F: open app (no session)
  F-->>U: 302 to /api/auth/login
  Note over F: mints PKCE verifier + state,<br/>stores both in httpOnly cookies
  U->>IdP: GET /oidc/authorize (code_challenge S256, state)
  alt no IdP session cookie
    IdP-->>U: 302 to /login (or /register if screen_hint=signup)
    U->>IdP: POST /login (credentials)
    IdP-->>U: set id-session cookie · 302 back to /authorize
  end
  IdP-->>U: 302 redirect_uri?code&state
  U->>F: GET /api/auth/callback?code&state
  Note over F: verifies state matches the cookie
  F->>IdP: POST /oidc/token (code + code_verifier)
  IdP-->>F: access + refresh (+ id_token)
  F-->>U: set session cookies → dashboard

Then, on every data request, the front attaches the access token and the product API verifies it independently:

%%{init: {'theme':'base','themeVariables':{'fontFamily':'Inter, system-ui, sans-serif','fontSize':'13.5px','actorBkg':'#dCe9e8','actorBorder':'#4f8f8d','actorTextColor':'#15302f','actorLineColor':'#9fb0af','signalColor':'#5a6b6a','signalTextColor':'#33403f','noteBkgColor':'#eaf0dc','noteBorderColor':'#7c9a47','noteTextColor':'#27300f'}}}%%
sequenceDiagram
  participant F as Product front
  participant API as Product API
  participant IdP
  F->>API: GET /data (Authorization: Bearer access)
  API->>IdP: fetch JWKS (cached ~10 min)
  API->>API: verify by kid + iss + exp (no aud)
  API-->>F: 200

Why the IdP hosts its own login pages

/login, /register, and /verify are served at the root of the IdP, the same origin as /oidc/authorize. That makes the id-session cookie first-party to the authorize endpoint. If login lived on a product front's origin, the cookie would be third-party and the browser would drop it — the flow would loop back to /login forever. This is what lets the two products live on different domains with no shared cookie.

Three properties to remember:

  • PKCE S256 is mandatory. Both code_challenge (at authorize) and code_verifier (at token) are required; the verifier is checked in constant time.
  • Codes are single-use and short-lived. A code is deleted the moment it's exchanged (a replay finds nothing) and a 60-second TTL index reaps any code that's issued but never used.
  • Access tokens carry no aud. Resource servers verify signature + iss + expiry only, so one token works at either product API.

Token issuance: claims, lifetimes

All tokens are RS256 JWTs. The private key comes from JWT_PRIVATE_KEY (PKCS8 PEM, raw or base64); in dev an ephemeral keypair is generated at boot, so tokens don't survive a restart. The public key is published as a JWK with a kid (RFC 7638 thumbprint by default).

Token Default TTL Carries Purpose
access ACCESS_TOKEN_EXPIRATION_SECONDS = 900 (15 min) tenancy claims, no aud authorize API calls
refresh REFRESH_TOKEN_EXPIRATION_SECONDS = 2 592 000 (30 days) same claims rotate the access token / switch context
id token = access sub, aud (client), profile/email per scope, nonce OIDC identity for the client

A token is workspace-scoped or agency-scoped, never both:

Mode Claims For
workspace sub, role, accountStatus, workspaceId, workspaceRole a customer/employee in one company
agency sub, role, accountStatus, agencyId, agencyRole an accountant's cross-customer reads

POST /oidc/token supports the authorization_code, refresh_token, and client_credentials grants. Client-credentials tokens are the reserved mechanism for service-to-service calls (the invoice bridge).

Context switching: select workspace / agency

A logged-in user can change which workspace or agency their tokens are scoped to without logging in again. This is the generalised replacement for xifrasoft's old GET /auth/refresh?workspace_id=.

%%{init: {'theme':'base','themeVariables':{'fontFamily':'Inter, system-ui, sans-serif','fontSize':'13.5px','actorBkg':'#dCe9e8','actorBorder':'#4f8f8d','actorTextColor':'#15302f','actorLineColor':'#9fb0af','signalColor':'#5a6b6a','signalTextColor':'#33403f','noteBkgColor':'#eaf0dc','noteBorderColor':'#7c9a47','noteTextColor':'#27300f'}}}%%
sequenceDiagram
  participant F as Front
  participant IdP
  F->>IdP: GET/POST /auth/refresh?workspace_id=W (Bearer refresh)
  Note over IdP: authorize: direct member of W?<br/>OR an agency grant covers W?
  IdP-->>F: new access + refresh, scoped to W
  • Select workspace (?workspace_id=) is authorized if the user is a direct member or an agency grant covers the workspace. This single rule is what lets an accountant drill into a customer's data through the same path the customer uses.
  • Select agency (?agency_id=) is authorized by agency membership.
  • A context switch re-issues both tokens in place (the refresh token rotates), so there's no window where the session is half-revoked. A plain /auth/refresh with no parameter rotates only the access token and keeps the current context.

Email verification

Signup creates a created (unverified) user and emails a single-use link; no session is issued until the email is verified.

%%{init: {'theme':'base','themeVariables':{'fontFamily':'Inter, system-ui, sans-serif','fontSize':'13.5px','lineColor':'#6f8180','primaryColor':'#e7efee','primaryTextColor':'#211915','primaryBorderColor':'#5f9b9a'}}}%%
flowchart LR
  s["POST /register<br/><small>or /auth/signup</small>"] --> u["User: created<br/><small>(unverified)</small>"]
  u --> link["email a 24h<br/>single-use link"]
  link --> v["GET /verify?token="]
  v --> a["User: active<br/><small>+ id-session set</small>"]
  a --> resume["302 → resume<br/>/oidc/authorize"]

  class s,v io
  class u,a state
  class link,resume ext
  classDef io fill:#d9e8e7,stroke:#4f8f8d,color:#15302f;
  classDef state fill:#e9f0d9,stroke:#7c9a47,color:#283010;
  classDef ext fill:#f3ece6,stroke:#b39a82,color:#2b211b;
  • Login on an unverified account returns 403 EMAIL_NOT_VERIFIED (distinct from bad-credentials) so the UI can render a "check your email" state with a resend button.
  • Signup, resend, and login are IP rate-limited (20 / 15 min) and non-enumerating: the response is identical whether the email is new, already registered, or already verified.
  • Only one verification link is valid at a time — issuing a new one deletes prior tokens for that user. Mail goes out through a thin Resend transport; with no RESEND_API_KEY set, the link is logged to stdout for local testing.

Endpoint reference

Routes at the root (/) are same-origin with /oidc/authorize; the rest live under /api/v1.

Discovery & keys

Method Path Auth Purpose
GET /.well-known/openid-configuration public OIDC discovery document.
GET /.well-known/jwks.json public Public signing keys; resource servers verify by kid.

Hosted auth pages (root)

Method Path Auth Purpose
GET/POST /login public, rate-limited Hosted login form; sets id-session, 302s to return_to.
GET/POST /register public, rate-limited Hosted signup; creates created user, emails link.
GET /verify?token= public Consume link → activate → resume /authorize.
POST /resend-verification public, rate-limited Non-enumerating resend.

OIDC

Method Path Auth Purpose
GET /oidc/authorize id-session cookie Start the flow; issue a single-use code.
POST /oidc/token client creds Exchange code/refresh/client-credentials for tokens.

Sessions & users

Method Path Auth Purpose
POST /auth/login public JSON login; 403 EMAIL_NOT_VERIFIED if unverified.
POST /auth/signup public JSON signup (202; emails verification).
GET/POST /auth/refresh Bearer refresh Rotate access, or switch context with ?workspace_id= / ?agency_id=.
DELETE /auth/logout Bearer access Delete the session → instant revocation.
GET /users/me Bearer access Authenticated profile (the userinfo equivalent).

Workspaces & agencies

Method Path Auth Purpose
POST /workspaces Bearer access Create a workspace (caller becomes owner).
GET /workspaces Bearer access List the user's direct memberships.
GET /workspaces/:id Bearer access One workspace (member only).
POST /agencies Bearer access Create an agency (caller becomes admin).
GET /agencies Bearer access List the user's agencies.
POST /agencies/:id/members agency admin Add an accountant/admin.
POST /agencies/:id/grants agency admin Grant { workspaceId, scope } (idempotent).
GET /agency/workspaces agency-scoped token List granted workspaces + scope. Called by the product APIs to scope cross-customer reads.

Configuration

Env vars follow the platform's four-places rule.

Var Example Purpose
ENV development Toggles ephemeral key, cookie Secure, CORS.
PORT 8082 HTTP port.
MONGO_URL mongodb://mongo:27017/identity Database.
API_VERSION v1 Route prefix (/api/v1).
ISSUER_URL http://localhost:8082 OIDC iss; must match what resource servers expect.
OIDC_LOGIN_URL / OIDC_REGISTER_URL …/login …/register Where /authorize bounces unauthenticated browsers.
ACCESS_TOKEN_EXPIRATION_SECONDS 900 Access TTL.
REFRESH_TOKEN_EXPIRATION_SECONDS 2592000 Refresh TTL.
JWT_PRIVATE_KEY PEM / base64 RS256 signing key. Required in production.
JWT_KEY_ID (optional) Override the kid; defaults to the JWK thumbprint.
RESEND_API_KEY / SENDER_EMAIL_ADDRESS Verification email transport.
*_REDIRECT_URIS / *_CLIENT_SECRET First-party client registrations for npm run seed:clients (see running locally).

Gotchas & edge-cases

Stateful sessions & immediate revocation

A session is a Mongo document holding the live tokens; the browser keeps the refresh token in the id-session cookie. DELETE /auth/logout deletes the document, and the next API call that looks the token up fails. There is no grace period and the design explicitly does not go stateless — it's what makes GDPR erasure and "log me out everywhere" instant.

No aud on access tokens

Only the id token is audience-bound. Access tokens deliberately omit aud so a single token is accepted at both product APIs. Restrict who can call an API at the network layer, not via aud.

read vs manage lives on the grant, not the token

An agency-scoped token says which agency, not what it may do. The per-workspace scope sits on the AgencyGrant, and a resource server reads it from GET /agency/workspaces. So the same token can read one customer and manage another.

Account linking needs no migration

When a standalone xifrasoft user later becomes a figestió client, their sub and data are untouched — an AgencyGrant is added linking the agency to the existing workspace. No merge, no duplicate user. See Integration → account linking.

Workspace-select via grant uses the same path

/auth/refresh?workspace_id= accepts membership or a covering agency grant. That's deliberate: the accountant's drill-in token is indistinguishable to the product API from the customer's own token, so there's exactly one code path to secure.

Rate limits are per-IP, on purpose

Login/signup/resend limits key on the client IP, not the email, so an attacker can't probe which emails exist by tripping a per-email limit. The trade-off is that distributed attacks need an upstream WAF.