diff --git a/docs/04-api-reference.md b/docs/04-api-reference.md index 34e5e2c..5b03436 100644 --- a/docs/04-api-reference.md +++ b/docs/04-api-reference.md @@ -19,6 +19,7 @@ and runnable examples are in [examples/](examples/). | Kind | Operation | Permission | Notes | |---|---|---|---| +| query | `myOrganizations: [OrganizationChoice!]!` | (signed in) | Every lab the signed-in person can switch to (same email, active account), the current one included. | | query | `mySessions: [Session!]!` | (signed in) | | | query | `permissions: [String!]!` | (signed in) | Every permission name the API checks. | | query | `roles: [Role!]!` | `role:read` | | @@ -38,6 +39,7 @@ and runnable examples are in [examples/](examples/). | mutation | `revokeSession(id: ID!): Boolean!` | (signed in) | | | mutation | `setUserPassword(userId: ID!, newPassword: String!): Boolean!` | `user:update` | | | mutation | `setUserRoles(userId: ID!, roles: [RoleAssignmentInput!]!): User!` | `user:update` | | +| mutation | `switchOrganization(orgCode: String!, device: DeviceInput): LoginResult!` | public | Moves this session to the signed-in person's account in another lab (same email, active there), without the password. This session ends; use the returned tokens. Send the same device as at login, or the session ends (INVALID_TOKEN). ORG_UNAVAILABLE when there is no such account, or it is the current lab. | | mutation | `upsertRole(input: RoleInput!): Role!` | `role:manage` | | ## organization diff --git a/docs/05-errors.md b/docs/05-errors.md index abb1cfc..d1d4519 100644 --- a/docs/05-errors.md +++ b/docs/05-errors.md @@ -26,7 +26,8 @@ Every error in a response carries `extensions.code`. Clients switch on the code, | `INVALID_CREDENTIALS` | 401 | Wrong email, organisation or password (same answer for each) | "Email or password is wrong" | | `ACCOUNT_DISABLED` | 403 | The user is disabled | Contact an administrator | | `INVITE_PENDING` | 403 | The user has not set a password yet | Use the reset link | -| `ORG_REQUIRED` | 400 | The email exists in more than one organisation | Ask for the organisation code | +| `ORG_REQUIRED` | 400 | The email and password match accounts in more than one lab. `extensions.organizations` lists them (`code`, `name`) | Show the list; log in again with the picked `orgCode` | +| `ORG_UNAVAILABLE` | 403 | `switchOrganization` to the current lab, an unknown code, or a lab without an active account for this person | Refresh `myOrganizations` | | `RATE_LIMITED` | 429 | Too many attempts; `extensions.retryAfterMs` says how long to wait | Wait, then retry | | `WEAK_PASSWORD` | 400 | The new password fails the policy | Show the message | | `INVALID_TOKEN` | 400 | A reset token is unknown, used or expired | Request a new link | diff --git a/docs/06-auth-and-permissions.md b/docs/06-auth-and-permissions.md index 18aa52c..5654960 100644 --- a/docs/06-auth-and-permissions.md +++ b/docs/06-auth-and-permissions.md @@ -90,6 +90,17 @@ value a client sent. A missing or malformed fingerprint is never an error. A cha session, whatever `DEVICE_BINDING` says: browser updates and TLS-inspecting networks change it too. It is not in the GraphQL schema; clients send nothing new. +### One person, several labs + +The same email can have an account in several labs (one user each). When the email and password match more +than one, `login` fails with `ORG_REQUIRED` and `extensions.organizations` (`[{ code, name }]`, only the labs +whose account matched): show them and log in again with the picked `orgCode`. Once signed in, +`myOrganizations` lists every lab the person can work in (`current` marks this session's), and +`switchOrganization(orgCode, device)` moves the session there without the password. The old session ends, +the same device rule as `refreshSession` applies, and auth-kit audits the switch in both labs +(`auth.org_switch_out`, `auth.org_switch`). The lab code is a name, not an id: an unknown code and a lab +without an account both answer `ORG_UNAVAILABLE`. + ### Password reset `requestPasswordReset(email, orgCode?)` always returns `true`, whether or not the account exists, and diff --git a/kits.json b/kits.json index 020318a..0802fd0 100644 --- a/kits.json +++ b/kits.json @@ -19,7 +19,7 @@ }, { "name": "auth-kit", - "ref": "v1.4.0" + "ref": "v1.5.0" }, { "name": "db-kit", @@ -35,7 +35,7 @@ }, { "name": "client", - "ref": "v1.4.0" + "ref": "v1.5.0" }, { "name": "invoice-kit", diff --git a/llms-full.txt b/llms-full.txt index b9838aa..09ca9f4 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -689,6 +689,7 @@ and runnable examples are in [examples/](examples/). | Kind | Operation | Permission | Notes | |---|---|---|---| +| query | `myOrganizations: [OrganizationChoice!]!` | (signed in) | Every lab the signed-in person can switch to (same email, active account), the current one included. | | query | `mySessions: [Session!]!` | (signed in) | | | query | `permissions: [String!]!` | (signed in) | Every permission name the API checks. | | query | `roles: [Role!]!` | `role:read` | | @@ -708,6 +709,7 @@ and runnable examples are in [examples/](examples/). | mutation | `revokeSession(id: ID!): Boolean!` | (signed in) | | | mutation | `setUserPassword(userId: ID!, newPassword: String!): Boolean!` | `user:update` | | | mutation | `setUserRoles(userId: ID!, roles: [RoleAssignmentInput!]!): User!` | `user:update` | | +| mutation | `switchOrganization(orgCode: String!, device: DeviceInput): LoginResult!` | public | Moves this session to the signed-in person's account in another lab (same email, active there), without the password. This session ends; use the returned tokens. Send the same device as at login, or the session ends (INVALID_TOKEN). ORG_UNAVAILABLE when there is no such account, or it is the current lab. | | mutation | `upsertRole(input: RoleInput!): Role!` | `role:manage` | | ## organization @@ -909,7 +911,8 @@ Every error in a response carries `extensions.code`. Clients switch on the code, | `INVALID_CREDENTIALS` | 401 | Wrong email, organisation or password (same answer for each) | "Email or password is wrong" | | `ACCOUNT_DISABLED` | 403 | The user is disabled | Contact an administrator | | `INVITE_PENDING` | 403 | The user has not set a password yet | Use the reset link | -| `ORG_REQUIRED` | 400 | The email exists in more than one organisation | Ask for the organisation code | +| `ORG_REQUIRED` | 400 | The email and password match accounts in more than one lab. `extensions.organizations` lists them (`code`, `name`) | Show the list; log in again with the picked `orgCode` | +| `ORG_UNAVAILABLE` | 403 | `switchOrganization` to the current lab, an unknown code, or a lab without an active account for this person | Refresh `myOrganizations` | | `RATE_LIMITED` | 429 | Too many attempts; `extensions.retryAfterMs` says how long to wait | Wait, then retry | | `WEAK_PASSWORD` | 400 | The new password fails the policy | Show the message | | `INVALID_TOKEN` | 400 | A reset token is unknown, used or expired | Request a new link | @@ -1039,6 +1042,17 @@ value a client sent. A missing or malformed fingerprint is never an error. A cha session, whatever `DEVICE_BINDING` says: browser updates and TLS-inspecting networks change it too. It is not in the GraphQL schema; clients send nothing new. +### One person, several labs + +The same email can have an account in several labs (one user each). When the email and password match more +than one, `login` fails with `ORG_REQUIRED` and `extensions.organizations` (`[{ code, name }]`, only the labs +whose account matched): show them and log in again with the picked `orgCode`. Once signed in, +`myOrganizations` lists every lab the person can work in (`current` marks this session's), and +`switchOrganization(orgCode, device)` moves the session there without the password. The old session ends, +the same device rule as `refreshSession` applies, and auth-kit audits the switch in both labs +(`auth.org_switch_out`, `auth.org_switch`). The lab code is a name, not an id: an unknown code and a lab +without an account both answer `ORG_UNAVAILABLE`. + ### Password reset `requestPasswordReset(email, orgCode?)` always returns `true`, whether or not the account exists, and @@ -2598,7 +2612,7 @@ flowchart TD }, { "name": "auth-kit", - "ref": "v1.4.0" + "ref": "v1.5.0" }, { "name": "db-kit", @@ -2614,7 +2628,7 @@ flowchart TD }, { "name": "client", - "ref": "v1.4.0" + "ref": "v1.5.0" }, { "name": "invoice-kit", @@ -2702,7 +2716,7 @@ Key facts for agents: }, "dependencies": { "@graphql-tools/schema": "^10.1.5", - "@mgpl-diagnostics/auth-kit": "^1.4.0", + "@mgpl-diagnostics/auth-kit": "^1.5.0", "@mgpl-diagnostics/catalog-kit": "^1.0.0", "@mgpl-diagnostics/db-kit": "^2.1.1", "@mgpl-diagnostics/finance-kit": "^1.0.1", @@ -2725,7 +2739,7 @@ Key facts for agents: "sequelize": "^6.37.8" }, "devDependencies": { - "@mgpl-diagnostics/client": "^1.4.0", + "@mgpl-diagnostics/client": "^1.5.0", "@types/node": "^22.20.5", "embedded-postgres": "^18.4.0-beta.17", "ioredis-mock": "^8.13.1", @@ -4016,6 +4030,13 @@ type Mutation { Renders a stored invoice or a booking's reports in the background; poll job(id) until completed or failed. """ startDocumentJob(input: DocumentJobInput!): Job! + + """ + Moves this session to the signed-in person's account in another lab (same email, active there), without + the password. This session ends; use the returned tokens. Send the same device as at login, or the + session ends (INVALID_TOKEN). ORG_UNAVAILABLE when there is no such account, or it is the current lab. + """ + switchOrganization(device: DeviceInput, orgCode: String!): LoginResult! updateBranch(id: ID!, input: BranchPatch!): Branch! updateCustomer(id: ID!, input: CustomerPatch!): Customer! updateDoctor(id: ID!, input: DoctorPatch!): Doctor! @@ -4066,6 +4087,19 @@ type Organization { phone: String } +""" +A lab the signed-in person can work in: they have an active account there with the same email. +Also listed in ORG_REQUIRED errors (extensions.organizations, without current) after a password matched. +""" +type OrganizationChoice { + """The lab code: pass it to login (orgCode) or switchOrganization.""" + code: String! + + """The lab of this session.""" + current: Boolean! + name: String! +} + input OrganizationInput { address: AddressInput email: String @@ -4356,6 +4390,11 @@ type Query { ledgers: [Ledger!]! letterhead(format: DocumentFormat = pdf): Document! lockDates: [PeriodLock!]! + + """ + Every lab the signed-in person can switch to (same email, active account), the current one included. + """ + myOrganizations: [OrganizationChoice!]! mySessions: [Session!]! organization: Organization! outstandingReceivables(asOf: DateTime, branchId: ID): Outstanding! @@ -5478,6 +5517,7 @@ import { deviceOf, type DeviceInput } from "../graphql/device"; export const authController = withErrorCodes({ Query: { viewer: (_: unknown, __: unknown, ctx: Ctx) => (ctx.viewer ? {} : null), + myOrganizations: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.auth.organizations(actorOf(ctx)), mySessions: async (_: unknown, __: unknown, ctx: Ctx) => { const sessions = await ctx.services.auth.sessions(actorOf(ctx)); return sessions.map((s) => ({ ...s, current: s.id === ctx.viewer!.sessionId, createdAt: new Date(s.createdAt), lastSeenAt: new Date(s.lastSeenAt), expiresAt: new Date(s.expiresAt), deviceId: s.device?.deviceId ?? null, deviceInfo: s.device?.info ?? null })); @@ -5512,6 +5552,11 @@ export const authController = withErrorCodes({ const r = await ctx.services.auth.login({ ...input, ip: ctx.ip, userAgent: ctx.userAgent, device: deviceOf(device), tlsFingerprint: ctx.tlsFingerprint }); return { ...r, accessToken: r.token, accessExpiresAt: r.expiresAt }; }, + switchOrganization: async (_: unknown, a: { orgCode: string; device?: DeviceInput | null }, ctx: Ctx) => { + if (!ctx.token) throw unauthenticated(); + const r = await ctx.services.auth.switchOrganization({ token: ctx.token, orgCode: a.orgCode, ip: ctx.ip, userAgent: ctx.userAgent, device: deviceOf(a.device), tlsFingerprint: ctx.tlsFingerprint }); + return { ...r, accessToken: r.token, accessExpiresAt: r.expiresAt }; + }, refreshSession: (_: unknown, a: { refreshToken: string; device?: DeviceInput | null }, ctx: Ctx) => ctx.services.auth.refresh({ refreshToken: a.refreshToken, ip: ctx.ip, userAgent: ctx.userAgent, device: deviceOf(a.device), tlsFingerprint: ctx.tlsFingerprint }), logout: async (_: unknown, a: { refreshToken?: string | null }, ctx: Ctx) => { @@ -6434,6 +6479,7 @@ export const ERROR_CODES = [ "PERIOD_LOCKED", "CREDIT_LIMIT_EXCEEDED", // auth-kit's own codes, passed through "INVALID_CREDENTIALS", "ACCOUNT_DISABLED", "INVITE_PENDING", "ORG_REQUIRED", "RATE_LIMITED", "WEAK_PASSWORD", "INVALID_TOKEN", + "ORG_UNAVAILABLE", // partner-lab imports (docs/18-imports.md) "UNSUPPORTED_REPORT", "REPORT_ALREADY_APPLIED", "BOOKING_PATIENT_MISMATCH", "FILE_TOO_LARGE", ] as const; @@ -6452,7 +6498,13 @@ export const badInput = (message: string) => apiError("BAD_USER_INPUT", message, /** Domain errors from the kits → GraphQL errors with codes. Anything unknown is re-thrown (and masked by Yoga). */ export function toGraphQLError(e: unknown): unknown { if (e instanceof GraphQLError) return e; - if (e instanceof AuthError) return apiError(e.code as ErrorCode, e.message, e.statusCode, e.details.retryAfterMs ? { retryAfterMs: e.details.retryAfterMs } : {}); + if (e instanceof AuthError) { + const extra: Record = {}; + if (e.details.retryAfterMs) extra.retryAfterMs = e.details.retryAfterMs; + // ORG_REQUIRED: the labs whose account matched the password, for a pick list (ids stay on the server) + if (e.details.organizations?.length) extra.organizations = e.details.organizations.map((o) => ({ code: o.code, name: o.name })); + return apiError(e.code as ErrorCode, e.message, e.statusCode, extra); + } if (e instanceof ForbiddenError) return apiError("FORBIDDEN", e.message, 403); if (e instanceof UnsupportedReportError) return apiError("UNSUPPORTED_REPORT", e.message, 422); if (e instanceof FileTooLargeError) return apiError("FILE_TOO_LARGE", e.message, 413, { maxBytes: e.maxBytes }); @@ -7474,9 +7526,23 @@ export const authTypeDefs = /* GraphQL */ ` expiresAt: DateTime! @deprecated(reason: "Use accessExpiresAt.") } + """ + A lab the signed-in person can work in: they have an active account there with the same email. + Also listed in ORG_REQUIRED errors (extensions.organizations, without current) after a password matched. + """ + type OrganizationChoice { + "The lab code: pass it to login (orgCode) or switchOrganization." + code: String! + name: String! + "The lab of this session." + current: Boolean! + } + extend type Query { "null when not signed in." viewer: Viewer + "Every lab the signed-in person can switch to (same email, active account), the current one included." + myOrganizations: [OrganizationChoice!]! mySessions: [Session!]! users: [User!]! user(id: ID!): User @@ -7498,6 +7564,12 @@ export const authTypeDefs = /* GraphQL */ ` INVALID_TOKEN when it is unknown, spent or expired: sign in again. """ refreshSession(refreshToken: String!, device: DeviceInput): LoginResult! + """ + Moves this session to the signed-in person's account in another lab (same email, active there), without + the password. This session ends; use the returned tokens. Send the same device as at login, or the + session ends (INVALID_TOKEN). ORG_UNAVAILABLE when there is no such account, or it is the current lab. + """ + switchOrganization(orgCode: String!, device: DeviceInput): LoginResult! "Ends this session: the bearer token (even an expired access token), or the refresh token when given." logout(refreshToken: String): Boolean! "Ends every other session of the signed-in user; returns how many." @@ -8723,7 +8795,7 @@ export async function buildServer(container: Container): Promise { + return (await this.auth.organizationsOf(actor.userId)).map((o) => ({ code: o.code, name: o.name, current: o.orgId === actor.orgId })); + } + + /** Moves the session to the person's account in the lab with this code (auth-kit checks it is theirs). */ + async switchOrganization(input: { token: string; orgCode: string; ip?: string; userAgent?: string; device?: DeviceSignals; tlsFingerprint?: string }) { + const org = await this.repos.orgs.findOne({ code: input.orgCode.trim().toUpperCase() }); + // an unknown code gets the same answer as a lab without an account, so codes cannot be probed + if (!org) throw new AuthError("ORG_UNAVAILABLE", "You can't switch to that organisation"); + const { orgCode: _c, ...rest } = input; + return this.auth.switchOrganization({ ...rest, orgId: org.id }); + } + /** Spends a refresh token: a new access token and the next refresh token (rotation). */ async refresh(input: { refreshToken: string; ip?: string; userAgent?: string; device?: DeviceSignals; tlsFingerprint?: string }) { const p = await this.auth.refresh(input); @@ -11521,6 +11607,26 @@ async function suite(db: Db, tag: string) { ok(!!same?.refreshToken, `[${tag}] refresh on the same device (new fingerprint) works`); ok(code(await gql(null, "mutation($r: String!, $d: DeviceInput) { refreshSession(refreshToken: $r, device: $d) { refreshToken } }", { r: same.refreshToken, d: { deviceId: "dev-laptop-9999-zzzzzz" } })) === "INVALID_TOKEN" && code(await gql(bound.accessToken, "{ organization { id } }")) === "UNAUTHENTICATED", `[${tag}] refresh from another device → INVALID_TOKEN and the session ends`); + // ---------------------------------------------------------------- one person in two labs: pick list and switching + for (const [org, branch] of [[a.org.id, a.branch.id], [other.org.id, other.branch.id]] as const) { + await db.rawRepos.users.create({ orgId: org, name: "Two Labs", email: "two@labs.test", passwordHash: await hashPassword(pw), status: "active", roles: [{ role: "front_desk", branchId: branch }] }); + } + const pick = await gql(null, 'mutation { login(input: { email: "two@labs.test", password: "Lab-Owner-2026!" }) { accessToken } }'); + const choices = (pick.errors?.[0]?.extensions as { organizations?: { code: string; name: string }[] } | undefined)?.organizations; + ok(code(pick) === "ORG_REQUIRED" && choices?.map((o) => `${o.code}:${o.name}`).join() === "APL:API Lab,OTH:Other Lab" && !JSON.stringify(pick).includes(a.org.id), `[${tag}] ORG_REQUIRED lists the labs to pick from (code + name, no ids)`); + ok(!(await gql(null, 'mutation { login(input: { email: "two@labs.test", password: "wrong-password" }) { accessToken } }')).errors?.[0]?.extensions?.hasOwnProperty("organizations"), `[${tag}] a wrong password lists no labs`); + const solo = await login("x@oth.test"); + const twoDev = { deviceId: "dev-two-labs-0001" }; + const inApl = (await gql(null, 'mutation($d: DeviceInput) { login(input: { email: "two@labs.test", password: "Lab-Owner-2026!", orgCode: "apl", device: $d }) { accessToken refreshToken } }', { d: twoDev })).data?.login; + const mine = (await gql(inApl.accessToken, "{ myOrganizations { code name current } }")).data?.myOrganizations; + ok(mine?.map((o: any) => `${o.code}:${o.current}`).join() === "APL:true,OTH:false", `[${tag}] myOrganizations: both labs, the current one marked`); + ok((await gql(solo, "{ myOrganizations { code } }")).data?.myOrganizations.length === 1, `[${tag}] myOrganizations: one lab for a one-lab person`); + const moved = (await gql(inApl.accessToken, 'mutation($d: DeviceInput) { switchOrganization(orgCode: "OTH", device: $d) { accessToken refreshToken user { email } } }', { d: twoDev })).data?.switchOrganization; + ok((await gql(moved?.accessToken, "{ organization { code } }")).data?.organization.code === "OTH" && code(await gql(inApl.accessToken, "{ organization { code } }")) === "UNAUTHENTICATED", `[${tag}] switchOrganization: new tokens work in the other lab, the old session ends`); + ok(code(await gql(moved.accessToken, 'mutation { switchOrganization(orgCode: "OTH") { accessToken } }')) === "ORG_UNAVAILABLE" && code(await gql(moved.accessToken, 'mutation { switchOrganization(orgCode: "NOPE") { accessToken } }')) === "ORG_UNAVAILABLE" && code(await gql(solo, 'mutation { switchOrganization(orgCode: "OTH") { accessToken } }')) === "ORG_UNAVAILABLE", `[${tag}] switch to the current lab, an unknown code or a lab without an account → ORG_UNAVAILABLE`); + ok(code(await gql(null, 'mutation { switchOrganization(orgCode: "APL") { accessToken } }')) === "UNAUTHENTICATED", `[${tag}] switchOrganization needs a session`); + ok(code(await gql(moved.accessToken, 'mutation { switchOrganization(orgCode: "APL", device: { deviceId: "dev-elsewhere-0009" }) { accessToken } }')) === "INVALID_TOKEN" && code(await gql(moved.accessToken, "{ organization { code } }")) === "UNAUTHENTICATED", `[${tag}] switch from another device → INVALID_TOKEN and the session ends`); + // ---------------------------------------------------------------- background documents (in-process queue here; BullMQ in [worker]) const otherAgain = await login("x@oth.test"); const owner2 = await login("owner@apl.test"); diff --git a/package-lock.json b/package-lock.json index 74b096b..c3c0cbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@graphql-tools/schema": "^10.1.5", - "@mgpl-diagnostics/auth-kit": "^1.4.0", + "@mgpl-diagnostics/auth-kit": "^1.5.0", "@mgpl-diagnostics/catalog-kit": "^1.0.0", "@mgpl-diagnostics/db-kit": "^2.1.1", "@mgpl-diagnostics/finance-kit": "^1.0.1", @@ -33,7 +33,7 @@ "sequelize": "^6.37.8" }, "devDependencies": { - "@mgpl-diagnostics/client": "^1.4.0", + "@mgpl-diagnostics/client": "^1.5.0", "@types/node": "^22.20.5", "embedded-postgres": "^18.4.0-beta.17", "ioredis-mock": "^8.13.1", @@ -1025,7 +1025,7 @@ } }, "node_modules/@mgpl-diagnostics/auth-kit": { - "version": "1.4.0", + "version": "1.5.0", "license": "SEE LICENSE IN LICENSE", "engines": { "node": ">=20" @@ -1044,7 +1044,7 @@ } }, "node_modules/@mgpl-diagnostics/client": { - "version": "1.4.0", + "version": "1.5.0", "dev": true, "license": "SEE LICENSE IN LICENSE", "engines": { diff --git a/package.json b/package.json index 7780dcf..f734460 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@graphql-tools/schema": "^10.1.5", - "@mgpl-diagnostics/auth-kit": "^1.4.0", + "@mgpl-diagnostics/auth-kit": "^1.5.0", "@mgpl-diagnostics/catalog-kit": "^1.0.0", "@mgpl-diagnostics/db-kit": "^2.1.1", "@mgpl-diagnostics/finance-kit": "^1.0.1", @@ -47,7 +47,7 @@ "sequelize": "^6.37.8" }, "devDependencies": { - "@mgpl-diagnostics/client": "^1.4.0", + "@mgpl-diagnostics/client": "^1.5.0", "@types/node": "^22.20.5", "embedded-postgres": "^18.4.0-beta.17", "ioredis-mock": "^8.13.1", diff --git a/schema.graphql b/schema.graphql index f7f043f..e093856 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1267,6 +1267,13 @@ type Mutation { Renders a stored invoice or a booking's reports in the background; poll job(id) until completed or failed. """ startDocumentJob(input: DocumentJobInput!): Job! + + """ + Moves this session to the signed-in person's account in another lab (same email, active there), without + the password. This session ends; use the returned tokens. Send the same device as at login, or the + session ends (INVALID_TOKEN). ORG_UNAVAILABLE when there is no such account, or it is the current lab. + """ + switchOrganization(device: DeviceInput, orgCode: String!): LoginResult! updateBranch(id: ID!, input: BranchPatch!): Branch! updateCustomer(id: ID!, input: CustomerPatch!): Customer! updateDoctor(id: ID!, input: DoctorPatch!): Doctor! @@ -1317,6 +1324,19 @@ type Organization { phone: String } +""" +A lab the signed-in person can work in: they have an active account there with the same email. +Also listed in ORG_REQUIRED errors (extensions.organizations, without current) after a password matched. +""" +type OrganizationChoice { + """The lab code: pass it to login (orgCode) or switchOrganization.""" + code: String! + + """The lab of this session.""" + current: Boolean! + name: String! +} + input OrganizationInput { address: AddressInput email: String @@ -1607,6 +1627,11 @@ type Query { ledgers: [Ledger!]! letterhead(format: DocumentFormat = pdf): Document! lockDates: [PeriodLock!]! + + """ + Every lab the signed-in person can switch to (same email, active account), the current one included. + """ + myOrganizations: [OrganizationChoice!]! mySessions: [Session!]! organization: Organization! outstandingReceivables(asOf: DateTime, branchId: ID): Outstanding! diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index e2fb3c8..4cccd59 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -8,6 +8,7 @@ import { deviceOf, type DeviceInput } from "../graphql/device"; export const authController = withErrorCodes({ Query: { viewer: (_: unknown, __: unknown, ctx: Ctx) => (ctx.viewer ? {} : null), + myOrganizations: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.auth.organizations(actorOf(ctx)), mySessions: async (_: unknown, __: unknown, ctx: Ctx) => { const sessions = await ctx.services.auth.sessions(actorOf(ctx)); return sessions.map((s) => ({ ...s, current: s.id === ctx.viewer!.sessionId, createdAt: new Date(s.createdAt), lastSeenAt: new Date(s.lastSeenAt), expiresAt: new Date(s.expiresAt), deviceId: s.device?.deviceId ?? null, deviceInfo: s.device?.info ?? null })); @@ -42,6 +43,11 @@ export const authController = withErrorCodes({ const r = await ctx.services.auth.login({ ...input, ip: ctx.ip, userAgent: ctx.userAgent, device: deviceOf(device), tlsFingerprint: ctx.tlsFingerprint }); return { ...r, accessToken: r.token, accessExpiresAt: r.expiresAt }; }, + switchOrganization: async (_: unknown, a: { orgCode: string; device?: DeviceInput | null }, ctx: Ctx) => { + if (!ctx.token) throw unauthenticated(); + const r = await ctx.services.auth.switchOrganization({ token: ctx.token, orgCode: a.orgCode, ip: ctx.ip, userAgent: ctx.userAgent, device: deviceOf(a.device), tlsFingerprint: ctx.tlsFingerprint }); + return { ...r, accessToken: r.token, accessExpiresAt: r.expiresAt }; + }, refreshSession: (_: unknown, a: { refreshToken: string; device?: DeviceInput | null }, ctx: Ctx) => ctx.services.auth.refresh({ refreshToken: a.refreshToken, ip: ctx.ip, userAgent: ctx.userAgent, device: deviceOf(a.device), tlsFingerprint: ctx.tlsFingerprint }), logout: async (_: unknown, a: { refreshToken?: string | null }, ctx: Ctx) => { diff --git a/src/graphql/errors.ts b/src/graphql/errors.ts index 98b1c5d..9ad4b16 100644 --- a/src/graphql/errors.ts +++ b/src/graphql/errors.ts @@ -15,6 +15,7 @@ export const ERROR_CODES = [ "PERIOD_LOCKED", "CREDIT_LIMIT_EXCEEDED", // auth-kit's own codes, passed through "INVALID_CREDENTIALS", "ACCOUNT_DISABLED", "INVITE_PENDING", "ORG_REQUIRED", "RATE_LIMITED", "WEAK_PASSWORD", "INVALID_TOKEN", + "ORG_UNAVAILABLE", // partner-lab imports (docs/18-imports.md) "UNSUPPORTED_REPORT", "REPORT_ALREADY_APPLIED", "BOOKING_PATIENT_MISMATCH", "FILE_TOO_LARGE", ] as const; @@ -33,7 +34,13 @@ export const badInput = (message: string) => apiError("BAD_USER_INPUT", message, /** Domain errors from the kits → GraphQL errors with codes. Anything unknown is re-thrown (and masked by Yoga). */ export function toGraphQLError(e: unknown): unknown { if (e instanceof GraphQLError) return e; - if (e instanceof AuthError) return apiError(e.code as ErrorCode, e.message, e.statusCode, e.details.retryAfterMs ? { retryAfterMs: e.details.retryAfterMs } : {}); + if (e instanceof AuthError) { + const extra: Record = {}; + if (e.details.retryAfterMs) extra.retryAfterMs = e.details.retryAfterMs; + // ORG_REQUIRED: the labs whose account matched the password, for a pick list (ids stay on the server) + if (e.details.organizations?.length) extra.organizations = e.details.organizations.map((o) => ({ code: o.code, name: o.name })); + return apiError(e.code as ErrorCode, e.message, e.statusCode, extra); + } if (e instanceof ForbiddenError) return apiError("FORBIDDEN", e.message, 403); if (e instanceof UnsupportedReportError) return apiError("UNSUPPORTED_REPORT", e.message, 422); if (e instanceof FileTooLargeError) return apiError("FILE_TOO_LARGE", e.message, 413, { maxBytes: e.maxBytes }); diff --git a/src/schema/auth.graphql.ts b/src/schema/auth.graphql.ts index 804afe4..ddc0657 100644 --- a/src/schema/auth.graphql.ts +++ b/src/schema/auth.graphql.ts @@ -77,9 +77,23 @@ export const authTypeDefs = /* GraphQL */ ` expiresAt: DateTime! @deprecated(reason: "Use accessExpiresAt.") } + """ + A lab the signed-in person can work in: they have an active account there with the same email. + Also listed in ORG_REQUIRED errors (extensions.organizations, without current) after a password matched. + """ + type OrganizationChoice { + "The lab code: pass it to login (orgCode) or switchOrganization." + code: String! + name: String! + "The lab of this session." + current: Boolean! + } + extend type Query { "null when not signed in." viewer: Viewer + "Every lab the signed-in person can switch to (same email, active account), the current one included." + myOrganizations: [OrganizationChoice!]! mySessions: [Session!]! users: [User!]! user(id: ID!): User @@ -101,6 +115,12 @@ export const authTypeDefs = /* GraphQL */ ` INVALID_TOKEN when it is unknown, spent or expired: sign in again. """ refreshSession(refreshToken: String!, device: DeviceInput): LoginResult! + """ + Moves this session to the signed-in person's account in another lab (same email, active there), without + the password. This session ends; use the returned tokens. Send the same device as at login, or the + session ends (INVALID_TOKEN). ORG_UNAVAILABLE when there is no such account, or it is the current lab. + """ + switchOrganization(orgCode: String!, device: DeviceInput): LoginResult! "Ends this session: the bearer token (even an expired access token), or the refresh token when given." logout(refreshToken: String): Boolean! "Ends every other session of the signed-in user; returns how many." diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index ec02a9d..7db1ade 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -1,4 +1,4 @@ -import { can, permissionMatches, PERMISSIONS, roleMap, SYSTEM_ROLES, type Auth, type DeviceSignals, type PublicUser } from "@mgpl-diagnostics/auth-kit"; +import { AuthError, can, permissionMatches, PERMISSIONS, roleMap, SYSTEM_ROLES, type Auth, type DeviceSignals, type PublicUser } from "@mgpl-diagnostics/auth-kit"; import { ConflictError, createUser, NotFoundError, ValidationError, type Id, type Repositories, type Role, type RoleAssignment, type User } from "@mgpl-diagnostics/db-kit"; import type { Actor } from "../graphql/context"; import type { JobQueue } from "../jobs/types"; @@ -25,6 +25,20 @@ export class AuthService { return this.auth.login({ ...input, orgCode: input.orgCode ?? undefined }); } + /** The labs the person can switch to (same email, active account), the current one marked. */ + async organizations(actor: Actor): Promise<{ code: string; name: string; current: boolean }[]> { + return (await this.auth.organizationsOf(actor.userId)).map((o) => ({ code: o.code, name: o.name, current: o.orgId === actor.orgId })); + } + + /** Moves the session to the person's account in the lab with this code (auth-kit checks it is theirs). */ + async switchOrganization(input: { token: string; orgCode: string; ip?: string; userAgent?: string; device?: DeviceSignals; tlsFingerprint?: string }) { + const org = await this.repos.orgs.findOne({ code: input.orgCode.trim().toUpperCase() }); + // an unknown code gets the same answer as a lab without an account, so codes cannot be probed + if (!org) throw new AuthError("ORG_UNAVAILABLE", "You can't switch to that organisation"); + const { orgCode: _c, ...rest } = input; + return this.auth.switchOrganization({ ...rest, orgId: org.id }); + } + /** Spends a refresh token: a new access token and the next refresh token (rotation). */ async refresh(input: { refreshToken: string; ip?: string; userAgent?: string; device?: DeviceSignals; tlsFingerprint?: string }) { const p = await this.auth.refresh(input); diff --git a/test/run.ts b/test/run.ts index aaa8b56..e9d9ef2 100644 --- a/test/run.ts +++ b/test/run.ts @@ -250,6 +250,26 @@ async function suite(db: Db, tag: string) { ok(!!same?.refreshToken, `[${tag}] refresh on the same device (new fingerprint) works`); ok(code(await gql(null, "mutation($r: String!, $d: DeviceInput) { refreshSession(refreshToken: $r, device: $d) { refreshToken } }", { r: same.refreshToken, d: { deviceId: "dev-laptop-9999-zzzzzz" } })) === "INVALID_TOKEN" && code(await gql(bound.accessToken, "{ organization { id } }")) === "UNAUTHENTICATED", `[${tag}] refresh from another device → INVALID_TOKEN and the session ends`); + // ---------------------------------------------------------------- one person in two labs: pick list and switching + for (const [org, branch] of [[a.org.id, a.branch.id], [other.org.id, other.branch.id]] as const) { + await db.rawRepos.users.create({ orgId: org, name: "Two Labs", email: "two@labs.test", passwordHash: await hashPassword(pw), status: "active", roles: [{ role: "front_desk", branchId: branch }] }); + } + const pick = await gql(null, 'mutation { login(input: { email: "two@labs.test", password: "Lab-Owner-2026!" }) { accessToken } }'); + const choices = (pick.errors?.[0]?.extensions as { organizations?: { code: string; name: string }[] } | undefined)?.organizations; + ok(code(pick) === "ORG_REQUIRED" && choices?.map((o) => `${o.code}:${o.name}`).join() === "APL:API Lab,OTH:Other Lab" && !JSON.stringify(pick).includes(a.org.id), `[${tag}] ORG_REQUIRED lists the labs to pick from (code + name, no ids)`); + ok(!(await gql(null, 'mutation { login(input: { email: "two@labs.test", password: "wrong-password" }) { accessToken } }')).errors?.[0]?.extensions?.hasOwnProperty("organizations"), `[${tag}] a wrong password lists no labs`); + const solo = await login("x@oth.test"); + const twoDev = { deviceId: "dev-two-labs-0001" }; + const inApl = (await gql(null, 'mutation($d: DeviceInput) { login(input: { email: "two@labs.test", password: "Lab-Owner-2026!", orgCode: "apl", device: $d }) { accessToken refreshToken } }', { d: twoDev })).data?.login; + const mine = (await gql(inApl.accessToken, "{ myOrganizations { code name current } }")).data?.myOrganizations; + ok(mine?.map((o: any) => `${o.code}:${o.current}`).join() === "APL:true,OTH:false", `[${tag}] myOrganizations: both labs, the current one marked`); + ok((await gql(solo, "{ myOrganizations { code } }")).data?.myOrganizations.length === 1, `[${tag}] myOrganizations: one lab for a one-lab person`); + const moved = (await gql(inApl.accessToken, 'mutation($d: DeviceInput) { switchOrganization(orgCode: "OTH", device: $d) { accessToken refreshToken user { email } } }', { d: twoDev })).data?.switchOrganization; + ok((await gql(moved?.accessToken, "{ organization { code } }")).data?.organization.code === "OTH" && code(await gql(inApl.accessToken, "{ organization { code } }")) === "UNAUTHENTICATED", `[${tag}] switchOrganization: new tokens work in the other lab, the old session ends`); + ok(code(await gql(moved.accessToken, 'mutation { switchOrganization(orgCode: "OTH") { accessToken } }')) === "ORG_UNAVAILABLE" && code(await gql(moved.accessToken, 'mutation { switchOrganization(orgCode: "NOPE") { accessToken } }')) === "ORG_UNAVAILABLE" && code(await gql(solo, 'mutation { switchOrganization(orgCode: "OTH") { accessToken } }')) === "ORG_UNAVAILABLE", `[${tag}] switch to the current lab, an unknown code or a lab without an account → ORG_UNAVAILABLE`); + ok(code(await gql(null, 'mutation { switchOrganization(orgCode: "APL") { accessToken } }')) === "UNAUTHENTICATED", `[${tag}] switchOrganization needs a session`); + ok(code(await gql(moved.accessToken, 'mutation { switchOrganization(orgCode: "APL", device: { deviceId: "dev-elsewhere-0009" }) { accessToken } }')) === "INVALID_TOKEN" && code(await gql(moved.accessToken, "{ organization { code } }")) === "UNAUTHENTICATED", `[${tag}] switch from another device → INVALID_TOKEN and the session ends`); + // ---------------------------------------------------------------- background documents (in-process queue here; BullMQ in [worker]) const otherAgain = await login("x@oth.test"); const owner2 = await login("owner@apl.test");