Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/04-api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | |
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/05-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
11 changes: 11 additions & 0 deletions docs/06-auth-and-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions kits.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
},
{
"name": "auth-kit",
"ref": "v1.4.0"
"ref": "v1.5.0"
},
{
"name": "db-kit",
Expand All @@ -35,7 +35,7 @@
},
{
"name": "client",
"ref": "v1.4.0"
"ref": "v1.5.0"
},
{
"name": "invoice-kit",
Expand Down
120 changes: 113 additions & 7 deletions llms-full.txt

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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!
Expand Down
6 changes: 6 additions & 0 deletions src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down Expand Up @@ -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) => {
Expand Down
9 changes: 8 additions & 1 deletion src/graphql/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, unknown> = {};
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 });
Expand Down
20 changes: 20 additions & 0 deletions src/schema/auth.graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."
Expand Down
16 changes: 15 additions & 1 deletion src/services/auth.service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions test/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading