From d2f47e3bb5fd9c02b8ed75c9d7e7565236304b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:51:32 +0200 Subject: [PATCH] fix(auth): the refusal code joins the published catalogue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two structural guards caught the new gate. The error-code catalogue is derived from the route tree on every run and fails in both directions, so a code the server emits and does not publish is a client that cannot branch on it; the session-surface guard freezes the short list of files under `src/app` that resolve a caller without `requireAuth`, and the register route now belongs on it — `requireAuth` is the wrong gate there twice over, since it would throw at the anonymous caller the route exists for and this route wants the ABSENCE of a session. The code is `auth.already_authenticated` rather than the bare `already_authenticated`: the catalogue's own header rules that new codes are dot.case, and it sits beside `auth.mfa.code_invalid` in the same family. The OpenAPI document renders the catalogue into the ErrorEnvelope description, so it is regenerated. --- docs/api/openapi.yaml | 26 +++++++++---------- e2e/invite-registration.spec.ts | 2 +- src/__tests__/session-surface-guard.test.ts | 6 +++++ .../api/auth/register/__tests__/route.test.ts | 2 +- src/app/api/auth/register/route.ts | 2 +- src/lib/openapi/error-codes.ts | 1 + 6 files changed, 23 insertions(+), 16 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6ba15e063..36f4dfe69 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -29943,19 +29943,19 @@ components: carry no code at all. ai_provider — ai_provider.invalid, ai_provider.no_fields; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue; auth — auth.admin.required, - auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, - auth.scope.insufficient, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — - backup.payload.undecryptable, backup.section.missing; coach — coach.conversation.invalidTitle, - coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, - coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, - coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — - coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — - cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, - cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, - cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — - cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; - documents — documents.chat.conversationNotFound, documents.chat.invalid, - documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, + auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, + auth.refresh.reuse, auth.refresh.revoked, auth.scope.insufficient, auth.stepup.required, + auth.token.expired, auth.token.invalid; backup — backup.payload.undecryptable, backup.section.missing; + coach — coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, + coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, + coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, + coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; + consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, + cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, + cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; + cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — + disable-coach.body.invalid_json; documents — documents.chat.conversationNotFound, + documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, diff --git a/e2e/invite-registration.spec.ts b/e2e/invite-registration.spec.ts index b37b19dea..aefaeb362 100644 --- a/e2e/invite-registration.spec.ts +++ b/e2e/invite-registration.spec.ts @@ -113,6 +113,6 @@ test.describe("invitation links", () => { }); expect(res.status()).toBe(409); const body = (await res.json()) as { meta?: { errorCode?: string } }; - expect(body.meta?.errorCode).toBe("already_authenticated"); + expect(body.meta?.errorCode).toBe("auth.already_authenticated"); }); }); diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index 855b7c650..324af37db 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -79,6 +79,12 @@ describe("S1 — the direct session-resolution set is frozen", () => { // the freshness comparison, which `requireAuth` does not project. "app/api/auth/native/complete/route.ts": "reads createdAt for the handoff freshness check", + // Anonymous self-registration. Probes for an already-valid session so an + // invitation opened from inside one is refused instead of minting a second + // account over the caller's own cookie. `requireAuth` is the wrong gate + // here twice over: it would throw 401 at the anonymous caller the route + // exists for, and this route wants the ABSENCE of a session. + "app/api/auth/register/route.ts": "refuses to register over a live session", // Anonymous IdP callback. Probes for an already-valid session so a // replayed callback redirects instead of minting a second identity. "app/api/auth/oidc/callback/route.ts": diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts index 088f1d040..d25cca103 100644 --- a/src/app/api/auth/register/__tests__/route.test.ts +++ b/src/app/api/auth/register/__tests__/route.test.ts @@ -340,7 +340,7 @@ describe("POST /api/auth/register — refuses a live session (v1.38.19)", () => meta?: { errorCode?: string }; }; expect(body.data).toBeNull(); - expect(body.meta?.errorCode).toBe("already_authenticated"); + expect(body.meta?.errorCode).toBe("auth.already_authenticated"); expect(body.error).toMatch(/sign/i); }); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index c8b8ca48f..d3221afbc 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -60,7 +60,7 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError( "Already signed in — sign out first to use an invitation", 409, - { errorCode: "already_authenticated" }, + { errorCode: "auth.already_authenticated" }, ); } diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 98fede930..ade5c0dec 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -46,6 +46,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = ], auth: [ "auth.admin.required", + "auth.already_authenticated", "auth.mfa.code_invalid", "auth.missing", "auth.refresh.invalid",