Skip to content

Correct the OTP request contract for authenticated WebView entry - #20

Closed
Aalv3 wants to merge 2 commits into
fix/auth-failure-classification-20260830from
fix/native-otp-contract-20260909
Closed

Aalv3 wants to merge 2 commits into
fix/auth-failure-classification-20260830from
fix/native-otp-contract-20260909

Conversation

@Aalv3

@Aalv3 Aalv3 commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Why

PR #19's device validation failed at step A. The request ledger showed, twice:

settled  PUT:/notifications/read        success
started  POST:/user-api-key/otp
settled  POST:/user-api-key/otp         failure  4xx   (~140ms)

Root cause, from app/controllers/user_api_keys_controller.rb:

def require_params_otp
  %i[public_key auth_redirect application_name].each { |p| params.require(p) }
end

application_name is required and was omitted, so the request was rejected as ActionController::ParameterMissing before any OTP was minted.

A second contract issue was found in the same read: create_otp uses respond_to with both an HTML and a JSON branch, and site.jsonApi sends no Accept header. Without an explicit .json, the server takes format.html and answers 302 to the custom scheme, which fetch cannot follow.

Changes

js/webViewSession.js

  • OTP_ENDPOINT is /user-api-key/otp.json
  • The request body is public_key, auth_redirect, application_name, paddingapplication_name reuses siteManager.deviceName, the same identity the authorization flow registers
  • WEB_SESSION_STAGES + webSessionFailure(stage, cause) classify each failure point

js/Discourse.js, js/screens/WebViewScreenComponents/WebViewComponent.js

  • A bootstrap failure records {event, stage, outcome, category} through the existing profileDiagnostics allowlist, readable on device via yarn device:harness profile-diagnostics. destination_resume records the terminal success stage.

The dialog itself was working as designed; it was simply the only observable, which is why a 400 and a decrypt failure were indistinguishable on device.

Unchanged

RSA machinery (no second cryptographic implementation), User-Api-Key / User-Api-Client-Id headers, rate-limit buckets and cooldowns, the /session/otp/ confirmation form, the canonical-origin destination boundary, the bootstrap-scoped WebView navigation window, and the /renaissance/member-photo/ credential boundary. No new bridge endpoint. No runtime version change. JS-only.

Diagnostics contain no secrets

Only a fixed stage label and a coarse status class (429 / 4xx / 5xx / timeout / network_or_unknown). A test asserts that no failure carries the User API key, either RSA key, the encrypted blob, the OTP, the client id, or a URL.

Gates

  • format:check clean, lint clean
  • Jest 787/787 across 96 suites (32 in webViewSession.test.js)
  • verify:ota, verify:ios-auth, verify:release-readiness, validate:ota all PASS
  • Native Release build via CI on this PR

Also fixes a real-clock race in rateLimitResilience.test.js: Date.now() was evaluated twice in one assertion and could straddle a millisecond. It failed once in a full-suite run and passed in isolation.

Not done here

No OTA published. Basic is untouched and preserved as the second device fixture.

Aalv3 and others added 2 commits September 9, 2026 10:13
The device bootstrap failed at POST /user-api-key/otp with a 4xx.
UserApiKeysController#require_params_otp requires public_key,
auth_redirect and application_name; application_name was omitted, so the
request was rejected as ParameterMissing before any OTP was minted.

Two contract corrections:

- Request /user-api-key/otp.json. create_otp responds to both html and
  json, and site.jsonApi sends no Accept header, so without the
  extension the server answers format.html with a 302 to the custom
  scheme, which fetch cannot follow.
- Send application_name, reusing siteManager.deviceName so the OTP
  request carries the same identity the authorization flow registers
  rather than inventing a second one.

The failure was also undiagnosable from the device: every stage
collapsed into one bounded catch behind "Not available right now".
Bootstrap failures now record a coarse stage - otp_request,
otp_response_parse, otp_callback_parse, otp_decrypt, otp_validation,
webview_bootstrap, destination_resume - plus a status class, through the
existing profileDiagnostics allowlist. No bodies, keys, tokens, OTP
values or URLs are recorded.

Unchanged: the RSA machinery, the User-Api-Key headers and rate-limit
buckets, the confirmation form, the canonical-origin destination
boundary, and the bootstrap-scoped WebView navigation window.

Also fixes a real-clock race in rateLimitResilience: Date.now() was
evaluated twice in one assertion and could straddle a millisecond.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR
@Aalv3
Aalv3 changed the base branch from main to fix/auth-failure-classification-20260830 September 9, 2026 14:46
@Aalv3

Aalv3 commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #22. The OTP request contract in this PR was correct - the request succeeded on device and the confirmation form rendered - but Finish Login failed with "Missing, invalid or expired token", and the founder has since ruled the architecture wrong regardless: a member already authenticated in the native app must not perform a second web authentication to read a notification. Notification taps now resolve to native intents. History preserved in testing/native-auth-stale-identity/BACKLOG.md item 9; the OTA provenance tag ota-eb509d26-43af7b78 remains immutable.

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