From ea2538f9a72f933e5e3129a4bd78f3abb492e1b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 18:13:39 +0000 Subject: [PATCH 1/3] feat(auth): pick a lab at sign-in and switch labs (myOrganizations, switchOrganization) - ORG_REQUIRED errors carry extensions.organizations ({ code, name }; only the labs whose account matched the password, no ids). - Query myOrganizations: every lab the signed-in person can work in, the current one marked. - Mutation switchOrganization(orgCode, device): moves the session to the person's account in another lab without the password (auth-kit 1.5.0). The old session ends; same device rule as refreshSession; audited in both labs. New error code ORG_UNAVAILABLE. - kits.json points auth-kit at its unreleased branch commit; package.json and the lockfile move to auth-kit 1.5.0 once it is published. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jBohk4otGVRjt3Y1cF1N2 --- docs/04-api-reference.md | 2 + docs/05-errors.md | 3 +- docs/06-auth-and-permissions.md | 11 +++ kits.json | 2 +- llms-full.txt | 114 ++++++++++++++++++++++++++++- schema.graphql | 25 +++++++ src/controllers/auth.controller.ts | 6 ++ src/graphql/errors.ts | 9 ++- src/schema/auth.graphql.ts | 20 +++++ src/services/auth.service.ts | 16 +++- test/run.ts | 20 +++++ 11 files changed, 220 insertions(+), 8 deletions(-) diff --git a/docs/04-api-reference.md b/docs/04-api-reference.md index 0044f40..21df1b3 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 8d0fbe0..34318f6 100644 --- a/docs/05-errors.md +++ b/docs/05-errors.md @@ -22,7 +22,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 96fe5aa..deedf6e 100644 --- a/kits.json +++ b/kits.json @@ -15,7 +15,7 @@ }, { "name": "auth-kit", - "ref": "v1.3.0" + "ref": "38cb34777afc366dc8c9818816ac2870228d2640" }, { "name": "db-kit", diff --git a/llms-full.txt b/llms-full.txt index 804f83d..e47057b 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -678,6 +678,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` | | @@ -697,6 +698,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 @@ -881,7 +883,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 | @@ -1010,6 +1013,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 @@ -2471,7 +2485,7 @@ flowchart TD }, { "name": "auth-kit", - "ref": "v1.3.0" + "ref": "38cb34777afc366dc8c9818816ac2870228d2640" }, { "name": "db-kit", @@ -3658,6 +3672,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! @@ -3708,6 +3729,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 @@ -3988,6 +4022,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! @@ -5075,6 +5114,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 })); @@ -5109,6 +5149,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) => { @@ -5881,6 +5926,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", ] as const; export type ErrorCode = (typeof ERROR_CODES)[number]; @@ -5897,7 +5943,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 PeriodLockedError) return apiError("PERIOD_LOCKED", e.message, 409); if (e instanceof CreditLimitError) return apiError("CREDIT_LIMIT_EXCEEDED", e.message, 409); @@ -6783,9 +6835,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 @@ -6807,6 +6873,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." @@ -7821,7 +7893,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); @@ -9777,6 +9863,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/schema.graphql b/schema.graphql index 157adc3..1cbf2df 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1038,6 +1038,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! @@ -1088,6 +1095,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 @@ -1368,6 +1388,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 c80354a..6c67241 100644 --- a/src/graphql/errors.ts +++ b/src/graphql/errors.ts @@ -14,6 +14,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", ] as const; export type ErrorCode = (typeof ERROR_CODES)[number]; @@ -30,7 +31,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 PeriodLockedError) return apiError("PERIOD_LOCKED", e.message, 409); if (e instanceof CreditLimitError) return apiError("CREDIT_LIMIT_EXCEEDED", e.message, 409); 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 66ddf45..9178585 100644 --- a/test/run.ts +++ b/test/run.ts @@ -247,6 +247,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"); From 03c618b64a166c5f6ae4e1cfd0325ea54f2d2dc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 07:29:26 +0000 Subject: [PATCH 2/3] chore(deps): auth-kit 1.5.0 (published) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jBohk4otGVRjt3Y1cF1N2 --- kits.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/kits.json b/kits.json index deedf6e..77e86a8 100644 --- a/kits.json +++ b/kits.json @@ -15,7 +15,7 @@ }, { "name": "auth-kit", - "ref": "38cb34777afc366dc8c9818816ac2870228d2640" + "ref": "v1.5.0" }, { "name": "db-kit", diff --git a/package-lock.json b/package-lock.json index 2c34e22..176392e 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.3.0", + "@mgpl-diagnostics/auth-kit": "^1.5.0", "@mgpl-diagnostics/catalog-kit": "^1.0.0", "@mgpl-diagnostics/db-kit": "^2.0.0", "@mgpl-diagnostics/finance-kit": "^1.0.1", @@ -1023,7 +1023,7 @@ } }, "node_modules/@mgpl-diagnostics/auth-kit": { - "version": "1.3.0", + "version": "1.5.0", "license": "SEE LICENSE IN LICENSE", "engines": { "node": ">=20" diff --git a/package.json b/package.json index 5a3610e..720e007 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@graphql-tools/schema": "^10.1.5", - "@mgpl-diagnostics/auth-kit": "^1.3.0", + "@mgpl-diagnostics/auth-kit": "^1.5.0", "@mgpl-diagnostics/catalog-kit": "^1.0.0", "@mgpl-diagnostics/db-kit": "^2.0.0", "@mgpl-diagnostics/finance-kit": "^1.0.1", From 09039a23616db99ecc165aecd03806c2373fde4f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 07:33:41 +0000 Subject: [PATCH 3/3] chore(deps): client 1.5.0 (published) Brings the myOrganizations and switchOrganization operations, so the client-operations check passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jBohk4otGVRjt3Y1cF1N2 --- kits.json | 2 +- llms-full.txt | 8 ++++---- package-lock.json | 6 ++---- package.json | 2 +- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/kits.json b/kits.json index 77e86a8..bc2ba86 100644 --- a/kits.json +++ b/kits.json @@ -31,7 +31,7 @@ }, { "name": "client", - "ref": "v1.2.0" + "ref": "v1.5.0" }, { "name": "invoice-kit", diff --git a/llms-full.txt b/llms-full.txt index e47057b..bd939fa 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2485,7 +2485,7 @@ flowchart TD }, { "name": "auth-kit", - "ref": "38cb34777afc366dc8c9818816ac2870228d2640" + "ref": "v1.5.0" }, { "name": "db-kit", @@ -2501,7 +2501,7 @@ flowchart TD }, { "name": "client", - "ref": "v1.2.0" + "ref": "v1.5.0" }, { "name": "invoice-kit", @@ -2589,7 +2589,7 @@ Key facts for agents: }, "dependencies": { "@graphql-tools/schema": "^10.1.5", - "@mgpl-diagnostics/auth-kit": "^1.3.0", + "@mgpl-diagnostics/auth-kit": "^1.5.0", "@mgpl-diagnostics/catalog-kit": "^1.0.0", "@mgpl-diagnostics/db-kit": "^2.0.0", "@mgpl-diagnostics/finance-kit": "^1.0.1", @@ -2611,7 +2611,7 @@ Key facts for agents: "sequelize": "^6.37.8" }, "devDependencies": { - "@mgpl-diagnostics/client": "^1.2.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/package-lock.json b/package-lock.json index 176392e..8cea2fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "sequelize": "^6.37.8" }, "devDependencies": { - "@mgpl-diagnostics/client": "^1.2.0", + "@mgpl-diagnostics/client": "^1.5.0", "@types/node": "^22.20.5", "embedded-postgres": "^18.4.0-beta.17", "ioredis-mock": "^8.13.1", @@ -1042,9 +1042,7 @@ } }, "node_modules/@mgpl-diagnostics/client": { - "version": "1.2.0", - "resolved": "https://npm.pkg.github.com/download/@mgpl-diagnostics/client/1.2.0/39a698b327d2bdc6bbb2a370358706364fe56f1c", - "integrity": "sha512-12S1r4U5/t57DB6Sj75xFrZ0XV130Tlmg/htoaoBKaD/lSWHwLyuHv4gOGWL6bK73sTr1/8CVn924osGJY9t5A==", + "version": "1.5.0", "dev": true, "license": "SEE LICENSE IN LICENSE", "engines": { diff --git a/package.json b/package.json index 720e007..6588d7e 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "sequelize": "^6.37.8" }, "devDependencies": { - "@mgpl-diagnostics/client": "^1.2.0", + "@mgpl-diagnostics/client": "^1.5.0", "@types/node": "^22.20.5", "embedded-postgres": "^18.4.0-beta.17", "ioredis-mock": "^8.13.1",