diff --git a/.changeset/oidc-failure-cleanup.md b/.changeset/oidc-failure-cleanup.md new file mode 100644 index 0000000..9bb78a1 --- /dev/null +++ b/.changeset/oidc-failure-cleanup.md @@ -0,0 +1,5 @@ +--- +"@evidence-browser/api": patch +--- + +Harden OIDC verification for issue #164 by asserting failed callbacks clear the signed transaction cookie and documenting the Authentik/Dex sign-off path. diff --git a/README.md b/README.md index e19fba1..e2b1fee 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,10 @@ eb upload dist/evidence.zip --workspace ci-results --bundle-id "pr-42-run-1" ### Authentik OIDC setup -Manual sign-off path recorded against Authentik `2026.8.0`. +Manual sign-off path recorded for issue `#164` against Authentik `2026.8.0`. +The automated CI-compatible OIDC flow uses Dex in +`packages/web/e2e-oidc/oidc-flow.spec.ts`; Authentik remains the first target +IdP for operator setup and manual provider sign-off. 1. In Authentik, create a Property Mapping for the admin group claim if the default `groups` claim is not already suitable: - Scope name: `groups` @@ -164,6 +167,11 @@ Manual sign-off path recorded against Authentik `2026.8.0`. ``` 6. Verify that an `evidence-browser-admins` member lands in `/admin`, and that a user outside `OIDC_ALLOWED_GROUPS` is rejected with the safe login error. +The regression suite also covers the local-login off switch: with +`AUTH_LOCAL_ENABLED=false`, `POST /api/auth/login` returns `403`, the login page +hides username/password fields, OIDC can still issue the normal +`evidence_session` cookie, and API keys remain valid. + Upgrade note: migration v1 rebuilds the `users` table. Back up `evidence.db` before upgrading an existing instance. ### Storage diff --git a/docs/ACCEPTANCE_CRITERIA.md b/docs/ACCEPTANCE_CRITERIA.md index e117ef0..04378c7 100644 --- a/docs/ACCEPTANCE_CRITERIA.md +++ b/docs/ACCEPTANCE_CRITERIA.md @@ -311,7 +311,7 @@ fixture-unicode.zip - **Given** AUTH_BYPASS=false이고 OIDC provider가 설정됨 - **When** `/login` 페이지에서 로그인 버튼을 클릭한다 - **Then** OIDC provider의 인증 페이지로 리다이렉트되고, 인증 후 원래 페이지로 돌아온다 -- **검증**: Verified — `packages/web/e2e-oidc/oidc-flow.spec.ts`가 `docker-compose.test.yml`의 Dex provider로 authorization-code flow를 실행한다. +- **검증**: Verified for issue #164 — `packages/web/e2e-oidc/oidc-flow.spec.ts`가 `docker-compose.test.yml`의 Dex provider로 authorization-code flow를 실행한다. API route tests additionally verify `state`/`nonce` failure handling and transaction-cookie clearing. - **Evidence**: `.evidence/20260821-0216-feat-168-oidc-idp-stack-attempt1/` (PR #178 QA bundle; uploaded locally as `/w/default/b/20260821-0216-feat-168-oidc-idp-stack-attempt1`) - **참고**: 실제 Authentik sign-off 절차는 `README.md`의 "Authentik OIDC setup"에 기록됨. diff --git a/packages/api/src/routes/auth-oidc.test.ts b/packages/api/src/routes/auth-oidc.test.ts index 24d5cdc..628e67d 100644 --- a/packages/api/src/routes/auth-oidc.test.ts +++ b/packages/api/src/routes/auth-oidc.test.ts @@ -275,6 +275,7 @@ describe("OIDC auth routes", () => { expect(callback.status).toBe(302); expect(callback.headers.get("location")).toBe("/login?error=oidc_failed"); + expect(findSetCookie(callback.headers, "eb_oidc_tx")).toContain("Max-Age=0"); }); it("rejects a wrong or absent nonce", async () => { @@ -293,6 +294,7 @@ describe("OIDC auth routes", () => { ); expect(wrongNonce.status).toBe(302); expect(wrongNonce.headers.get("location")).toBe("/login?error=oidc_failed"); + expect(findSetCookie(wrongNonce.headers, "eb_oidc_tx")).toContain("Max-Age=0"); resetOidcDiscoveryCache(); mockIssuerFetch({ omitNonce: true }); @@ -302,6 +304,7 @@ describe("OIDC auth routes", () => { ); expect(absentNonce.status).toBe(302); expect(absentNonce.headers.get("location")).toBe("/login?error=oidc_failed"); + expect(findSetCookie(absentNonce.headers, "eb_oidc_tx")).toContain("Max-Age=0"); }); it("rejects an expired transaction cookie before token exchange", async () => { @@ -324,6 +327,7 @@ describe("OIDC auth routes", () => { expect(callback.status).toBe(302); expect(callback.headers.get("location")).toBe("/login?error=oidc_failed"); + expect(findSetCookie(callback.headers, "eb_oidc_tx")).toContain("Max-Age=0"); expect(fetchMock).not.toHaveBeenCalledWith(tokenEndpoint, expect.anything()); });