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
S256is mandatory. Bothcode_challenge(at authorize) andcode_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/refreshwith 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_KEYset, 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.