From 38cb34777afc366dc8c9818816ac2870228d2640 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 18:09:02 +0000 Subject: [PATCH] feat: choose and switch organisations for one person (1.5.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ORG_REQUIRED now carries details.organizations: the organisations whose account matched the email and password (named via the new optional OrgStore.get), so clients can show a pick list. - organizationsOf(userId): every active account with the same email. - switchOrganization({ token, orgId, … }): moves a valid session to the same person's account in another organisation without the password. The old session ends, device binding applies as on refresh, and the switch is audited in both organisations. New error code ORG_UNAVAILABLE. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jBohk4otGVRjt3Y1cF1N2 --- AGENTS.md | 5 +- README.md | 15 ++++- llms-full.txt | 176 ++++++++++++++++++++++++++++++++++++++++++------ package.json | 2 +- scripts/test.ts | 38 ++++++++++- src/auth.ts | 90 ++++++++++++++++++++++--- src/errors.ts | 13 +++- src/index.ts | 4 +- src/stores.ts | 9 +++ 9 files changed, 308 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4a6edbc..d37c560 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,9 @@ src/rbac.ts PERMISSIONS, SYSTEM_ROLES, can, branchesFor, permissionsOf, rol src/stores.ts storage interfaces (UserStore, OrgStore, RoleStore, AuditSink, SessionStore, KeyValueStore, RateLimiter) src/memory.ts in-process stores (tests, single server) src/redis.ts Redis stores ("/redis" entry): sessions, single-use values, sliding-window limiter -src/auth.ts createAuth(): login, refresh (JWT mode), authenticate, context, require, logout, sessions, password change/reset -scripts/test.ts 131 checks; session, JWT, device-binding and TLS-fingerprint flows run on both memory and Redis (ioredis-mock) stores +src/auth.ts createAuth(): login, refresh (JWT mode), authenticate, context, require, logout, sessions, password change/reset, + organizationsOf / switchOrganization (one person, several organisations) +scripts/test.ts 155 checks; session, JWT, device-binding and TLS-fingerprint flows run on both memory and Redis (ioredis-mock) stores ``` ## Rules diff --git a/README.md b/README.md index 8e3d2ec..5cbc4bc 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ There is no HTTP server here. Your backend (GraphQL resolvers, REST handlers, jo | | | | --- | --- | | **Passwords** | scrypt (N=2¹⁵), salted, self-describing hashes; weaker hashes upgraded on the next login; NIST-style policy (length, common passwords, repeated characters, no email) | -| **Login** | rate-limited per IP (failed attempts only: an office of staff on one connection can all sign in) and per email; timing-safe (an unknown email costs the same as a wrong password); same email in several organisations → `ORG_REQUIRED`; audited | +| **Login** | rate-limited per IP (failed attempts only: an office of staff on one connection can all sign in) and per email; timing-safe (an unknown email costs the same as a wrong password); same email in several organisations → `ORG_REQUIRED` with the organisations whose account matched the password (`details.organizations`); audited | | **Sessions** | opaque tokens, stored only as SHA-256; idle timeout that slides with use plus an absolute maximum age; list devices, revoke one, log out everywhere | | **JWT mode** | short-lived HS256 access tokens plus single-use refresh tokens, rotated on every refresh; a refresh token used twice ends its session (theft); secret rotation; logout and revocation still apply at once | | **Accounts** | disabling a user or changing roles applies on the very next request | @@ -122,6 +122,8 @@ networks change it. A refresh without one keeps the stored hash, and `auth.devic | Call | Returns / throws | | --- | --- | | `login({ email, password, orgCode?, ip?, userAgent?, device?, tlsFingerprint? })` | `{ token, expiresAt, user }`, plus `refreshToken, refreshExpiresAt` in JWT mode (`token` is then the access JWT) · `INVALID_CREDENTIALS` `ACCOUNT_DISABLED` `INVITE_PENDING` `ORG_REQUIRED` `RATE_LIMITED` | +| `organizationsOf(userId)` | `OrgChoice[]` (`{ orgId, code, name }`): every active account with the same email, the current one included. Needs `orgs.get` | +| `switchOrganization({ token, orgId, ip?, userAgent?, device?, tlsFingerprint? })` | what `login` returns, for the same person's account in `orgId`; the old session ends; same device rule as `refresh`; audited in both organisations (`auth.org_switch_out`, `auth.org_switch`) · `UNAUTHENTICATED` `ORG_UNAVAILABLE` `INVALID_TOKEN` | | `refresh({ refreshToken, ip?, userAgent?, device?, tlsFingerprint? })` | JWT mode: `{ accessToken, accessExpiresAt, refreshToken, refreshExpiresAt, user }` · `INVALID_TOKEN` (also: another device) | | `verifyAccessToken(token)` | JWT mode: claims `{ sub, org, sid, iat, exp }` or null, no storage read | | `authenticate(token)` | `Viewer \| null` (slides the idle window) | @@ -144,7 +146,16 @@ Also exported: - `can`, `branchesFor`, `permissionsOf`, `roleMap`. Every failure is an `AuthError` with a stable `code`, an HTTP-style `statusCode`, and `details.retryAfterMs` -where it applies. +where it applies. `ORG_REQUIRED` carries `details.organizations` (`OrgChoice[]`, only the accounts the password +matched, and only when `orgs.get` is passed) so a client can show a pick list and log in again with `orgCode`. + +### Several organisations, one person + +The same email can have an account in several organisations (one user record each). Sign-in with that email +and password answers `ORG_REQUIRED` with the organisations to choose from; the client logs in again with the +chosen `orgCode`. Once signed in, `organizationsOf(userId)` lists them and `switchOrganization` moves the +session to another one without asking for the password again: the current session must be valid and +on the same device, it ends, and a new one starts for the other account. Pass `orgs: db.repos.orgs` (it has `get`). ## Built-in roles diff --git a/llms-full.txt b/llms-full.txt index 545b918..553622b 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -37,7 +37,7 @@ There is no HTTP server here. Your backend (GraphQL resolvers, REST handlers, jo | | | | --- | --- | | **Passwords** | scrypt (N=2¹⁵), salted, self-describing hashes; weaker hashes upgraded on the next login; NIST-style policy (length, common passwords, repeated characters, no email) | -| **Login** | rate-limited per IP (failed attempts only: an office of staff on one connection can all sign in) and per email; timing-safe (an unknown email costs the same as a wrong password); same email in several organisations → `ORG_REQUIRED`; audited | +| **Login** | rate-limited per IP (failed attempts only: an office of staff on one connection can all sign in) and per email; timing-safe (an unknown email costs the same as a wrong password); same email in several organisations → `ORG_REQUIRED` with the organisations whose account matched the password (`details.organizations`); audited | | **Sessions** | opaque tokens, stored only as SHA-256; idle timeout that slides with use plus an absolute maximum age; list devices, revoke one, log out everywhere | | **JWT mode** | short-lived HS256 access tokens plus single-use refresh tokens, rotated on every refresh; a refresh token used twice ends its session (theft); secret rotation; logout and revocation still apply at once | | **Accounts** | disabling a user or changing roles applies on the very next request | @@ -149,6 +149,8 @@ networks change it. A refresh without one keeps the stored hash, and `auth.devic | Call | Returns / throws | | --- | --- | | `login({ email, password, orgCode?, ip?, userAgent?, device?, tlsFingerprint? })` | `{ token, expiresAt, user }`, plus `refreshToken, refreshExpiresAt` in JWT mode (`token` is then the access JWT) · `INVALID_CREDENTIALS` `ACCOUNT_DISABLED` `INVITE_PENDING` `ORG_REQUIRED` `RATE_LIMITED` | +| `organizationsOf(userId)` | `OrgChoice[]` (`{ orgId, code, name }`): every active account with the same email, the current one included. Needs `orgs.get` | +| `switchOrganization({ token, orgId, ip?, userAgent?, device?, tlsFingerprint? })` | what `login` returns, for the same person's account in `orgId`; the old session ends; same device rule as `refresh`; audited in both organisations (`auth.org_switch_out`, `auth.org_switch`) · `UNAUTHENTICATED` `ORG_UNAVAILABLE` `INVALID_TOKEN` | | `refresh({ refreshToken, ip?, userAgent?, device?, tlsFingerprint? })` | JWT mode: `{ accessToken, accessExpiresAt, refreshToken, refreshExpiresAt, user }` · `INVALID_TOKEN` (also: another device) | | `verifyAccessToken(token)` | JWT mode: claims `{ sub, org, sid, iat, exp }` or null, no storage read | | `authenticate(token)` | `Viewer \| null` (slides the idle window) | @@ -171,7 +173,16 @@ Also exported: - `can`, `branchesFor`, `permissionsOf`, `roleMap`. Every failure is an `AuthError` with a stable `code`, an HTTP-style `statusCode`, and `details.retryAfterMs` -where it applies. +where it applies. `ORG_REQUIRED` carries `details.organizations` (`OrgChoice[]`, only the accounts the password +matched, and only when `orgs.get` is passed) so a client can show a pick list and log in again with `orgCode`. + +### Several organisations, one person + +The same email can have an account in several organisations (one user record each). Sign-in with that email +and password answers `ORG_REQUIRED` with the organisations to choose from; the client logs in again with the +chosen `orgCode`. Once signed in, `organizationsOf(userId)` lists them and `switchOrganization` moves the +session to another one without asking for the password again: the current session must be valid and +on the same device, it ends, and a new one starts for the other account. Pass `orgs: db.repos.orgs` (it has `get`). ## Built-in roles @@ -217,8 +228,9 @@ src/rbac.ts PERMISSIONS, SYSTEM_ROLES, can, branchesFor, permissionsOf, rol src/stores.ts storage interfaces (UserStore, OrgStore, RoleStore, AuditSink, SessionStore, KeyValueStore, RateLimiter) src/memory.ts in-process stores (tests, single server) src/redis.ts Redis stores ("/redis" entry): sessions, single-use values, sliding-window limiter -src/auth.ts createAuth(): login, refresh (JWT mode), authenticate, context, require, logout, sessions, password change/reset -scripts/test.ts 131 checks; session, JWT, device-binding and TLS-fingerprint flows run on both memory and Redis (ioredis-mock) stores +src/auth.ts createAuth(): login, refresh (JWT mode), authenticate, context, require, logout, sessions, password change/reset, + organizationsOf / switchOrganization (one person, several organisations) +scripts/test.ts 155 checks; session, JWT, device-binding and TLS-fingerprint flows run on both memory and Redis (ioredis-mock) stores ``` ## Rules @@ -288,7 +300,7 @@ Key facts for agents: ```json { "name": "@mgpl-diagnostics/auth-kit", - "version": "1.4.0", + "version": "1.5.0", "description": "MGPL Diagnostics authentication for the backend: scrypt passwords, revocable sessions (Redis or memory), rate-limited timing-safe login, password reset, roles and branch-aware permissions. An internal API, no HTTP server.", "type": "module", "license": "SEE LICENSE IN LICENSE", @@ -435,7 +447,7 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); function fakeDb() { const users = new Map(); - const orgs = [{ id: "org-a", code: "MGPL" }, { id: "org-b", code: "OTHER" }]; + const orgs = [{ id: "org-a", code: "MGPL", name: "Maa Gayatri Path Lab" }, { id: "org-b", code: "OTHER", name: "Arang Collection Centre" }, { id: "org-c", code: "THIRD", name: "Third Lab" }]; const orgRoles: Record = { "org-a": [{ key: "lab_auditor", permissions: ["audit:read", "accounts:read"] }] }; const auditLog: { action: string; userId?: string | null; data?: Record }[] = []; const userStore: UserStore = { @@ -449,7 +461,7 @@ function fakeDb() { const add = async (u: Omit & { password: string; hashParams?: { N: number; r: number; p: number } }) => { users.set(u.id, { ...u, passwordHash: await hashPassword(u.password, u.hashParams) }); }; - return { users, userStore, orgStore: { findOne: async ({ code }: { code: string }) => orgs.find((o) => o.code === code) ?? null }, roleStore, audit, auditLog, orgRoles, add }; + return { users, userStore, orgStore: { findOne: async ({ code }: { code: string }) => orgs.find((o) => o.code === code) ?? null, get: async (id: string) => orgs.find((o) => o.id === id) ?? null }, roleStore, audit, auditLog, orgRoles, add }; } // ------------------------------------------------------------------ unit: passwords & RBAC @@ -509,6 +521,26 @@ async function flows(label: string, stores: { sessions: SessionStore; kv: KeyVal ok((await code(auth.login({ email: "dual@x.in", password: "shared-pass-99" }))) === "ORG_REQUIRED", "same email in two organisations → ORG_REQUIRED"); ok((await auth.login({ email: "dual@x.in", password: "shared-pass-99", orgCode: "other" })).user.id === "u5", "…with orgCode → the right one"); + // choosing and switching organisations + await db.add({ id: "u6", orgId: "org-c", name: "Dual C", email: "dual@x.in", password: "different-pass-77", status: "active", roles: [] }); + const orgErr = await auth.login({ email: "dual@x.in", password: "shared-pass-99" }).catch((e: AuthError) => e); + ok(orgErr instanceof AuthError && orgErr.details.organizations?.map((x) => x.code).join() === "OTHER,MGPL" && orgErr.details.organizations[0]!.name === "Arang Collection Centre", "ORG_REQUIRED names the organisations whose password matched (sorted by name), not the others"); + const wrongErr = await auth.login({ email: "dual@x.in", password: "wrong-password-1" }).catch((e: AuthError) => e); + ok(wrongErr instanceof AuthError && wrongErr.code === "INVALID_CREDENTIALS" && !wrongErr.details.organizations, "a wrong password names no organisation"); + const inA = await auth.login({ email: "dual@x.in", password: "shared-pass-99", orgCode: "MGPL", ip: "2.2.2.2" }); + ok((await auth.organizationsOf("u4")).map((x) => x.code).join() === "OTHER,MGPL,THIRD" && (await auth.organizationsOf("u1")).length === 1, "organizationsOf: every active account with the same email, the current one included"); + const toB = await auth.switchOrganization({ token: inA.token, orgId: "org-b", ip: "2.2.2.2" }); + ok(toB.user.id === "u5" && (await auth.authenticate(toB.token))?.orgId === "org-b" && (await auth.authenticate(inA.token)) === null, "switchOrganization: new session in the other organisation, the old one ends"); + ok(db.auditLog.some((x) => x.action === "auth.org_switch_out" && x.userId === "u4" && x.data?.to === "org-b") && db.auditLog.some((x) => x.action === "auth.org_switch" && x.userId === "u5" && x.data?.from === "org-a"), "switch audited in both organisations"); + ok((await code(auth.switchOrganization({ token: toB.token, orgId: "org-b" }))) === "ORG_UNAVAILABLE" && (await code(auth.switchOrganization({ token: toB.token, orgId: "org-x" }))) === "ORG_UNAVAILABLE", "switching to the same or an unknown organisation → ORG_UNAVAILABLE"); + ok((await code(auth.switchOrganization({ token: login.token.replace(/.$/, "x"), orgId: "org-b" }))) === "UNAUTHENTICATED", "switching needs a valid session"); + const asha = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + ok((await code(auth.switchOrganization({ token: asha.token, orgId: "org-b" }))) === "ORG_UNAVAILABLE", "no account with that email there → ORG_UNAVAILABLE"); + await auth.logout(asha.token); + db.users.set("u4", { ...db.users.get("u4")!, status: "disabled" }); + ok((await code(auth.switchOrganization({ token: toB.token, orgId: "org-a" }))) === "ORG_UNAVAILABLE" && !(await auth.organizationsOf("u5")).some((x) => x.orgId === "org-a"), "a disabled account there cannot be switched to or listed"); + db.users.set("u4", { ...db.users.get("u4")!, status: "active" }); + const before = db.users.get("u2")!.passwordHash; await auth.login({ email: "old@mgpl.in", password: "neem-tree-arang" }); ok(before.startsWith("scrypt$16384$") && db.users.get("u2")!.passwordHash.startsWith("scrypt$32768$"), "weak hash upgraded on login"); @@ -681,6 +713,20 @@ async function jwtFlows(label: string, stores: { sessions: SessionStore; kv: Key issued.push(u2.refreshToken); ok((await auth.sessionsOf("j1")).find((x) => x.id === sidOf(u2.accessToken))?.tlsFingerprintHash === hashTlsFingerprint(JA4), "a session without one is bound by the first refresh that has one"); ok((await code(auth.refresh({ refreshToken: t4.refreshToken, device: { deviceId: "dev-laptop-9999-zzzzzz" }, tlsFingerprint: JA4b }))) === "INVALID_TOKEN" && db.auditLog.some((x) => x.action === "auth.device_mismatch" && x.data?.tls === "same"), "a device mismatch audit says whether the TLS fingerprint matched"); + // switching organisations in JWT mode keeps the device binding + await db.add({ id: "j8", orgId: "org-a", name: "Two A", email: "two@x.in", password: "shared-pass-99", status: "active", roles: [] }); + await db.add({ id: "j9", orgId: "org-b", name: "Two B", email: "two@x.in", password: "shared-pass-99", status: "active", roles: [] }); + const sw = createAuth({ users: db.userStore, orgs: db.orgStore, audit: db.audit, ...stores, jwt: { secret: SECRET } }); + const dev = { deviceId: "dev-switch-0001-abcdef" }; + const s1 = await sw.login({ email: "two@x.in", password: "shared-pass-99", orgCode: "MGPL", device: dev }); + ok((await code(sw.switchOrganization({ token: s1.token, orgId: "org-b", device: { deviceId: "dev-other-0002-zzzzzz" } }))) === "INVALID_TOKEN" && (await sw.authenticate(s1.token)) === null, "switch from another device: refused, session ended"); + const s2 = await sw.login({ email: "two@x.in", password: "shared-pass-99", orgCode: "MGPL", device: dev }); + const s3 = await sw.switchOrganization({ token: s2.token, orgId: "org-b", device: dev }); + ok(!!s3.refreshToken && (await sw.authenticate(s3.token))?.userId === "j9" && (await code(sw.refresh({ refreshToken: s2.refreshToken!, device: dev }))) === "INVALID_TOKEN", "switch in JWT mode: new access + refresh token; the old refresh token is dead"); + const s4 = await sw.refresh({ refreshToken: s3.refreshToken!, device: dev }); + ok(s4.user.id === "j9", "the switched session refreshes on the same device"); + issued.push(s3.refreshToken!, s4.refreshToken); + ok(hashTlsFingerprint("has space x") === undefined && hashTlsFingerprint("short") === undefined && hashTlsFingerprint("x".repeat(257)) === undefined, "malformed TLS fingerprints are dropped"); let threw = false; @@ -719,7 +765,7 @@ import { jwtSigner, looksLikeJwt, type AccessClaims, type JwtOptions } from "./j import { memoryRateLimiter } from "./memory"; import { assertPasswordPolicy, hashPassword, hashToken, needsRehash, randomToken, verifyPassword, type PasswordPolicy } from "./password"; import { branchesFor, can, permissionsOf, roleMap, type Permission, type RoleAssignment, type RoleMap } from "./rbac"; -import type { AuditSink, AuthUser, DeviceSignals, KeyValueStore, OrgStore, RateLimiter, RoleStore, Session, SessionStore, UserStore } from "./stores"; +import type { AuditSink, AuthUser, DeviceSignals, KeyValueStore, OrgChoice, OrgStore, RateLimiter, RoleStore, Session, SessionStore, UserStore } from "./stores"; export interface AuthOptions { users: UserStore; @@ -819,6 +865,17 @@ export interface LoginResult { refreshExpiresAt?: Date; } +/** Moving a signed-in person to their account in another organisation (same email). */ +export interface SwitchOrganizationInput { + /** The current access token (JWT mode) or session token. Its session ends when the switch succeeds. */ + token: string; + orgId: string; + ip?: string; + userAgent?: string; + device?: DeviceSignals; + tlsFingerprint?: string; +} + /** What `refresh` returns: a new access token and the next refresh token (the old one is spent). */ export interface TokenPair { accessToken: string; @@ -908,6 +965,29 @@ export function createAuth(o: AuthOptions) { return user ? [user] : []; } + /** The organisations of these accounts, named, sorted by name. Empty without `orgs.get`. */ + async function choicesFor(users: AuthUser[]): Promise { + if (!o.orgs?.get) return []; + const out: OrgChoice[] = []; + for (const u of users) { + const org = await o.orgs.get(u.orgId); + if (org) out.push({ orgId: org.id, code: org.code, name: org.name || org.code }); + } + return out.sort((a, b) => a.name.localeCompare(b.name)); + } + + /** Signs a user in on a new session: last login, audit, and the token(s) for the client. */ + async function issue(user: AuthUser, s: { ip?: string; userAgent?: string; device?: DeviceSignals; tlsFingerprint?: string }, action: string, data?: Record): Promise { + const lastLoginAt = new Date(); + await o.users.update(user.id, { lastLoginAt }); + const { token, session } = await startSession(user, s.ip, s.userAgent, s.device, s.tlsFingerprint); + await audit({ orgId: user.orgId, userId: user.id, action, ip: s.ip, ...(data ? { data } : {}) }); + const pub = publicUser({ ...user, lastLoginAt }); + if (!jwt) return { token, expiresAt: new Date(session.expiresAt), user: pub }; + const access = accessToken(session); + return { token: access.token, expiresAt: access.expiresAt, refreshToken: token, refreshExpiresAt: refreshExpiry(session), user: pub }; + } + async function startSession(user: AuthUser, ip?: string, userAgent?: string, device?: DeviceSignals, tlsFingerprint?: string): Promise<{ token: string; session: Session }> { const token = randomToken(); const now = Date.now(); @@ -992,7 +1072,11 @@ export function createAuth(o: AuthOptions) { if (first) await audit({ orgId: first.orgId, userId: first.id, action: "auth.login_failed", ip: input.ip }); throw new AuthError("INVALID_CREDENTIALS", "Email or password is incorrect"); } - if (matched.length > 1) throw new AuthError("ORG_REQUIRED", "This email belongs to several organisations. Choose one."); + if (matched.length > 1) { + // the password matched every one of these accounts, so naming their organisations reveals nothing new + const organizations = await choicesFor(matched.filter((u) => u.status === "active")); + throw new AuthError("ORG_REQUIRED", "This email belongs to several organisations. Choose one.", { organizations }); + } const user = matched[0]!; if (user.status === "disabled") throw new AuthError("ACCOUNT_DISABLED", "This account is disabled"); if (user.status === "invited") throw new AuthError("INVITE_PENDING", "Accept your invitation first"); @@ -1001,15 +1085,45 @@ export function createAuth(o: AuthOptions) { // only failed attempts count against the IP: staff behind one office connection must all be able to // sign in. Every attempt is still counted first, so a burst of guesses is blocked before verification. if (input.ip) await limiter.undo?.(`login:ip:${input.ip}`); - const patch: { lastLoginAt: Date; passwordHash?: string } = { lastLoginAt: new Date() }; - if (needsRehash(user.passwordHash)) patch.passwordHash = await hashPassword(input.password); - await o.users.update(user.id, patch); - const { token, session } = await startSession(user, input.ip, input.userAgent, input.device, input.tlsFingerprint); - await audit({ orgId: user.orgId, userId: user.id, action: "auth.login", ip: input.ip }); - const pub = publicUser({ ...user, lastLoginAt: patch.lastLoginAt }); - if (!jwt) return { token, expiresAt: new Date(session.expiresAt), user: pub }; - const access = accessToken(session); - return { token: access.token, expiresAt: access.expiresAt, refreshToken: token, refreshExpiresAt: refreshExpiry(session), user: pub }; + if (needsRehash(user.passwordHash)) await o.users.update(user.id, { passwordHash: await hashPassword(input.password) }); + return issue(user, input, "auth.login"); + }, + + /** + * The organisations a signed-in person can switch to: every active account with the same email, + * the current one included. Needs `orgs.get` (empty otherwise). + */ + async organizationsOf(userId: string): Promise { + const user = await o.users.get(userId); + if (!user || user.status !== "active") return []; + return choicesFor((await o.users.findAllByEmail(normalizeEmail(user.email))).filter((u) => u.status === "active")); + }, + + /** + * Moves a signed-in person to their account in another organisation (same email, active there), + * without the password: the current session must be valid, and it ends. The new session is bound + * to the same device, with the same device-binding rule as `refresh`. Audited in both organisations + * (auth.org_switch_out / auth.org_switch). Returns what `login` returns. + */ + async switchOrganization(input: SwitchOrganizationInput): Promise { + const viewer = await authenticate(input.token); + if (!viewer) throw new AuthError("UNAUTHENTICATED", "Sign in required"); + const s = await o.sessions.get(viewer.sessionId); + if (!s) throw new AuthError("UNAUTHENTICATED", "Sign in required"); + const target = input.orgId === viewer.orgId ? null : (await o.users.findAllByEmail(normalizeEmail(viewer.email))).find((u) => u.orgId === input.orgId && u.status === "active"); + if (!target) throw new AuthError("ORG_UNAVAILABLE", "You can't switch to that organisation"); + const device = cleanDeviceSignals(input.device); + const bound = s.device?.deviceId; + if (deviceBinding !== "off" && bound && device?.deviceId !== bound) { + await audit({ orgId: s.orgId, userId: s.userId, action: "auth.device_mismatch", ip: input.ip, entity: "session", data: { expected: bound, got: device?.deviceId ?? null, switchTo: target.orgId } }); + if (deviceBinding === "enforce") { + await o.sessions.delete(s.id); + throw new AuthError("INVALID_TOKEN", "Sign in again"); + } + } + await o.sessions.delete(s.id); + await audit({ orgId: viewer.orgId, userId: viewer.userId, action: "auth.org_switch_out", ip: input.ip, data: { to: target.orgId } }); + return issue(target, { ...input, device: device ? { ...device, deviceId: bound ?? device.deviceId } : s.device }, "auth.org_switch", { from: viewer.orgId }); }, /** @@ -1207,6 +1321,8 @@ export function createAuth(o: AuthOptions) { ## File: src/errors.ts ```ts +import type { OrgChoice } from "./stores"; + export type AuthErrorCode = | "INVALID_CREDENTIALS" | "ACCOUNT_DISABLED" @@ -1216,7 +1332,8 @@ export type AuthErrorCode = | "WEAK_PASSWORD" | "INVALID_TOKEN" | "UNAUTHENTICATED" - | "FORBIDDEN"; + | "FORBIDDEN" + | "ORG_UNAVAILABLE"; const STATUS: Record = { INVALID_CREDENTIALS: 401, @@ -1228,6 +1345,7 @@ const STATUS: Record = { INVALID_TOKEN: 400, UNAUTHENTICATED: 401, FORBIDDEN: 403, + ORG_UNAVAILABLE: 403, }; /** Every auth failure. `code` is stable for clients; `statusCode` suits HTTP and GraphQL extensions. */ @@ -1236,7 +1354,12 @@ export class AuthError extends Error { constructor( readonly code: AuthErrorCode, message: string, - readonly details: { retryAfterMs?: number; permission?: string } = {}, + readonly details: { + retryAfterMs?: number; + permission?: string; + /** ORG_REQUIRED: the organisations whose account matched the email and password. Only after the password was verified. */ + organizations?: OrgChoice[]; + } = {}, ) { super(message); this.name = "AuthError"; @@ -1251,7 +1374,7 @@ export class AuthError extends Error { ## File: src/index.ts ```ts -export { cleanDeviceSignals, createAuth, hashTlsFingerprint, type Auth, type AuthContext, type AuthOptions, type LoginInput, type LoginResult, type PublicUser, type TokenPair, type Viewer } from "./auth"; +export { cleanDeviceSignals, createAuth, hashTlsFingerprint, type Auth, type AuthContext, type AuthOptions, type LoginInput, type LoginResult, type PublicUser, type SwitchOrganizationInput, type TokenPair, type Viewer } from "./auth"; export { jwtSigner, looksLikeJwt, type AccessClaims, type JwtOptions } from "./jwt"; export { AuthError, type AuthErrorCode } from "./errors"; export { assertPasswordPolicy, hashPassword, hashToken, needsRehash, randomToken, SCRYPT, verifyPassword, type PasswordPolicy } from "./password"; @@ -1260,7 +1383,7 @@ export { type Permission, type RoleAssignment, type RoleDefinition, type RoleMap, } from "./rbac"; export { memoryKeyValueStore, memoryRateLimiter, memorySessionStore } from "./memory"; -export type { AuditSink, AuthUser, DeviceSignals, KeyValueStore, OrgStore, RateLimiter, RoleStore, Session, SessionStore, UserStatus, UserStore } from "./stores"; +export type { AuditSink, AuthUser, DeviceSignals, KeyValueStore, OrgChoice, OrgStore, RateLimiter, RoleStore, Session, SessionStore, UserStatus, UserStore } from "./stores"; ``` @@ -1782,6 +1905,15 @@ export interface UserStore { export interface OrgStore { findOne(filter: { code: string }): Promise<{ id: string; code: string } | null>; + /** By id. Needed to name the organisations a person can choose (ORG_REQUIRED, organizationsOf). */ + get?(id: string): Promise<{ id: string; code: string; name?: string } | null>; +} + +/** One organisation a person can sign in to (same email, an active account there). */ +export interface OrgChoice { + orgId: string; + code: string; + name: string; } export interface RoleStore { diff --git a/package.json b/package.json index 57f1c19..ec39db8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mgpl-diagnostics/auth-kit", - "version": "1.4.0", + "version": "1.5.0", "description": "MGPL Diagnostics authentication for the backend: scrypt passwords, revocable sessions (Redis or memory), rate-limited timing-safe login, password reset, roles and branch-aware permissions. An internal API, no HTTP server.", "type": "module", "license": "SEE LICENSE IN LICENSE", diff --git a/scripts/test.ts b/scripts/test.ts index db8df05..85e33da 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -19,7 +19,7 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); function fakeDb() { const users = new Map(); - const orgs = [{ id: "org-a", code: "MGPL" }, { id: "org-b", code: "OTHER" }]; + const orgs = [{ id: "org-a", code: "MGPL", name: "Maa Gayatri Path Lab" }, { id: "org-b", code: "OTHER", name: "Arang Collection Centre" }, { id: "org-c", code: "THIRD", name: "Third Lab" }]; const orgRoles: Record = { "org-a": [{ key: "lab_auditor", permissions: ["audit:read", "accounts:read"] }] }; const auditLog: { action: string; userId?: string | null; data?: Record }[] = []; const userStore: UserStore = { @@ -33,7 +33,7 @@ function fakeDb() { const add = async (u: Omit & { password: string; hashParams?: { N: number; r: number; p: number } }) => { users.set(u.id, { ...u, passwordHash: await hashPassword(u.password, u.hashParams) }); }; - return { users, userStore, orgStore: { findOne: async ({ code }: { code: string }) => orgs.find((o) => o.code === code) ?? null }, roleStore, audit, auditLog, orgRoles, add }; + return { users, userStore, orgStore: { findOne: async ({ code }: { code: string }) => orgs.find((o) => o.code === code) ?? null, get: async (id: string) => orgs.find((o) => o.id === id) ?? null }, roleStore, audit, auditLog, orgRoles, add }; } // ------------------------------------------------------------------ unit: passwords & RBAC @@ -93,6 +93,26 @@ async function flows(label: string, stores: { sessions: SessionStore; kv: KeyVal ok((await code(auth.login({ email: "dual@x.in", password: "shared-pass-99" }))) === "ORG_REQUIRED", "same email in two organisations → ORG_REQUIRED"); ok((await auth.login({ email: "dual@x.in", password: "shared-pass-99", orgCode: "other" })).user.id === "u5", "…with orgCode → the right one"); + // choosing and switching organisations + await db.add({ id: "u6", orgId: "org-c", name: "Dual C", email: "dual@x.in", password: "different-pass-77", status: "active", roles: [] }); + const orgErr = await auth.login({ email: "dual@x.in", password: "shared-pass-99" }).catch((e: AuthError) => e); + ok(orgErr instanceof AuthError && orgErr.details.organizations?.map((x) => x.code).join() === "OTHER,MGPL" && orgErr.details.organizations[0]!.name === "Arang Collection Centre", "ORG_REQUIRED names the organisations whose password matched (sorted by name), not the others"); + const wrongErr = await auth.login({ email: "dual@x.in", password: "wrong-password-1" }).catch((e: AuthError) => e); + ok(wrongErr instanceof AuthError && wrongErr.code === "INVALID_CREDENTIALS" && !wrongErr.details.organizations, "a wrong password names no organisation"); + const inA = await auth.login({ email: "dual@x.in", password: "shared-pass-99", orgCode: "MGPL", ip: "2.2.2.2" }); + ok((await auth.organizationsOf("u4")).map((x) => x.code).join() === "OTHER,MGPL,THIRD" && (await auth.organizationsOf("u1")).length === 1, "organizationsOf: every active account with the same email, the current one included"); + const toB = await auth.switchOrganization({ token: inA.token, orgId: "org-b", ip: "2.2.2.2" }); + ok(toB.user.id === "u5" && (await auth.authenticate(toB.token))?.orgId === "org-b" && (await auth.authenticate(inA.token)) === null, "switchOrganization: new session in the other organisation, the old one ends"); + ok(db.auditLog.some((x) => x.action === "auth.org_switch_out" && x.userId === "u4" && x.data?.to === "org-b") && db.auditLog.some((x) => x.action === "auth.org_switch" && x.userId === "u5" && x.data?.from === "org-a"), "switch audited in both organisations"); + ok((await code(auth.switchOrganization({ token: toB.token, orgId: "org-b" }))) === "ORG_UNAVAILABLE" && (await code(auth.switchOrganization({ token: toB.token, orgId: "org-x" }))) === "ORG_UNAVAILABLE", "switching to the same or an unknown organisation → ORG_UNAVAILABLE"); + ok((await code(auth.switchOrganization({ token: login.token.replace(/.$/, "x"), orgId: "org-b" }))) === "UNAUTHENTICATED", "switching needs a valid session"); + const asha = await auth.login({ email: "asha@mgpl.in", password: "tulsi-leaf-raipur" }); + ok((await code(auth.switchOrganization({ token: asha.token, orgId: "org-b" }))) === "ORG_UNAVAILABLE", "no account with that email there → ORG_UNAVAILABLE"); + await auth.logout(asha.token); + db.users.set("u4", { ...db.users.get("u4")!, status: "disabled" }); + ok((await code(auth.switchOrganization({ token: toB.token, orgId: "org-a" }))) === "ORG_UNAVAILABLE" && !(await auth.organizationsOf("u5")).some((x) => x.orgId === "org-a"), "a disabled account there cannot be switched to or listed"); + db.users.set("u4", { ...db.users.get("u4")!, status: "active" }); + const before = db.users.get("u2")!.passwordHash; await auth.login({ email: "old@mgpl.in", password: "neem-tree-arang" }); ok(before.startsWith("scrypt$16384$") && db.users.get("u2")!.passwordHash.startsWith("scrypt$32768$"), "weak hash upgraded on login"); @@ -265,6 +285,20 @@ async function jwtFlows(label: string, stores: { sessions: SessionStore; kv: Key issued.push(u2.refreshToken); ok((await auth.sessionsOf("j1")).find((x) => x.id === sidOf(u2.accessToken))?.tlsFingerprintHash === hashTlsFingerprint(JA4), "a session without one is bound by the first refresh that has one"); ok((await code(auth.refresh({ refreshToken: t4.refreshToken, device: { deviceId: "dev-laptop-9999-zzzzzz" }, tlsFingerprint: JA4b }))) === "INVALID_TOKEN" && db.auditLog.some((x) => x.action === "auth.device_mismatch" && x.data?.tls === "same"), "a device mismatch audit says whether the TLS fingerprint matched"); + // switching organisations in JWT mode keeps the device binding + await db.add({ id: "j8", orgId: "org-a", name: "Two A", email: "two@x.in", password: "shared-pass-99", status: "active", roles: [] }); + await db.add({ id: "j9", orgId: "org-b", name: "Two B", email: "two@x.in", password: "shared-pass-99", status: "active", roles: [] }); + const sw = createAuth({ users: db.userStore, orgs: db.orgStore, audit: db.audit, ...stores, jwt: { secret: SECRET } }); + const dev = { deviceId: "dev-switch-0001-abcdef" }; + const s1 = await sw.login({ email: "two@x.in", password: "shared-pass-99", orgCode: "MGPL", device: dev }); + ok((await code(sw.switchOrganization({ token: s1.token, orgId: "org-b", device: { deviceId: "dev-other-0002-zzzzzz" } }))) === "INVALID_TOKEN" && (await sw.authenticate(s1.token)) === null, "switch from another device: refused, session ended"); + const s2 = await sw.login({ email: "two@x.in", password: "shared-pass-99", orgCode: "MGPL", device: dev }); + const s3 = await sw.switchOrganization({ token: s2.token, orgId: "org-b", device: dev }); + ok(!!s3.refreshToken && (await sw.authenticate(s3.token))?.userId === "j9" && (await code(sw.refresh({ refreshToken: s2.refreshToken!, device: dev }))) === "INVALID_TOKEN", "switch in JWT mode: new access + refresh token; the old refresh token is dead"); + const s4 = await sw.refresh({ refreshToken: s3.refreshToken!, device: dev }); + ok(s4.user.id === "j9", "the switched session refreshes on the same device"); + issued.push(s3.refreshToken!, s4.refreshToken); + ok(hashTlsFingerprint("has space x") === undefined && hashTlsFingerprint("short") === undefined && hashTlsFingerprint("x".repeat(257)) === undefined, "malformed TLS fingerprints are dropped"); let threw = false; diff --git a/src/auth.ts b/src/auth.ts index 5e21c4e..30b543a 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -3,7 +3,7 @@ import { jwtSigner, looksLikeJwt, type AccessClaims, type JwtOptions } from "./j import { memoryRateLimiter } from "./memory"; import { assertPasswordPolicy, hashPassword, hashToken, needsRehash, randomToken, verifyPassword, type PasswordPolicy } from "./password"; import { branchesFor, can, permissionsOf, roleMap, type Permission, type RoleAssignment, type RoleMap } from "./rbac"; -import type { AuditSink, AuthUser, DeviceSignals, KeyValueStore, OrgStore, RateLimiter, RoleStore, Session, SessionStore, UserStore } from "./stores"; +import type { AuditSink, AuthUser, DeviceSignals, KeyValueStore, OrgChoice, OrgStore, RateLimiter, RoleStore, Session, SessionStore, UserStore } from "./stores"; export interface AuthOptions { users: UserStore; @@ -103,6 +103,17 @@ export interface LoginResult { refreshExpiresAt?: Date; } +/** Moving a signed-in person to their account in another organisation (same email). */ +export interface SwitchOrganizationInput { + /** The current access token (JWT mode) or session token. Its session ends when the switch succeeds. */ + token: string; + orgId: string; + ip?: string; + userAgent?: string; + device?: DeviceSignals; + tlsFingerprint?: string; +} + /** What `refresh` returns: a new access token and the next refresh token (the old one is spent). */ export interface TokenPair { accessToken: string; @@ -192,6 +203,29 @@ export function createAuth(o: AuthOptions) { return user ? [user] : []; } + /** The organisations of these accounts, named, sorted by name. Empty without `orgs.get`. */ + async function choicesFor(users: AuthUser[]): Promise { + if (!o.orgs?.get) return []; + const out: OrgChoice[] = []; + for (const u of users) { + const org = await o.orgs.get(u.orgId); + if (org) out.push({ orgId: org.id, code: org.code, name: org.name || org.code }); + } + return out.sort((a, b) => a.name.localeCompare(b.name)); + } + + /** Signs a user in on a new session: last login, audit, and the token(s) for the client. */ + async function issue(user: AuthUser, s: { ip?: string; userAgent?: string; device?: DeviceSignals; tlsFingerprint?: string }, action: string, data?: Record): Promise { + const lastLoginAt = new Date(); + await o.users.update(user.id, { lastLoginAt }); + const { token, session } = await startSession(user, s.ip, s.userAgent, s.device, s.tlsFingerprint); + await audit({ orgId: user.orgId, userId: user.id, action, ip: s.ip, ...(data ? { data } : {}) }); + const pub = publicUser({ ...user, lastLoginAt }); + if (!jwt) return { token, expiresAt: new Date(session.expiresAt), user: pub }; + const access = accessToken(session); + return { token: access.token, expiresAt: access.expiresAt, refreshToken: token, refreshExpiresAt: refreshExpiry(session), user: pub }; + } + async function startSession(user: AuthUser, ip?: string, userAgent?: string, device?: DeviceSignals, tlsFingerprint?: string): Promise<{ token: string; session: Session }> { const token = randomToken(); const now = Date.now(); @@ -276,7 +310,11 @@ export function createAuth(o: AuthOptions) { if (first) await audit({ orgId: first.orgId, userId: first.id, action: "auth.login_failed", ip: input.ip }); throw new AuthError("INVALID_CREDENTIALS", "Email or password is incorrect"); } - if (matched.length > 1) throw new AuthError("ORG_REQUIRED", "This email belongs to several organisations. Choose one."); + if (matched.length > 1) { + // the password matched every one of these accounts, so naming their organisations reveals nothing new + const organizations = await choicesFor(matched.filter((u) => u.status === "active")); + throw new AuthError("ORG_REQUIRED", "This email belongs to several organisations. Choose one.", { organizations }); + } const user = matched[0]!; if (user.status === "disabled") throw new AuthError("ACCOUNT_DISABLED", "This account is disabled"); if (user.status === "invited") throw new AuthError("INVITE_PENDING", "Accept your invitation first"); @@ -285,15 +323,45 @@ export function createAuth(o: AuthOptions) { // only failed attempts count against the IP: staff behind one office connection must all be able to // sign in. Every attempt is still counted first, so a burst of guesses is blocked before verification. if (input.ip) await limiter.undo?.(`login:ip:${input.ip}`); - const patch: { lastLoginAt: Date; passwordHash?: string } = { lastLoginAt: new Date() }; - if (needsRehash(user.passwordHash)) patch.passwordHash = await hashPassword(input.password); - await o.users.update(user.id, patch); - const { token, session } = await startSession(user, input.ip, input.userAgent, input.device, input.tlsFingerprint); - await audit({ orgId: user.orgId, userId: user.id, action: "auth.login", ip: input.ip }); - const pub = publicUser({ ...user, lastLoginAt: patch.lastLoginAt }); - if (!jwt) return { token, expiresAt: new Date(session.expiresAt), user: pub }; - const access = accessToken(session); - return { token: access.token, expiresAt: access.expiresAt, refreshToken: token, refreshExpiresAt: refreshExpiry(session), user: pub }; + if (needsRehash(user.passwordHash)) await o.users.update(user.id, { passwordHash: await hashPassword(input.password) }); + return issue(user, input, "auth.login"); + }, + + /** + * The organisations a signed-in person can switch to: every active account with the same email, + * the current one included. Needs `orgs.get` (empty otherwise). + */ + async organizationsOf(userId: string): Promise { + const user = await o.users.get(userId); + if (!user || user.status !== "active") return []; + return choicesFor((await o.users.findAllByEmail(normalizeEmail(user.email))).filter((u) => u.status === "active")); + }, + + /** + * Moves a signed-in person to their account in another organisation (same email, active there), + * without the password: the current session must be valid, and it ends. The new session is bound + * to the same device, with the same device-binding rule as `refresh`. Audited in both organisations + * (auth.org_switch_out / auth.org_switch). Returns what `login` returns. + */ + async switchOrganization(input: SwitchOrganizationInput): Promise { + const viewer = await authenticate(input.token); + if (!viewer) throw new AuthError("UNAUTHENTICATED", "Sign in required"); + const s = await o.sessions.get(viewer.sessionId); + if (!s) throw new AuthError("UNAUTHENTICATED", "Sign in required"); + const target = input.orgId === viewer.orgId ? null : (await o.users.findAllByEmail(normalizeEmail(viewer.email))).find((u) => u.orgId === input.orgId && u.status === "active"); + if (!target) throw new AuthError("ORG_UNAVAILABLE", "You can't switch to that organisation"); + const device = cleanDeviceSignals(input.device); + const bound = s.device?.deviceId; + if (deviceBinding !== "off" && bound && device?.deviceId !== bound) { + await audit({ orgId: s.orgId, userId: s.userId, action: "auth.device_mismatch", ip: input.ip, entity: "session", data: { expected: bound, got: device?.deviceId ?? null, switchTo: target.orgId } }); + if (deviceBinding === "enforce") { + await o.sessions.delete(s.id); + throw new AuthError("INVALID_TOKEN", "Sign in again"); + } + } + await o.sessions.delete(s.id); + await audit({ orgId: viewer.orgId, userId: viewer.userId, action: "auth.org_switch_out", ip: input.ip, data: { to: target.orgId } }); + return issue(target, { ...input, device: device ? { ...device, deviceId: bound ?? device.deviceId } : s.device }, "auth.org_switch", { from: viewer.orgId }); }, /** diff --git a/src/errors.ts b/src/errors.ts index 314c372..2d9253c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,3 +1,5 @@ +import type { OrgChoice } from "./stores"; + export type AuthErrorCode = | "INVALID_CREDENTIALS" | "ACCOUNT_DISABLED" @@ -7,7 +9,8 @@ export type AuthErrorCode = | "WEAK_PASSWORD" | "INVALID_TOKEN" | "UNAUTHENTICATED" - | "FORBIDDEN"; + | "FORBIDDEN" + | "ORG_UNAVAILABLE"; const STATUS: Record = { INVALID_CREDENTIALS: 401, @@ -19,6 +22,7 @@ const STATUS: Record = { INVALID_TOKEN: 400, UNAUTHENTICATED: 401, FORBIDDEN: 403, + ORG_UNAVAILABLE: 403, }; /** Every auth failure. `code` is stable for clients; `statusCode` suits HTTP and GraphQL extensions. */ @@ -27,7 +31,12 @@ export class AuthError extends Error { constructor( readonly code: AuthErrorCode, message: string, - readonly details: { retryAfterMs?: number; permission?: string } = {}, + readonly details: { + retryAfterMs?: number; + permission?: string; + /** ORG_REQUIRED: the organisations whose account matched the email and password. Only after the password was verified. */ + organizations?: OrgChoice[]; + } = {}, ) { super(message); this.name = "AuthError"; diff --git a/src/index.ts b/src/index.ts index f61a3f4..3ecc8d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export { cleanDeviceSignals, createAuth, hashTlsFingerprint, type Auth, type AuthContext, type AuthOptions, type LoginInput, type LoginResult, type PublicUser, type TokenPair, type Viewer } from "./auth"; +export { cleanDeviceSignals, createAuth, hashTlsFingerprint, type Auth, type AuthContext, type AuthOptions, type LoginInput, type LoginResult, type PublicUser, type SwitchOrganizationInput, type TokenPair, type Viewer } from "./auth"; export { jwtSigner, looksLikeJwt, type AccessClaims, type JwtOptions } from "./jwt"; export { AuthError, type AuthErrorCode } from "./errors"; export { assertPasswordPolicy, hashPassword, hashToken, needsRehash, randomToken, SCRYPT, verifyPassword, type PasswordPolicy } from "./password"; @@ -7,4 +7,4 @@ export { type Permission, type RoleAssignment, type RoleDefinition, type RoleMap, } from "./rbac"; export { memoryKeyValueStore, memoryRateLimiter, memorySessionStore } from "./memory"; -export type { AuditSink, AuthUser, DeviceSignals, KeyValueStore, OrgStore, RateLimiter, RoleStore, Session, SessionStore, UserStatus, UserStore } from "./stores"; +export type { AuditSink, AuthUser, DeviceSignals, KeyValueStore, OrgChoice, OrgStore, RateLimiter, RoleStore, Session, SessionStore, UserStatus, UserStore } from "./stores"; diff --git a/src/stores.ts b/src/stores.ts index 7ae83fd..4c893bf 100644 --- a/src/stores.ts +++ b/src/stores.ts @@ -29,6 +29,15 @@ export interface UserStore { export interface OrgStore { findOne(filter: { code: string }): Promise<{ id: string; code: string } | null>; + /** By id. Needed to name the organisations a person can choose (ORG_REQUIRED, organizationsOf). */ + get?(id: string): Promise<{ id: string; code: string; name?: string } | null>; +} + +/** One organisation a person can sign in to (same email, an active account there). */ +export interface OrgChoice { + orgId: string; + code: string; + name: string; } export interface RoleStore {