Skip to content

docs(usersrole): describe the real 401/403 shape and CORS preflight in the frozen contracts [JDWLABS-495] - #223

Merged
jdwillmsen merged 1 commit into
mainfrom
fix/JDWLABS-495-contract-refusal-shape
Sep 6, 2026
Merged

docs(usersrole): describe the real 401/403 shape and CORS preflight in the frozen contracts [JDWLABS-495]#223
jdwillmsen merged 1 commit into
mainfrom
fix/JDWLABS-495-contract-refusal-shape

Conversation

@jdwillmsen

Copy link
Copy Markdown
Member

Summary

The frozen identity-service and profile-service contracts documented a ContainerError JSON body on every 401 and 403. Measured against a booted usersrole on a real port, that claim only holds for 403:

  • 401 (CustomAuthenticationEntryPoint) is genuinely empty — Content-Length: 0, no Content-Type, for every Accept value. No token means the internal forward to /error (which sendError triggers) is refused again by the same filter chain, so BasicErrorController never runs.
  • 403 (CustomAccessDeniedHandler) is not empty. The caller here already holds a verified token — only @PreAuthorize refused it — so that same token authenticates the forwarded /error dispatch, and BasicErrorController renders Boot's standard error body (timestamp, status, error, path — no message, since server.error.include-message is never). This matches the existing ContainerError schema exactly.

This is a correction to the initial ticket premise (which described both as empty) — verified end-to-end against the real, running application before writing anything down, per this repo's debugging discipline.

  • Both frozen specs now describe Unauthorized (401) as empty-body, no-Content-Type, header-only; Forbidden (403) keeps ContainerError with a description explaining why it differs from 401.
  • docs/contracts/README.md gains a CORS preflight section transcribing SecurityConfig.corsConfigurationSource's CorsConfiguration (origins, methods, headers, credentials, max-age) and stating preflights bypass authentication entirely (CorsFilter runs ahead of the JWT filter).
  • FilterChainContractParityTests extended with three new tests driving real HTTP calls against the booted app: the 401 empty-body/no-Content-Type shape, the 403 real container-error body, and a CORS preflight to an authenticated path answering 200 with no token. The 403 test mints a token for a principal with no authorities via a mocked UserRepository/RoleRepository (this suite boots no database schema to authenticate one against for real).
  • libs/backend/shared/auth/README.md's inaccuracy note is corrected rather than blindly removed: the Go library's WriteForbidden sends no body for 403, which the verification above shows no longer matches the JVM. Flagged as a real, separate gap for its own follow-up change — not fixed here, since changing Go production behavior is out of scope for a contract-documentation PR.
  • No changes needed to served-document-deviations.json or check-contract-drift.mjs — the drift checker pins error status codes, never body shape, so the pinned error sets still pass unchanged (verified via npx nx run usersrole:check-contracts).

Test plan

  • npx nx run usersrole:lint — green
  • npx nx run-many -t lint test check-contracts -p usersrole — green (full test suite, including the 3 new/extended tests)
  • npx nx format:check --all — green
  • New tests: theAuthenticationEntryPointAnswersWithNoBodyAndNoContentType, theAccessDeniedHandlerAnswersWithTheContainerErrorBody, aPreflightToAnAuthenticatedPathAnswers200WithNoToken

🤖 Generated with Claude Code

https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY

…n the frozen contracts

The frozen identity-service and profile-service contracts documented a
ContainerError JSON body on every 401 and 403. Measured against a booted
usersrole, that only holds for 403: CustomAuthenticationEntryPoint's 401 is
genuinely empty (no token means the internal /error forward is refused again
before BasicErrorController runs), while CustomAccessDeniedHandler's 403
keeps its verified token across that same forward and gets a real Boot error
body. Corrects Unauthorized to empty/no-Content-Type in both specs, refines
Forbidden's description to explain why it differs, and adds a CORS preflight
section to the contracts README transcribing SecurityConfig's
CorsConfiguration and noting preflights bypass authentication entirely.

FilterChainContractParityTests now drives real HTTP calls against the booted
app to pin both shapes plus the preflight bypass, using a mocked
UserRepository/RoleRepository to authenticate a token-holding-but-unprivileged
principal without a database schema. The Go shared auth library's README is
corrected rather than blindly cleared: its WriteForbidden sends no body for
403, which the verification above shows no longer matches the JVM — flagged
as a real, separate gap for its own change, not fixed here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY
@jdwillmsen
jdwillmsen merged commit c9e5409 into main Sep 6, 2026
22 checks passed
@jdwillmsen
jdwillmsen deleted the fix/JDWLABS-495-contract-refusal-shape branch September 6, 2026 04:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant