Merge branch 'fix/v1393-review-security' into release/v1.39.3

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 00:59:23 +02:00
commit 3cbfe7f7f9
76 files changed
+2751 -409

No files matched your search

+151 -51
View File
@@ -864,7 +864,11 @@ paths:
`selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and
there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape
is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or
Bearer; shared `export:<userId>` rate bucket (10/h). Strict validation: unknown keys 422."
Bearer; shared `export:<userId>` rate bucket (10/h). Strict validation: unknown keys 422. A browser session
needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes
(with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with
`meta.methods` naming the proofs the account can give. When a manager exports a record they manage, it is their
own session that is asked. A Bearer caller is not asked for more than its token."
requestBody:
required: true
content:
@@ -1489,7 +1493,10 @@ paths:
Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the
window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report
selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count`
(≤200) / `_offset`."
(≤200) / `_offset`. A browser session needs a fresh proof as well as a live one: a sign-in or `POST
/api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it
the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A token,
including a `fhir:read` one, is not asked for more."
parameters:
- in: query
name: _count
@@ -1524,7 +1531,13 @@ paths:
notes ride their exported column. Shares the `export:<userId>` bucket with the encrypted variant (10 per hour)
and audits every attempt as `export.download`. Cookie or Bearer auth; the caller is always resolved as
themselves, so this download cannot be delegated to a shared record. Not module-gated: a disabled module's rows
are still exported, because this is the account's own data leaving on request."
are still exported, because this is the account's own data leaving on request. `type=all`, the default, is the
whole record and asks for a fresh proof like the full backup; the single-type exports do not. A browser session
needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes
(with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with
`meta.methods` naming the proofs the account can give. A Bearer caller asking for `type=all` presents an
`X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a second factor) and
receives 401 `auth.stepup.required` without it."
parameters:
- in: query
name: format
@@ -1852,6 +1865,15 @@ paths:
200 with `data: null, error: null` and `meta.mfaRequired: true` plus a single-use `meta.mfaTicket` and the
`meta.methods` list. The client must POST the ticket + a code to `/api/auth/mfa/verify` to obtain the token
bundle. Accounts without MFA are unchanged.
Two limits apply. Per source address, five attempts in fifteen minutes. Per account, failed passwords from a
place the account has not signed in from before make the next attempt wait: five failures a day are free, then
each one doubles the wait, from thirty seconds up to at most fifteen minutes. A place the account has signed in
from is not counted and not held back: a remembered browser (trusted-device cookie), a phone that sends the
`X-Device-Id` a login of this account was issued to, or an address a session or device login of this account
came from in the last thirty days. Both answer 429 with `Retry-After`. An identifier that names no account is
limited the same way, so a 429 says nothing about whether it exists.
requestBody:
required: true
content:
@@ -1871,7 +1893,9 @@ paths:
"403":
description: "Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in
through SSO instead. The one exception is a browser the SSO callback sent back with a pending link to
confirm an existing account: it may sign in with that one account's password, which links the account."
confirm an existing account: it may sign in with that one account's password, which links the account. A
sign-in to any other account on that browser is refused before its password is checked, so the answer is the
same whether the password was right or wrong."
content:
application/json:
schema:
@@ -2172,14 +2196,18 @@ paths:
/api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused.
Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a
password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a
second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST
/api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey /
security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401
`auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw
on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the
step-up mint.
Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a
password-proved one is enough on an account without a second factor, and on an account that already has one the
elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor,
the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes;
otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey
assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor,
only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key
or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a
remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with
`meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed`
and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once
spent), the same budget as the step-up mint.
requestBody:
required: false
content:
@@ -2316,14 +2344,18 @@ paths:
refused.
Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a
password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a
second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST
/api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey /
security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401
`auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw
on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the
step-up mint.
Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a
password-proved one is enough on an account without a second factor, and on an account that already has one the
elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor,
the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes;
otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey
assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor,
only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key
or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a
remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with
`meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed`
and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once
spent), the same budget as the step-up mint.
requestBody:
required: false
content:
@@ -2600,7 +2632,10 @@ paths:
(the OAuth connection, so it cannot mint a new access token), every programmatic token (connector, measurement
and document tokens), and every clinician share link. Share links are revoked by default because a link made by
whoever held a lost session or device would otherwise keep opening the record; send `keepShareLinks=1` to keep
them. A Bearer caller keeps its own device login."
them. Record-sharing invitations this account offered that nobody has accepted yet are withdrawn too, because an
invitation sent from a stolen session would otherwise wait for the thief's own account. Accepted grants are not
ended: the response lists them as `grantsKept` so the client can show who can still read the record and offer to
end each one. A Bearer caller keeps its own device login."
parameters:
- in: query
name: keepShareLinks
@@ -3455,7 +3490,9 @@ paths:
**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a
password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options
issued.
issued. On an account that already has a second factor a password is refused before it is checked, with 422
`auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone
would sign in without the second factor from then on.
Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the
@@ -3502,7 +3539,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"422":
description: "`auth.reproof.too_weak`: a password on an account that already has a second factor. `meta.methods` names
the proofs that would be accepted. Refused before the password is checked, so it spends no attempt."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
/api/auth/passkey/register-verify:
post:
@@ -19367,7 +19410,12 @@ paths:
(`requireFreshMfaIfEnrolled`, cookie-only): an enrolled account without a fresh factor gets 401
`auth.stepup.required`, and a Bearer caller gets 403 `sharing.invite.manage_browser_only` before anything else
happens, because the gate it would hit resolves through the session cookie and would otherwise answer 'not
authenticated' to a caller it had just authenticated."
authenticated' to a caller it had just authenticated. Every invitation, at any level, also needs a fresh proof:
a browser session must have signed in or used `POST /api/auth/reproof` within the last five minutes (with the
second factor, on an account that has one), or it gets 401 `auth.reproof.required` with `meta.methods`; a Bearer
caller presents an `X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a
second factor) and gets 401 `auth.stepup.required` without it. `DELETE /api/auth/me/sessions` withdraws the
invitations nobody has accepted yet."
requestBody:
required: true
content:
@@ -19382,8 +19430,10 @@ paths:
schema:
$ref: "#/components/schemas/AccountGrantEnvelope"
"401":
description: "Offering MANAGE from an MFA-enrolled account without a recent second-factor proof (`meta.errorCode:
auth.stepup.required`). Re-prove the factor and retry; READ and WRITE invitations are never gated."
description: "No recent proof. A browser session that has not signed in or re-proved within five minutes gets
`meta.errorCode: auth.reproof.required` with `meta.methods`; re-prove at POST /api/auth/reproof and retry. A
Bearer caller without a valid `X-Step-Up` elevation gets `auth.stepup.required`, as does offering MANAGE
from an MFA-enrolled browser session without a recent second-factor proof."
content:
application/json:
schema:
@@ -22971,8 +23021,12 @@ components:
a second factor in the last five minutes needs nothing more, otherwise this field must carry the current
password. Without an accepted proof the address is not changed and comes back in `rejectedFields` as
`reproof_required` or `reproof_failed` (or, when the address was the only field sent, 401
`auth.reproof.required` / `auth.reproof.failed`). Attempts draw on the account's shared re-proof budget of
five per fifteen minutes. Never stored."
`auth.reproof.required` / `auth.reproof.failed`). On an account with a second factor the password is not
accepted and not checked: only a second factor or passkey completed in the last five minutes counts (at
sign-in or at POST /api/auth/reproof), and without one the address comes back as `second_factor_required`
(or 401 `profile.update.emailSecondFactorRequired` when it was the only field). A token cannot change the
address of such an account. Attempts draw on the account's shared re-proof budget of five per fifteen
minutes. Never stored."
type: string
maxLength: 512
description: Partial profile update. Every field is optional and an omitted field is left untouched. An explicit null
@@ -31482,29 +31536,29 @@ components:
onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited,
onboarding.tour.invalid; personal_records — personal_records.invalid_query; practitioner —
practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited,
profile.update.invalidBody, profile.update.nothingSaved; record_write — record_write.rate_limited;
report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape,
report-selection.leaves.unknown; route — route.retired; share-link — share-link.document.decryptFailed,
share-link.document.notFound, share-link.document.rateLimited, share-link.documents.invalid,
share-link.report.notFound, share-link.report.rateLimited, share-link.selection.forbidden_leaf,
share-link.selection.unknown_leaf; sharing — sharing.accept.expired, sharing.accept.not_found,
sharing.accept.not_pending, sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate,
sharing.invite.invalid, sharing.invite.invalid_scope, sharing.invite.manage_browser_only,
sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended,
sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found,
sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority —
source-priority.body.invalid_json, source-priority.body.invalid_shape; tokens —
tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached; vaccination —
vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid,
vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout —
workout.batch.invalid, workout.batch.payload_too_large, workout.batch.too_large; (unprefixed) —
about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at,
json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns,
modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only,
private_origin_not_approved, private_origin_not_grantable, rate_limited_self, too_many_rows,
unreadable_json, upstream_invalid_json, vapid_not_configured"
profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved;
record_write — record_write.rate_limited; report-selection — report-selection.body.invalid_json,
report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; share-link
— share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited,
share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited,
share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing —
sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked,
sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope,
sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted,
sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended,
sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
source-priority.body.invalid_shape; tokens — tokens.documents.ceiling_reached,
tokens.measurements.ceiling_reached; vaccination — vaccination.booster-invalid,
vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.encounter-not-found,
vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict,
dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict,
invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict,
missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured,
notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable,
rate_limited_self, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured"
type: string
capability:
description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`,
@@ -39004,11 +39058,57 @@ components:
minimum: -9007199254740991
maximum: 9007199254740991
description: Clinician share links revoked; 0 when `keepShareLinks=1` was sent.
pendingInvitesRevoked:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: "Record-sharing invitations this account offered that nobody had accepted yet, now withdrawn (`revokedBy:
GRANTOR`)."
grantsKept:
type: array
items:
type: object
properties:
id:
type: string
description: The grant id, for DELETE /api/account/grants/{id}.
account:
type: object
properties:
id:
type: string
username:
type: string
displayName:
anyOf:
- type: string
- type: "null"
required:
- id
- username
- displayName
additionalProperties: false
access:
type: string
enum:
- READ
- WRITE
- MANAGE
required:
- id
- account
- access
additionalProperties: false
description: Accepted grants this account gave that are still live. Signing out does not end them, because each is a
person the owner chose and ending one silently could cut off a carer; they are listed so a client can show
who can still read the record and offer to end each with DELETE /api/account/grants/{id}.
required:
- sessionsRevoked
- accessTokensRevoked
- connectorsRevoked
- shareLinksRevoked
- pendingInvitesRevoked
- grantsKept
additionalProperties: false
SessionRevokeEnvelope:
type: object