Skip to content

fix(auth): keep credentials out of authentication logs - #567

Open
Raghav-Bansal-15 wants to merge 92 commits into
risa-labs-inc:devfrom
Raghav-Bansal-15:fix/security-credential-logging
Open

Raghav-Bansal-15 wants to merge 92 commits into
risa-labs-inc:devfrom
Raghav-Bansal-15:fix/security-credential-logging

Conversation

@Raghav-Bansal-15

@Raghav-Bansal-15 Raghav-Bansal-15 commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Stops credentials from reaching authentication logs.

What changed: token and session masks now write [REDACTED] instead of showing part of the value. Deep-link and browser logs mask URLs and session ids. The passkey edge function keeps the original storage error for server-side diagnosis through authFailureDetails, but returns only a safe message to callers. A passkey-lookup failure still returns the same inert challenge as other pre-auth failures, so error paths do not reveal whether an account exists.

Why: partial masks still leak credential material into logs, and raw storage errors reached responses. Server logs keep what operators need, clients get nothing sensitive.

How it was tested: 174 Deno passkey tests pass. Focused Kotlin suites for the sanitizer and redaction pass. ktlint and detekt pass on the touched modules. Full CI runs on this head.

@kshivang

Copy link
Copy Markdown
Contributor

@claude review: Please review credential redaction, independent correlation IDs, passkey diagnostic allowlisting, and regression coverage on this standalone extraction.

@claude

claude Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 6m 42s —— View job


Review: credential redaction extraction

  • Gather context (diff vs. dev base commit, changed files)
  • Review credential redaction (LogSanitizer, bearer values, authFailureDetails)
  • Review independent correlation IDs
  • Review passkey diagnostic allowlisting (Deno functions)
  • Review regression coverage
  • Post review

The direction is right and the two new test files are the right shape (canary strings asserted absent from captured output, rather than expectation snapshots). Five things I'd want resolved before this merges, two of them because the change's own premise is contradicted elsewhere in the same diff.


1. maskUriParams does not cover the passkey URL's own sensitive params — email, challenge and credentialId are now logged in full

CrossDeviceBrowserManager.kt:100 and :152 mask the URL with maskUriParams, but the URL those two lines receive is built in DesktopPasskeyService.kt:74-80 and :158-163:

$baseUrl/passkey/auth/mobile?challenge=…&email=…&sessionId=…&credentialId=…&rpId=…

sensitiveUriParamNames (LogSanitizer.kt:317-331) lists sessionId/session_id but not email, challenge or credentialId. So after this change those two debug lines emit the user's email address in clear, the full base64url challenge, and the full credential id — the exact three values the Deno half of this same PR stopped logging (challenge.substring(0, 20) removed in challenge.ts:26 and database.ts:38/:240; findPasskeyByCredentialId's credential id removed at database.ts:446). The host already has maskEmail and maskCredentialId for two of them.

LogSanitizer.describeUri(url) is probably the right call here — scheme + host + path is all a "which page are we opening" line needs — otherwise extend sensitiveUriParamNames.

Fix this →

2. The "independent correlation IDs" are not in this extraction

grep -ri correlat over supabase/functions/passkey and over the Kotlin diff finds nothing. What actually happened is that identifiers were removed with no substitute: maskSessionId is now a constant [REDACTED] (so all four BossAppWithAuth.kt:75/87/99/108 lines are indistinguishable), and the Deno side deleted the session id outright (services/auth.ts:445, :461, :471, :570, challenge.ts:28 → hasSessionId: Boolean).

Consequence: in a cross-device or concurrent flow there is no key to join Passkey authentication completed to No metadata found for session, and on the server every interleaved request in one log stream is now anonymous. That's a real operational regression, and the PR body reads as though the mitigating half landed.

Minimal restoration that keeps the security property: correlationFor(value) = first 8 hex of SHA-256 over a per-process random salt plus the value — non-reversible, not linkable across installs or across log files, and it restores the join. Same idea server-side (sessionRef). If the correlation half is deliberately deferred to a follow-up, the description should say so.

3. authFailureDetails collapses nearly every failure to {failed: true}

utils/logging.ts:5-16 keeps code only when it is one of 7 hardcoded values, and status only when numeric. Note PostgrestError carries code/message/details/hint and no status, so:

  • every catch path logs literally {failed:true} — Exception storing challenge, Exception verifying challenge, Global error, Failed to verify caller token, Signature verification error, Exception in generateSupabaseAccessToken. Not even the exception class survives;
  • error-handler.ts:52 compounds it by replacing the thrown value with new Error("Unknown service failure") before logging. It is inert for responses today (both in-tree wrappers ignore the onError argument, and withGenericErrorHandler has no other consumer), but it discards the last remaining diagnostic;
  • operationally important, structurally non-secret codes are dropped: PGRST301 (JWT expired), PGRST202, 22P02, 40001, 57014, and the entire GoTrue vocabulary — otp_expired, invalid_credentials, over_request_rate_limit, and unexpected_failure, which tests/logging.test.ts:336 itself uses as a response code.

The allowlist reads as "the codes the surrounding code branches on", which is why 42P10/42703/PGRST204 are there. Allowlisting by shape rather than by value keeps the property you want (none of these vocabularies can carry credential material) without blinding the unexpected cases: code matching /^[A-Z0-9]{5}$/ (SQLSTATE), /^PGRST\d{3}$/, or /^[a-z][a-z0-9_]{2,40}$/ (GoTrue), plus name for exceptions. And when a code is present but rejected, emit code: "unlisted" so an operator knows there is more to find rather than reading the absence as "no code".

Fix this →

4. The log is closed; the response body carrying the same string is not

index.ts:129 still returns ctx.json({ error: err.message }, 500), one line below the console.error this PR redacted. services/auth.ts:140 still returns `Failed to store authentication result: ${storeResult.error}`, where storeResult.error is the Postgres error.message. If the threat model is "a database or HTTP failure can embed a row or a token", an HTTP body is a strictly more exposed sink than a function log an operator has to go and read. Outside this diff's literal lines, but its own premise argues for it — worth either fixing or recording as a deliberate deferral.

5. nameMarksSecret's special case reintroduces the map/free-text drift its own KDoc exists to prevent

LogSanitizer.kt:422-428 adds normalized.equals("session_id"), bypassing the word-split mechanism. sanitizeMap matches keys by substring, nameMarksSecret by whole normalized name, so:

name sanitizeMap sanitizeExceptionMessage / sanitizeStackTrace
sessionId, session_id, SESSION_ID, sessionid redacted redacted
crossDeviceSessionId, authSessionId redacted not redacted
session-id, session.id not redacted (substring fails on -/.) not redacted

crossDeviceSessionId is the actual variable name at DesktopPasskeyService.kt:154, so it is the spelling most likely to reach an exception message — and crash reports run through sanitizeExceptionMessage and egress. A pair match over the existing split covers every prefix and every separator with no special case and no new vocabulary entries:

val words = name.replace(camelCaseBoundary, "_").split('_', '-', '.')
return words.any { w -> sensitiveValueNames.any { w.equals(it, ignoreCase = true) } } ||
    words.zipWithNext().any { (a, b) -> a.equals("session", true) && b.equals("id", true) }

That also lets the sessionid/session_id entries come back out of sensitiveValueNames (:350-351), keeping that set the single shared vocabulary the KDoc at :333-338 promises.


Smaller points

maskToken as a constant removes the last signal from sanitizeMap's length rule. sanitizeMap:401 fires on any string ≥ 20 chars under any key, so a 20-char plugin id or file name now logs as [REDACTED] with no way to tell two different values apart. maskCredentialId:78 already shows this file's preferred compromise: [CREDENTIAL_ID:${length}chars]. A [REDACTED:34chars] form satisfies "no prefix, no suffix, no fragment" and keeps two log lines distinguishable. If a length is itself considered a leak here, say so in the KDoc, since maskCredentialId publishes one.

Dropping error = e is correct for this pipeline, but write down why, and note the class isn't closed. I checked: BossLogger.log hands entry.error straight to SLF4J (BossLogger.kt:447-463) and writeToFileAsync appends entry.error.message plus frames with no sanitizer at all (:517-531); entry.data is interpolated raw; and LogSanitizer.sanitizeMap has no production caller in the tree. So the local log file is protected only by discipline at ~550 other logger.*(… error = e) sites. Two asks: a one-line comment at the changed sites so the next person doesn't restore the throwable, and a follow-up issue to sanitize inside BossLogger (message, data via sanitizeMap, error via sanitizeExceptionMessage/sanitizeStackTrace) which would make call-site discipline unnecessary. The crash-report egress path is already safe — CrashReport.SanitizedLogEntry drops data and error outright — so this is specifically the log file and support bundle.

Separately, e.javaClass.simpleName alone throws away the cause chain, which is what actually diagnoses an openBrowserWithProcessBuilder failure. mapOf("errorType" to e.javaClass.simpleName, "error" to LogSanitizer.sanitizeExceptionMessage(e.message)) keeps the message through the sanitizer that exists for exactly this.

Import placement. import { authFailureDetails } from … was inserted above the module docblock in index.ts, utils/authorization.ts, utils/crypto.ts and utils/error-handler.ts, splitting each file's header from its file. Move it into the existing import group. utils/logging.ts also uses semicolons and double quotes where every neighbouring file uses neither, and deno lint is continue-on-error in edge-functions.yml, so nothing will flag it.

authFailureDetails(undefined) → {failed:false} now appears on success lines: database.ts:47 logs { found: true, error: {failed:false} }. Returning undefined for a nullish error keeps that line clean.

userId asymmetry. registration.ts:55, challenge.ts:30 and database.ts:511 still log a full user id, while the host's maskUserId truncates one to 4 chars. Consistent with "harmless identifiers remain intact", but the two halves now state opposite policies for the same value — one sentence in the PR body would make that a decision rather than an omission.

Regression coverage

CredentialRedactionTest is well-targeted, and I hand-traced all four spellings through camelCaseBoundary → nameMarksSecret → sensitiveUriParamNames; they all pass, including SESSION_ID (no lowercase, so no camel boundary, so the new whole-name equality is what catches it — worth a comment, because it's the non-obvious one). Gaps: nothing pins the two paths against prefixed names (crossDeviceSessionId), which is where point 5 bites and where a test added now would fail and document the gap; session-id/session.id are untested; and nothing covers the maskUriParams URL from point 1.

logging.test.ts is gated (edge-functions.yml runs deno test --allow-all with working-directory: supabase/functions/passkey) and the canary design is right. Two weaknesses: assert(output.includes("23505")) and ("409") are asserted against a console.error the test itself makes, so they exercise authFailureDetails in isolation rather than proving any production call site still emits a usable code — driving a storeChallenge failure with code: "23505" through the captured log would be the real version; and nothing pins the dropping of an unlisted code, which is the behaviour most likely to surprise an operator. assertEquals(replay.refreshToken, refreshToken) is a good touch: it proves the redaction did not reach the response.

I could not execute either suite here — ./gradlew and deno are both unavailable/denied in this sandbox — so the pass claims in the PR body are unverified by me; everything above is from reading and hand-tracing, and the flipped expectations in LogSanitizerTest all agree with the new implementation.
• branch fix/security-credential-logging

@kshivang

Copy link
Copy Markdown
Contributor

Addressed the concrete redaction gaps in reviewer commit 771278f85 (original implementation remains Raghav Bansal @Raghav-Bansal-15’s c3499c265).

  1. Added challenge/email/credentialId/credential_id to URI redaction and a realistic passkey URL regression.
  2. Corrected the description: no independent correlation IDs are implemented. Correlation is deliberately deferred; these diagnostics omit capabilities rather than derive identifiers from them.
  3. Expanded the explicit diagnostic-code allowlist with the reported SQLSTATE/PostgREST/GoTrue codes and emits unlisted for rejected codes. I did not adopt the proposed shape matcher or unrestricted exception name: arbitrary credential material can satisfy those shapes. A production storeChallenge failure now verifies both preserved and rejected codes, rather than a test-authored console call.
  4. Raw HTTP response errors are a pre-existing, separate response-sanitization concern, deliberately deferred from this logging PR. Concrete follow-up locations: global error response in index.ts and the failed completion-store response in services/auth.ts. This PR does not claim to sanitize HTTP responses.
  5. Session/id adjacent-word recognition now handles prefixed/camelCase/dash/dot names in maps, exception messages, and stack traces, with regressions. Added a comment explaining why CrossDeviceBrowserManager must not log raw throwables.

The process-wide BossLogger raw-throwable/data sink and broader response-sanitization audit remain separate follow-up work; this PR fixes the listed authentication call sites, not every host log sink. Harmless user identifiers remain unchanged by design.

Validation: passkey Deno suite 150/150 passed with mocked database calls; no live migrations. Kotlin tests and root ktlint/detekt remain queued behind the shared local build lock; hosted validation for the new head is pending. No repeated Claude review requested.

@kshivang

Copy link
Copy Markdown
Contributor

Validation handoff to @Raghav-Bansal-15: your change usefully removes bearer-token fragments and database error payloads from authentication diagnostics. Your original commit and credit are preserved; reviewer fixes are separately recorded in 771278f85 and formatting-only 2688753c1.

The concrete Claude redaction findings were repaired and covered by regressions (see 5651013587). Local validation passed 150 passkey Deno tests, 22 plugin-logging tests, 80 host sanitizer tests, and root detekt. Root ktlint found one reviewer-introduced wrapping error; 2688753c1 fixes it, but the focused quality rerun is still waiting for the shared build lock (/tmp/logging-567-quality-recheck.log).

I cannot merge without passing required CI on the current head 2688753c112eaf3c8842d1edbc23bf44245f9715. Final run https://github.com/risa-labs-inc/BossConsole/actions/runs/34736852870 was approved but remains queued. This is an infrastructure/validation blocker, not evidence of an author defect. No unresolved in-scope substantive code defect is asserted.

Next steps: complete the queued ktlint/detekt recheck and final-head Build & Test checks, investigate any actual failure, then refresh dev/mergeability and verify the now-merged #541 URI-boundary fix remains present alongside these redactions. Retain the 150 Deno tests and the Kotlin passkey URL/session-name regressions. Once green, restore ready status and merge into dev under the shared lock. Manual disposable-auth log checks remain recorded in the PR/inventory; no live migration is needed.

Converting this PR to draft under the requested bounded-review handoff policy until that validation is complete.

@kshivang
kshivang marked this pull request as draft September 13, 2026 04:05
@kshivang
kshivang force-pushed the dev branch 4 times, most recently from cd88490 to 5a2e818 Compare September 14, 2026 11:16
@Raghav-Bansal-15
Raghav-Bansal-15 force-pushed the fix/security-credential-logging branch from 2688753 to 8f28613 Compare September 14, 2026 16:11
@Raghav-Bansal-15 Raghav-Bansal-15 changed the title Remove complete credentials and token fragments from authentication logs fix(auth): keep credentials out of authentication logs Sep 14, 2026
@Raghav-Bansal-15
Raghav-Bansal-15 marked this pull request as ready for review September 14, 2026 16:11
@Raghav-Bansal-15

Copy link
Copy Markdown
Contributor Author

Rebased this PR and its reviewer fixes onto current dev, preserving authorship, and marked it ready for review. The current-head full fork build and test run is green.

The credential/URL redaction fixes are retained. This does not claim to sanitize every application log sink or HTTP error response.

Upstream CI still requires maintainer approval. I am following the remaining PR checks; nothing has been merged.

@kshivang

Copy link
Copy Markdown
Contributor

@Raghav-Bansal-15 Your revised authentication logging change preserves the useful full credential redaction, passkey URL coverage and safe diagnostic-code allowlist, with earlier contributor/reviewer authorship retained. I independently ran 161 Deno tests and 99 focused Kotlin/JUnit sanitizer tests, all passing. These include real production sanitizer/facade code and captured synthetic passkey diagnostics. The revised patch applies cleanly to freshly fetched dev 5dc82116e; no new correctness finding or code repair was needed in this sweep.

The latest applicable Claude findings were checked against the current code and previous handoff. The URL-field and session-name fixes remain present; the finite diagnostic allowlist remains preferable to accepting arbitrary credential-shaped strings. Correlation IDs and generic HTTP-error-body sanitization remain explicitly outside this change's claims. #691's browser URL call sites and #640's userinfo sanitizer are complementary, not duplicates.

I approved upstream run 34867321442 once. Upstream code-quality, shell, version and BOSS AI contracts checks pass; platform, compatibility and database checks are still incomplete at this checkpoint; fork CI and focused local tests do not substitute for those checks. Under the current bounded-review policy I am placing this in draft as a validation handoff, not for an author defect. To return it to ready: let current-head upstream checks finish, investigate any actual failures, then refresh dev integration and merge only after the required green checks. Do not repeat already resolved redaction findings or request another Claude review solely for the queue.

No application was launched and desktop/mobile ceremony log inspection was not performed. No live secrets or database mutations were used. The original rating and attribution are unchanged.

@kshivang
kshivang marked this pull request as draft September 15, 2026 01:43
@Raghav-Bansal-15
Raghav-Bansal-15 marked this pull request as ready for review September 15, 2026 06:26
@kshivang

Copy link
Copy Markdown
Contributor

@claude Please review the current head of #567 against dev, including contributor follow-ups and prior finding dispositions. This is the current morning batch review; do not repeat repaired findings. Focus on remaining correctness, credential exposure, regressions and focused test coverage. Earlier reviewer repairs remain separately attributed.

@claude

claude Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 6m 35s —— View job


Morning batch review: #567 current head (56ca7f8)

  • Isolate the PR's own commits from the merged-in dev content
  • Re-check prior finding dispositions against current code
  • Review remaining correctness / credential exposure
  • Review focused test coverage
  • Post review

Scope note: origin/dev...HEAD has no merge base in this shallow checkout, so I reviewed the PR's own three commits (487f6b9, 3d00f6d, 8f28613 - 21 files, +376/-136) and read the surrounding production code at HEAD. The other ~97 changed files are dev content merged in at 56ca7f8 and are not this PR's.


Prior dispositions: confirmed repaired

I re-checked all five and agree they are closed. Briefly, so nobody has to re-derive it:

# Status at HEAD
1 URL params challenge/email/credentialId/credential_id/sessionId/session_id are in sensitiveUriParamNames (LogSanitizer.kt:337-342), matching the real params built at DesktopPasskeyService.kt:75-80/:159-164. Pinned by passkeyPageParametersDoNotExposeCredentials.
2 correlation IDs PR body corrected; deferral is now stated rather than implied.
3 allowlist Expanded to 16 codes with "unlisted" for rejects. Shape matcher declined with a stated reason. See B below - the mechanism is right but unreachable at five call sites.
4 response bodies Deferred with concrete locations named.
5 nameMarksSecret Special case replaced by the zipWithNext pair rule (:443-445), and sanitizeMap now also calls nameMarksSecret (:415) - so session-id, session.id, crossDeviceSessionId are covered on both paths. Pinned by prefixedAndSeparatedSessionNamesAreRedacted. Additive only: every name the pair rule catches was already caught by the substring test or was previously uncovered.

Four findings remain, two of which are new consequences of the reviewer commit rather than leftovers.


A. authFailureDetails logs failed: false on the failure branch it was called from

utils/logging.ts:6 sets failed: error != null. Four call sites sit behind a guard with a second disjunct, so the branch fires with error nullish:

// utils/authorization.ts:81
if (error || !data?.user?.id) {
  console.error('❌ Caller token rejected:', authFailureDetails(error))

A token that resolves without an error but yields no user.id now logs ❌ Caller token rejected: { failed: false } - a line that denies the rejection it is reporting. This one is a strict regression: the replaced expression was error?.message ?? 'no user for token', and that fallback string was written deliberately to name exactly this branch. It is not credential material and nothing required its removal.

Same shape, lower stakes, at services/auth.ts:326 (!userResult.success || !userResult.user) and services/mobile.ts:30/:99/:131. And the two success lines - utils/database.ts:50 and services/auth.ts:482 - print error: {failed:false} on every successful query, which is the smaller point raised last round and still open.

Returning undefined for a nullish error fixes the success lines; the guarded branches additionally want a caller-supplied reason, since only the caller knows the second disjunct fired.

Fix this →

B. Five call sites hand authFailureDetails a string, so the expanded allowlist cannot fire there

The 16-code allowlist added in 771278f85 is the right mechanism, but at the sites the original review actually named it has nothing to read. These five pass a ServiceResult.error:

  • services/auth.ts:71 ← getUserPasskeys returns error: error.message (database.ts:431)
  • services/auth.ts:327, :538 ← getUserWithEmail returns error: userError?.message || 'User email not found' (database.ts:547)
  • services/auth.ts:372 ← storeCompletedAuthentication returns error: error.message || error.code (database.ts:169)
  • services/auth.ts:519 ← claimStoredSession returns error: error.message (database.ts:204)

authFailureDetails bails at typeof error !== "object" (logging.ts:7), so all five can only ever emit {failed:true}. ❌ Failed to store completed authentication: {failed:true} - the exact line the first review picked out - is unchanged by the allowlist expansion, because the PostgrestError was already flattened to its message one frame down.

This is invisible to the new test: production challenge failures retain known codes drives storeChallenge, which logs the raw error object at challenge.ts:57, so it exercises the one shape that works. The fix is in the helpers - return code beside error, or hand the original error down - not in logging.ts.

Worth noting the direction of the loss at :372: storeResult.error is dropped from the log and then interpolated into the HTTP body two lines later (services/auth.ts:375). That is finding 4's deferral, but it means the string is present and readable at that call site while the log refuses it.

Fix this →

C. Seven full email addresses are still logged server-side - and 3d00f6d sharpened the asymmetry

The reviewer commit added email to sensitiveUriParamNames, so the desktop now refuses to log the email address it puts into the passkey URL. The edge function serving that URL logs the same address in clear, on the request that URL makes:

services/auth.ts:51    'Generating authentication challenge for email:', email
services/auth.ts:57    'User not found with email:', email
services/auth.ts:548   'Generating Supabase session for passkey auth:', userResult.user.email
services/mobile.ts:19  'Generating mobile registration page for:', email
services/mobile.ts:88  'Generating mobile authentication page for:', email
utils/database.ts:492  'Finding user by email:', email
utils/database.ts:507  'User not found with email:', email
utils/database.ts:551  'Found user email:', userData.email

The host has maskEmail for this and now applies it via the URL path. The PR body dispositions userId as "harmless identifiers remain intact" - which is arguable for an opaque UUID and not for an email address, and the two halves of the diff now state opposite policies for the same value. Either mask them or say in one sentence that server-side email is a deliberate retention, the way userId is.

Fix this →

D. The rule the PR wrote down is not applied to the sibling file in the same ceremony

CrossDeviceBrowserManager.kt:18 now carries the rule - "Keep raw throwables out of auth logs: their messages can contain the credential URL" - and it is accurate: java.net.URI.create(url) at :108 throws IllegalArgumentException("Illegal character in query at index N: <full URL>"). But DesktopPasskeyService, in the same flow, still logs raw throwables either side of it:

  • :105 logger.error(LogCategory.PASSKEY, "Browser registration error", error = e)
  • :211 logger.error(LogCategory.PASSKEY, "Biometric authentication error", error = e)
  • :107 / :213 PasskeyState.Error(e.message ?: …) puts the raw message into UI state

I traced the reachable exceptions: today the URL-bearing IllegalArgumentException is caught at CrossDeviceBrowserManager:121 and the fallback's IOException (Cannot run program "xdg-open"…) is what actually propagates, so there is no live leak. That is an accident of which frame catches, not a property - and BossLogger.writeToFileAsync appends error.message plus frames with no sanitizer, which is the follow-up the last round asked for. Applying sanitizeExceptionMessage(e.message) at these two sites costs nothing and makes the file consistent with the rule the PR just documented next door.


Smaller points

  • knownCodes is rebuilt on every call (logging.ts:9). It is a 16-element Set allocated inside an error-path function reached from ~50 sites. Hoist it to a module-level const.
  • withGenericErrorHandler:52 substitutes before it redacts. error instanceof Error ? error : new Error("Unknown service failure") is an improvement over error as Error, but the non-Error case is precisely the shape - a thrown PostgrestError-like object literal - whose code/status authFailureDetails could have kept. Pass the original to authFailureDetails and keep the substitution only for onError.
  • Import above the module docblock, still. index.ts:1, utils/authorization.ts:1, utils/crypto.ts:1, utils/error-handler.ts:1 each now open with the authFailureDetails import and then the /** … */ header, which detaches each file's rationale docblock from its file. Unaddressed from the last round.
  • utils/logging.ts uses semicolons and double quotes where every neighbouring file uses neither; deno lint is continue-on-error in edge-functions.yml, so nothing will flag it.
  • CrossDeviceBrowserManager call-site formatting is now mixed: three sites use positional args on one line, four use category =/message =/data = with data = split onto its own line above a short mapOf(...). Both are ktlint-clean; picking one would read better.
  • Pre-existing, in a block this PR reformatted: the inner catch (fallbackException) at CrossDeviceBrowserManager:129-136 is unreachable. openBrowserWithProcessBuilder catches internally and returns a Result, so it never throws - "All browser opening methods failed" cannot log, and the caller receives the exception logged at :201 instead. Not introduced here; worth knowing before anyone relies on that line.

Regression coverage

Right, and better than last round. production challenge failures retain known codes and omit arbitrary codes drives a real storeChallenge failure and covers "unlisted" - which is what the previous test-authored console.error could not do. The canary-absent design in capture (including the 20-char prefix check) is the correct shape, and assertEquals(replay.refreshToken, refreshToken) proving redaction did not reach the response is still the best line in the file. CredentialRedactionTest closes the prefixed/separated session-name gap the last round identified, and plugin-logging's publish task depends on its own desktopTest, so these are on the gated path.

Remaining gaps, in order of what a test added now would actually catch:

  1. Nothing pins {failed:false} (finding A) or the string-error collapse (finding B). Both are behaviours an operator will read as "no failure" / "no code available", and both would fail a test written today.
  2. verifyCallerToken is untested, and it holds the one call site where an explicit diagnostic was removed.
  3. Nothing pins the Kotlin call sites - only LogSanitizer itself. The regression class this PR exists to prevent is a future edit restoring mapOf("url" to url) at CrossDeviceBrowserManager:101, and the sanitizer tests pass either way. The Deno half does test through production call sites; the Kotlin half does not.
  4. register/mobile?...&rpName= is not covered, only auth/mobile. Same param names, so low value.

I could not execute either suite here - deno is unavailable and Gradle/network calls are denied in this sandbox - so the PR body's pass claims are unverified by me. Everything above is from reading and hand-tracing HEAD; the flipped LogSanitizerTest expectations all agree with the new maskToken/maskSessionId implementations.


• branch fix/security-credential-logging

@kshivang

Copy link
Copy Markdown
Contributor

@Raghav-Bansal-15 Your current authentication redaction and prior contributor/reviewer work remain intact. I verified the actual auth-only diff and the earlier finding dispositions, and independently ran 161 passkey Deno tests plus 84 production Kotlin sanitizer/facade tests, all passing. A combined tree with #640 and #691 also passes 123 focused tests. No new in-scope code defect is established.

The latest dev integration has one AGENTS.md conflict; I prepared that integration locally, keeping dev's documentation and preserving all original commits. The resulting diff is the 21 intended auth/logging files. That integration push is awaiting reviewer-side authorization, so the published head is still 56ca7f8 and has no current upstream check results. The once-requested current Claude review has completed; its dispositions follow. The shared local Gradle request was stopped while still waiting for the lock; no full local Gradle pass is claimed.

This is an integration/authorization/validation checkpoint, not a request to repeat repaired work or an author defect. I am leaving the PR ready rather than drafting it for pending infrastructure. Preserve the finite error-code allowlist, passkey field/session-name regressions and explicit exclusion of generic HTTP-error response sanitization. Manual synthetic desktop/mobile auth-log checks remain unperformed.

Current Claude response: the missing-user failed:false diagnostic, completed-auth storage code loss, and thrown-object diagnostic loss are reproduced with three failing Deno tests and fixed locally. The full suite now passes 164/164. Nullish errors omit the misleading failed flag; missing caller identity gets a fixed no_user_for_token reason; storage failures log allowlisted diagnostics before conversion to strings; generic wrappers preserve the original object for logging while keeping the safe callback fallback. These are reviewer repairs, not new contributor credit.

Finding B is narrower than suggested: getUserPasskeys, getUserWithEmail and claimStoredSession already log the raw error through authFailureDetails at the database boundary, so their upper-layer string messages do not lose the only copy of the safe code. The actual missing storeCompletedAuthentication log is now covered, including its insert fallback. No shared ServiceResult redesign was needed.

Server-side email and opaque user identifiers deliberately remain retained in this credential-capability scope; masking email in passkey URLs does not claim server-wide identity redaction. The sibling DesktopPasskeyService throwable observation explicitly found no reachable URL leak, so no speculative rewrite was added. Generic HTTP response sanitization remains deferred. Set-allocation, import placement and positional formatting nits are not merge-blocking in this bounded pass. No repeated Claude review requested.

@Raghav-Bansal-15

Copy link
Copy Markdown
Contributor Author

Follow-up at e37c1836: Resolved current-dev conflicts and applied the diagnostic corrections described in review after confirming they were not in the published branch. The three new regressions fail before those corrections; all 164 Deno and 52 plugin-logging tests pass locally with logging quality checks. Exact-head fork Build & Test passes.

Original and reviewer commits are preserved. Remaining current-head hosted checks and upstream approval are separate gates; no merge or release.

@kshivang

Copy link
Copy Markdown
Contributor

@Raghav-Bansal-15 I reviewed your follow-up e37c183 against current dev and the prior Claude dispositions. The missing-user reason, nullish-error flag, completion-store diagnostics on both paths, and thrown-object diagnostics are correctly repaired. Your replacement regressions cover those actual production paths. The updated sanitizer also preserves already-merged #640; no unrelated implementation is being counted as new work.

Fresh independent checks pass: 164 Deno tests and 110 production Kotlin sanitizer/facade tests. Diff checks and current-dev merge-tree are clean. Exact-head fork Build & Test run 34977361361 is successful. No additional source repair or repeated Claude review loop is needed.

Upstream run 34977121692 remains action_required. Automatic approval review rejected my request to approve that gated workflow, so explicit approval has been requested and I will not retry without it. This is reviewer-side authorization, not a failing test or author defect. I am leaving the PR ready; once upstream checks can run and pass, refresh dev/head/checks under the merge lock. The old request to push my local integration is superseded by your published follow-up and is no longer needed. Original 7.5/10 and reviewer/contributor attribution remain preserved.

Rebase the reviewed 21-file authentication redaction patch onto the
current dev tip (bc9aa3b). The previous integration commit was built
on a pre-history-replacement snapshot that predates live-sessions, the
terminal_sessions migrations, release notes and risa-labs-inc#769's
enumeration-safe /auth/challenge work; merging the branch as published
would have deleted those files.

Only real content conflict: services/auth.ts error logs, where the
masked authFailureDetails() logging (this PR) is retained over the raw
error object that risa-labs-inc#769 re-introduced on the dev side. The merged
effective diff against dev is byte-identical to the reviewed head's
auth patch.
@kshivang

Copy link
Copy Markdown
Contributor

@claude Please review the current head b53379d (re-integrated onto dev tip bc9aa3b after the history replacement): verify the 21-file authentication redaction patch survived integration byte-identical, that services/auth.ts keeps masked authFailureDetails logging alongside #769's enumeration-safe challenge flow, and that nothing in the auth logging path still exposes tokens, session ids, emails or raw error payloads.

@claude

claude Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 5m 55s —— View job


Review: #567 at b53379dd (re-integrated onto dev bc9aa3b2)

  • Confirm ancestry and isolate the PR's own diff vs origin/dev
  • Verify the 21-file redaction patch survived integration byte-identical
  • Check services/auth.ts keeps masked authFailureDetails alongside fix(security): make /auth/challenge enumeration-safe and rate-limited #769's enumeration-safe challenge flow
  • Sweep the auth logging path for tokens / session ids / emails / raw error payloads
  • Post review

The integration is clean in the sense that matters most - origin/dev is now a direct ancestor of HEAD (git merge-base origin/dev HEAD = bc9aa3b2), so git diff origin/dev HEAD is the whole of this PR: 21 files, +414/-138, exactly the counts in the description, and every file is one of the 21 auth/logging files. b53379dd is a true two-parent merge (4461fba4 + bc9aa3b2) whose combined diff touches no auth file, so the patch was not re-edited during the merge.

But byte-identical to the reviewed patch is not the same as byte-identical to dev on the lines the patch did not mean to change, and that distinction is where this integration went wrong. One file carries two lines that are now reverts of dev, in the direction this PR exists to prevent. I could not fetch e37c1836 in this sandbox (shallow checkout, network-restricted), so I verified against dev's current content rather than against the prior head.


1. Blocking - index.ts reverts dev's response hardening, on an unauthenticated endpoint

git diff origin/dev HEAD -- supabase/functions/passkey/index.ts:

 app.post("/maintenance/cleanup", async (ctx) => {
   ...
-    return ctx.json({ error: 'Internal server error' }, 500)   // dev
+    return ctx.json({ error: result.error }, 500)              // this PR

 app.onError((err, ctx) => {
-  console.error('Global error:', err)
-  return ctx.json({ error: 'Internal server error' }, 500)     // dev
+  console.error('Global error:', authFailureDetails(err))       // ✅ the intended half
+  return ctx.json({ error: err.message }, 500)                  // ❌ the revert
})

The console.error half is the improvement this PR is for. The two ctx.json lines are dev content being undone. Mechanically: 4461fba4 replayed a pre-hardening index.ts, the merge saw only the branch side as changed, and took it silently.

Three things make this worse than a cosmetic ancestry slip:

  • /maintenance/cleanup has no authorization. index.ts:36-56 mounts only CORS and the service-role client injection - no verifyCallerToken, and the function deploys --no-verify-jwt per this repo's own documented deploy command. cleanupExpiredChallenges returns {success:false, error: error.message} (utils/challenge.ts:80), so result.error is the raw Postgres message, handed to any caller who POSTs that path. Note the same value is passed through authFailureDetails one frame up for the log - so at HEAD the log is redacted and the HTTP body is not, which inverts the PR's own threat model.
  • app.onError returning err.message is the catch-all for anything that escapes a route or throws in middleware, including utils/jwt.ts:62/:79's `Failed to generate session link: ${linkError?.message}` and `Failed to create session: ${sessionError?.message}` - GoTrue text, straight to the client.
  • CI cannot see it. Nothing under supabase/functions/passkey/tests/ imports index.ts or exercises /maintenance/cleanup or app.onError - I grepped. routes.test.ts:423 ("unexpected route exceptions return a generic 500") looks like it covers this and does not: buildApp (:19-30) constructs its own OpenAPIHono and mounts only the three routers, so it exercises routes/management.ts:127's own catch, never index.ts's handler. The Edge Functions run on this head passed for exactly that reason.

For scale: every other 500 in the function - all 8 route-level catches plus the two mobile HTML paths - returns the constant. After this PR, index.ts holds the only two that don't.

Fix this →

2. services/auth.ts:117 logs a raw Postgres message - #769's line, two lines below a wrapped one

Answering the #769 question directly: yes, the enumeration-safe flow is intact. inertChallenge is at services/auth.ts:56 with all four call sites (:89, :100, :106, :123), routes/auth.ts:93-99 keeps the rateLimit/429 gate, and the auth.ts diff against dev is purely logging - no #769 line was displaced.

One exception, and it is in the same function:

:106  console.error('Error fetching user passkeys:', authFailureDetails(passkeyResult.error))  // wrapped
...
:117  console.error('Failed to store challenge:', storeResult.error)                            // raw

storeChallenge returns { success: false, error: error.message } (utils/challenge.ts:58), so that is the unredacted Postgres message - the message/details payload this PR's own logging.ts docblock says "may embed complete rows or request bodies". It is identical on dev, so neither author introduced it: #769 added the block after the redaction patch was written, and the redaction pass never came back over it. That is precisely the class of miss a re-integration review should catch.

(Its sibling in storeChallenge already logs the same error through authFailureDetails at challenge.ts:57, so the safe code is not lost - but this line re-emits the raw one.)

Fix this →

3. Eight route-level catches still log the raw throwable, while their mobile siblings were wrapped in this same commit

routes/auth.ts:114, :201, :258          console.error('Route error:', error)
routes/management.ts:127, :213, :304    console.error('Route error:', error)
routes/register.ts:113, :221            console.error('Route error:', error)

These are the widest funnel in the function - the outermost catch on every JSON route. The PR did wrap both equivalents in routes/mobile.ts (:112, :205) and did touch routes/auth.ts and routes/register.ts for the clientDataJSON catch a few lines above each of these, so the omission reads as an oversight rather than a decision. Cheap to close: authFailureDetails(error) at eight sites, the same call already imported in all three files.


Confirmed intact

Because the diff is against dev directly, anything not in it is identical to dev. I checked the places most likely to have been clobbered:

Status
#769 enumeration-safe challenge Intact - inertChallenge ×4, rateLimit/429 in routes/auth.ts, auth.ts diff is logging-only
#691 query/fragment URL secrets, #640 userinfo, MCP auth-scheme redaction Intact - LogSanitizer.kt's diff shows only maskToken/maskSessionId/the two name lists/nameMarksSecret; every other line matches dev
Host LogSanitizer facade Pure delegation (utils/logging/LogSanitizer.kt:13-39), so BossAppWithAuth.kt's calls get the new behaviour - worth confirming because the host imports ai.rever.boss.utils.logging, not the plugin-logging object
URL param list vs the real passkey URL challenge/email/credentialId/credential_id/sessionId/session_id all present, matching DesktopPasskeyService's built params; pinned by passkeyPageParametersDoNotExposeCredentials
nameMarksSecret pair rule crossDeviceSessionId, session-id, session.id, SESSION_ID, sessionid all covered on both the map and free-text paths; session_status=active still survives
No collision from adding email to the URI list No existing test in QueryParameterSanitizerTest, BrowserUrlLogConventionTest, DescribeUriSecretShapesTest or MagicLinkPasteTest asserts an email param surviving

Prior dispositions I re-checked and am not re-raising: the {failed:false} fix (authFailureDetails now returns {} for nullish, and authorization.ts:82 supplies no_user_for_token), storeCompletedAuthentication's diagnostics on both write paths (database.ts:163, :174), thrown-object preservation in withGenericErrorHandler, the finite allowlist over a shape matcher, server-side email/userId retention, and the knownCodes/import-placement/formatting nits.

Server-side emails are still logged in 7 places (services/auth.ts:82, :573; services/mobile.ts:19, :88; utils/database.ts:494, :509, :553) - unchanged, as dispositioned.

Verification limits

deno is not installed on this runner and Gradle/network calls are denied, so I executed neither suite; everything above is from reading and hand-tracing HEAD. I also could not fetch e37c1836, so the "byte-identical to the reviewed patch" claim is unverified by me - what I verified is the diff against dev, which is the stronger check for finding 1 and is how finding 1 surfaced. CI on this head: Edge Functions green, Build & Test still queued - and note that Edge Functions being green is not evidence about finding 1, since index.ts has no test coverage at all.

--- • branch fix/security-credential-logging

Addresses the Claude review on head b53379d:

- index.ts reverted two of dev's hardened 500 responses (the
  unauthenticated /maintenance/cleanup handler and app.onError). Restore
  both to the generic 'Internal server error' body; keep the redacted
  console.error half.
- Move the entrypoint app to app.ts (with a lazily created, testable
  service-role client in utils/client.ts) so tests drive the real
  handlers, matching the crash-report/organisation pattern.
- storeChallenge returns an allowlisted code alongside the error;
  services/auth.ts logs authFailureDetails for the store failure and
  services/registration.ts reports the code instead of the raw database
  message.
- Wrap the eight remaining raw 'Route error:' logs in
  authFailureDetails.
- New tests/app.test.ts pins: cleanup 500 and global 500 bodies never
  carry diagnostics, the global handler still logs, and both challenge
  flows keep Postgres messages off the wire.
@kshivang

Copy link
Copy Markdown
Contributor

Thanks — all three findings are legitimate and are fixed at 83f8095a (pushed to fix/security-credential-logging).

1. index.ts reverting dev's hardened 500s — fixed. Both lines restored to ctx.json({ error: 'Internal server error' }, 500); the redacted console.error('Global error:', authFailureDetails(err)) half is kept. Since the merge-base replay made index.ts the vector, I also moved the entrypoint app to app.ts with a lazily created, testable service-role client (utils/client.ts, the organisation function's pattern) and a thin index.ts that only calls Deno.serve — matching the crash-report/organisation/latest-release convention in this repo (and its documented rationale about not gating Deno.serve on import.meta.main). New tests/app.test.ts imports the real app and drives app.request() through the production handlers:

  • POST /passkey/maintenance/cleanup with a canary Postgres message queued on the delete → asserts 500, body contains Internal server error, and the canary never appears in the body.
  • Global handler: a canary thrown from Deno.env.get during a request → asserts 500, canary absent from the body, and the log stays redacted (shows Global error: with no canary text — the log goes through authFailureDetails, so the raw message is not asserted there).
  • Both challenge flows with canary store-failure messages → asserts the canary never appears in logs or response (see finding 2).

2. services/auth.ts:117 raw storeResult.error — fixed, as suggested. storeChallenge now returns code alongside error (the allowlisted Postgres error code, error.code ?? 'unknown'; the catch path returns code: 'unknown'), and the auth line logs authFailureDetails({ code: storeResult.code, message: storeResult.error }) — same shape as line 106. The canary test above drives generateAuthChallenge through a 23505 store failure and asserts the raw message never reaches captured output while 23505 does. While wiring the canary I found one adjacent instance of the same class you'd flagged: services/registration.ts returned storeResult.error in the client response (register/challenge 400). It now reports Failed to store challenge (23505) — code yes, message no — pinned by the same test file.

3. Eight raw Route error: logs — fixed. All eight sites (routes/auth.ts:114,201,258, routes/management.ts:127,213,304, routes/register.ts:113,221) now wrap authFailureDetails(error); management.ts gains the import (the other two already had it).

Validation at 83f8095a: deno test in supabase/functions/passkey → 178 passed / 0 failed (174 prior + 4 new entrypoint/canary tests), deno check clean across all touched modules. Kotlin side unchanged in this commit (the 140 plugin-logging tests + ktlint/detekt already passed at b53379dd). Happy for a fresh look at 83f8095a whenever convenient.

@kshivang

Copy link
Copy Markdown
Contributor

@claude review — new head 83f8095 addresses your three findings on b53379d (evidence in the preceding comment): the two hardened 500 bodies are restored, the entrypoint app moved to app.ts with entrypoint-level canary tests (tests/app.test.ts), storeChallenge returns an allowlisted code, and the eight raw Route-error logs are wrapped. Please re-verify the integration against origin/dev and confirm no new issues in 83f8095 itself.

@claude

claude Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 4m 34s —— View job


Re-review: #567 at 83f8095a

  • Verify ancestry / diff vs origin/dev
  • Re-check the three prior findings
  • Review the new app.ts / client.ts / index.ts split
  • Review tests/app.test.ts and the storeChallenge code plumbing
  • Sweep for new issues in 83f8095a
  • Post review

All three findings are fixed, and the entrypoint split is done the right way - not a bespoke test seam, but the pattern organisation/index.ts already uses, docblock and all (I diffed them; crash-report has the same app.ts/index.ts pair). The Deno.serve rationale in the new index.ts is correct and worth having written down.

Integration is clean: git merge-base origin/dev HEAD = bc9aa3b2 = origin/dev, so git diff origin/dev HEAD is the whole PR - 25 files, +700/-273, every one an auth/logging file. b53379dd..HEAD touches 10 files and no Kotlin, so the reviewed host-side patch is untouched by this commit.

Prior finding Status at 83f8095a
1. index.ts reverting dev's 500s Fixed. Both bodies are { error: 'Internal server error' } (app.ts:67, :120), the redacted console.error('Global error:', authFailureDetails(err)) kept.
2. auth.ts:117 raw storeResult.error Fixed, and fixed at the source: storeChallenge returns code: error.code ?? 'unknown' (challenge.ts:60, :67) and the log now matches line 106's shape.
3. Eight raw Route error: logs Fixed, all eight (routes/auth.ts:114,201,258, management.ts:127,213,304, register.ts:113,221), plus the two clientDataJSON catches.

Two things remain, both about the response side that this commit started fixing.


1. auth/complete still returns the raw Postgres message - the sibling of the leak this commit closed

services/auth.ts:400:

console.error('❌ Failed to store completed authentication:', authFailureDetails(storeResult.error))
return {
  success: false,
  error: `Failed to store authentication result: ${storeResult.error || 'Unknown error'}`
}

storeCompletedAuthentication returns error: error.message || error.code (utils/database.ts:164, :171), so storeResult.error is the raw Postgres message. routes/auth.ts:195 hands it straight out as a 400 body.

This was finding 4's deferral last round, and I would have left it there - except that this commit deliberately un-deferred the identical case one file over ("While wiring the canary I found one adjacent instance of the same class"), and the one that stayed is the more exposed of the two:

  • register/challenge (fixed) sits behind requireAuthenticatedCaller (routes/register.ts:93) - the SQLSTATE goes to a caller who already proved account ownership.
  • auth/complete (not fixed) has no caller authentication at all; possession of a live challenge is the only gate.

The fix is the one already applied next door: give storeCompletedAuthentication a code alongside error the way storeChallenge now has one, and report Failed to store authentication result (23505). Both its return sites (database.ts:164 and :171) already log through authFailureDetails, so the code is in hand.

Fix this →

2. The new response path puts an unfiltered error code on the wire

services/registration.ts:69:

error: storeResult.code ? `Failed to store challenge (${storeResult.code})` : 'Failed to store challenge'

storeResult.code is error.code ?? 'unknown' straight off the PostgREST error - it never passes the 16-entry allowlist. That inverts this PR's own stated policy in the one direction that matters: the allowlist exists because "arbitrary credential material can satisfy those shapes" (the reason a shape matcher was declined for logs), and here an unvetted code goes into an HTTP body, which is the more exposed sink.

Practically the risk is small - for a PostgrestError this is a SQLSTATE, and the route is authenticated - so this is about keeping one definition of "safe code" rather than about a live leak. authFailureDetails({ code: storeResult.code }).code is the whole fix, and it makes the wire strictly no more permissive than the log. Worth a line in the KDoc either way, since a reader of challenge.ts:58-59 ("code is what callers may surface") will reasonably assume it was already filtered.

Two smaller things in the same two lines: the storeResult.code ? ternary has a dead false branch (both failure returns always set code), and services/auth.ts:117 builds authFailureDetails({ code: storeResult.code, message: storeResult.error }) - handing the raw message into the helper that currently drops it. It is safe today and one edit to logging.ts away from not being; passing only { code } costs nothing.

3. Test veracity: one assertion in app.test.ts cannot fail

tests/app.test.ts:73:

assert(!output.includes(diagnostic), "the log is redacted exactly like the response body")

authFailureDetails returns {failed, code?, status?} and never a message, for any input - so this passes whatever the handler does, including if it logged a different error entirely. It reads like the canary check that carries the test, and the load-bearing assertion is actually the body one at :65. Asserting the log's shape (output.includes('"failed":true'), or that no code is emitted for a plain Error) would pin something.

The other three tests are good, and registration challenge store failure reports a code, never the database message is the right shape: it asserts the exact string Failed to store challenge (23505), so the fix above would surface there rather than silently.

One coupling worth a comment: test 2's mechanism depends on productionClient in utils/client.ts still being null when it runs, since the thrown Deno.env.get only fires on first creation. deno test shares module instances across files in a process, so a future test that drives app.request() without setPasskeyClientForTests would cache the client and this test would start failing for a reason unrelated to what it tests. It fails loudly (the 500 assertion), not silently, so this is a note rather than a defect - a resetPasskeyClientForTests() or the centralised teardown organisation/tests/helpers/mocks.ts uses would remove the ordering dependency.


Smaller points

  • The move was not quite mechanical. app.ts changes two published OpenAPI strings: "WebAuthn/Passkey authentication API for BOSS application" → "WebAuthn authentication API…" (:77) and "Passkey registration endpoints" → "WebAuthn registration endpoints" (:96). Unrelated to redaction and visible at /passkey/openapi.
  • index.ts's 14-route inventory docblock was dropped, not moved. It was the only place the function's surface was listed in one view; app.ts's new header does not replace it.
  • tests/routes.test.ts still builds its own twin (buildApp, :19-30). That was unavoidable before; now that app.ts is importable, the twin is the thing that hid finding 1 last round and could be retired.
  • knownCodes is still rebuilt per call (logging.ts:9) - a 16-element Set allocated inside an error-path function reached from ~50 sites. Carried over, still one line.
  • Pre-existing, now that the body is fixed: /passkey/maintenance/cleanup remains unauthenticated. It only deletes rows with expires_at < now(), so this is an observation rather than a finding - but it is the endpoint the new test drives, so worth knowing it is reachable by anyone.
  • Import-above-the-docblock persists in authorization.ts:1, crypto.ts:1, error-handler.ts:1 (now not index.ts, which was rewritten); utils/logging.ts still uses semicolons and double quotes against the local style, and deno lint is continue-on-error.

Verification limits

deno is not installed on this runner and Gradle/network calls are denied, so I executed neither suite - everything above is reading and hand-tracing 83f8095a. I did verify the claimed convention rather than take it: organisation/index.ts carries the byte-for-byte equivalent docblock and setServiceClientForTests, and crash-report/ has the same app.ts/index.ts pair. CI on this head: ⚡ Edge Functions green, 🔨 Build & Test queued.

--- • branch fix/security-credential-logging

…esponses

Claude re-review follow-ups at 83f8095:

- storeCompletedAuthentication now returns a code alongside error (like
  storeChallenge), and auth/complete's 400 body reports
  'Failed to store authentication result (<code>)' through the
  authFailureDetails allowlist instead of the raw Postgres message.
  auth/complete is unauthenticated, so this is the more exposed of the two
  store-failure sinks.
- register/challenge runs its code through the same allowlist
  (authFailureDetails({ code }).code) so log and wire share one definition
  of 'safe code'; the dead false branch of the old ternary is gone, and
  services/auth.ts logs store failures by code only rather than handing the
  raw message into the helper.
- tests/app.test.ts: the global-handler test now asserts the structured log
  shape ('failed':true) instead of an assertion that cannot fail, drives the
  lazy client through resetPasskeyClientForTests so it no longer depends on
  module-instance ordering, and gains a canary test that drives a real
  signed assertion through completeAuthentication to a failing
  storeCompletedAuthentication and asserts the 23503 code reaches the error
  while the database message never does.
- app.ts restores the two published OpenAPI strings dropped in the entrypoint
  rewrite; index.ts gets the 14-route inventory docblock back.
@kshivang

Copy link
Copy Markdown
Contributor

@claude re-review fixes are in at aae298c17 (deno test 179/179, deno check clean):

1. auth/complete raw message — fixed. storeCompletedAuthentication (utils/database.ts) now returns code alongside error on both failure returns (retry.error.code ?? 'unknown' / error.code ?? 'unknown'), and services/auth.ts reports Failed to store authentication result (<code>) where the code comes through authFailureDetails({ code }).code — allowlisted, no message. The failure log is code-only too. New canary test in tests/app.test.ts: a real ES256-signed assertion (via helpers/webauthn.ts createAssertion) drives completeAuthentication through verification, challenge consumption, and counter advance to a completed_authentications upsert failing with a canary message; asserts 23503 in the returned error and the canary in neither the error nor the captured logs.

2. Unfiltered code on the wire — fixed. services/registration.ts now reports Failed to store challenge (${authFailureDetails({ code: storeResult.code }).code ?? 'unknown'}) — same allowlist as the log, one definition of "safe code" (a comment says so). The dead false branch is gone with it. services/auth.ts's store-challenge log now passes only { code }, not the raw message into the helper.

3. Test veracity — fixed. The global-handler test now asserts output.includes('"failed":true') (the structured shape authFailureDetails actually emits) in addition to Global error:, and the module-cache coupling is addressed structurally: utils/client.ts gained resetPasskeyClientForTests() (drops both testClient and the cached productionClient), which the test calls before and after — no ordering dependency on other test files, with a comment explaining why.

Smaller points: both published OpenAPI strings restored in app.ts (verified byte-identical to origin/dev's doc block), and the 14-route inventory docblock is back on index.ts. routes.test.ts twin retirement and the knownCodes one-liner are skipped as optional per your note; the unauthenticated /maintenance/cleanup stays as an observation (the endpoint is cron-shaped and only deletes expired rows — I agree it warrants a follow-up issue, not a change in this PR).

@claude

claude Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Claude finished @kshivang's task in 7m 37s —— View job


Re-review: #567 at aae298c1

  • Verify ancestry / diff vs origin/dev
  • Re-check the three prior findings
  • Review the new completeAuthentication canary test and client.ts reset
  • Sweep for new issues in aae298c1
  • Post review

All three findings are fixed, and the fix for #1 is the right one - the code is plumbed from the database boundary rather than filtered at the response, so the log and the body read the same value through the same allowlist. 23503 is in the 16-code set, so the new canary's assert(message.includes('23503')) is a real assertion rather than a pass-by-accident.

Prior finding Status at aae298c1
1. auth/complete raw message Fixed. storeCompletedAuthentication returns code on both failure returns (database.ts:164, :171), auth.ts:406 reports Failed to store authentication result (<code>) through authFailureDetails({ code }).code, log at :399 is code-only.
2. Unfiltered code on the wire Fixed. registration.ts:73 runs the code through the same allowlist; dead ternary branch gone; auth.ts:119 no longer hands the raw message into the helper.
3. Test veracity Fixed. '"failed":true' shape assertion added, and resetPasskeyClientForTests() (client.ts:34) removes the module-cache ordering dependency structurally.

Smaller points also confirmed: both OpenAPI strings restored (app.ts:77, :96), the 14-route inventory is back on index.ts, and the two hardened 500s are intact (app.ts:67, :120).

The remaining issue is that the class of defect finding 1 belongs to is still open at three sites in two of the files this PR touched, and one of them is the same unauthenticated shape.


1. register/complete still returns the raw Postgres message - twice, and it is the sibling of the endpoint just fixed

routes/register.ts:216 returns ctx.json({ error: result.error || 'Registration failed' }, 400), and two paths put an unfiltered database string into result.error:

// services/registration.ts:273  ← storePasskeyInDB
error: storeResult.error || 'Failed to store passkey'
// database.ts:309 / :316  return { success: false, error: error.message }
// database.ts:323          return { success: false, error: (error as Error).message }

// services/registration.ts:167  ← verifyAndConsumeChallenge
error: challengeResult.error || 'Invalid challenge'
// database.ts:243 → consumeChallengeRow:90  return { consumed: false, error: error.message }
// database.ts:250                            return { success: false, error: (error as Error).message }

Three things line this up exactly with the case aae298c1 just closed:

  • The exposure tier is the same. routes/register.ts:204 uses resolveOptionalCaller, not requireAuthenticatedCaller - "a bearer token is honoured when the transport can carry one", per its own comment - so register/complete is reachable with no caller authentication, like auth/complete. The (code) reasoning written into auth.ts:401-405 applies verbatim.
  • The same inversion is present. All five source sites log through authFailureDetails (database.ts:89, :249, :308, :315, :322) and then return the raw message to the caller - the log is redacted and the body is not.
  • It is in the file this commit half-fixed. registration.ts:73 now runs its code through the allowlist; :273, ~200 lines down in the same module, still ships error.message.

storePasskeyInDB needs the code plumbing storeChallenge and storeCompletedAuthentication now have; consumeChallengeRow already has the code in hand at :89.

Fix this →

2. manage/list returns the raw message - the only one of the three management endpoints that does

services/management.ts:18:

error: result.error || 'Failed to fetch passkeys'   // ← getUserPasskeys, database.ts:433 / :440

manage/delete and manage/update in the same file already return 'Failed to delete passkey' / 'Failed to update passkey' after logging through authFailureDetails (:72, :123), so list is out of step with its own neighbours. Behind requireAuthenticatedCaller (routes/management.ts:34), so this is the lower tier - the same tier as register/challenge, which this branch did fix, which is why it is worth closing rather than deferring alongside it.


Smaller points

  • challenge.ts:58-59's comment is now misleading, and in the direction that causes this bug. "error is safe for logs (already filtered upstream); code is what callers may surface" - error is error.message, raw and unfiltered; what is filtered is the console.error on the line above. A reader trusting that sentence would pass storeResult.error to a log, which is precisely the call this commit removed at auth.ts:119. Rewording it to say "error is the raw database message: server-side only, never logged and never returned" would make the comment match what auth.ts now does.
  • 'unknown' becomes 'unlisted' on the wire. Both helpers normalise a missing code to the literal 'unknown' (challenge.ts:60/:67, database.ts:164/:171), which is not in knownCodes, so authFailureDetails rewrites it to "unlisted" - a body reading Failed to store challenge (unlisted) where "unknown" was the accurate word, and it makes the ?? 'unknown' tail at registration.ts:73/auth.ts:406 unreachable. Returning code: error.code (undefined when absent) instead of ?? 'unknown' gets both right and keeps unlisted meaning what logging.ts:12 says it means.
  • captureLogs's JSON.stringify makes a quoted canary un-assertable. output.includes(canary) runs against JSON text, so a canary containing " or \ is escaped in the haystack and can never match - the assertion passes no matter what was logged. Today it happens not to bite (CHALLENGE_CANARY and COMPLETE_CANARY have no quotes; CLEANUP_CANARY does, but is only asserted against the raw body), so this is a trap for the next canary rather than a live gap. output.map(args => args.map(String).join(' ')).join('\n') removes it.
  • The new completion test could pin the positive side too. !output.includes(COMPLETE_CANARY) is a regression guard rather than proof, for the same structural reason the test-2 assertion was replaced: authFailureDetails cannot emit a message for any input. assert(output.includes('23503')) is available here - authFailureDetails({code:'23503'}) emits it - and would make the log half say something, matching what :119 in the challenge test already does.
  • Mixed teardown convention: tests 1, 3 and 4 use setPasskeyClientForTests(null) while test 2 uses resetPasskeyClientForTests(). Harmless (the others never create a production client), but now that the stronger verb exists it may as well be the only one.
  • Carried and explicitly skipped as optional, no argument from me: knownCodes rebuilt per call (logging.ts:9), the routes.test.ts twin, the unauthenticated /maintenance/cleanup as a follow-up issue. Also still carried: import-above-the-docblock in utils/authorization.ts:1, utils/crypto.ts:1, utils/error-handler.ts:1, and utils/logging.ts's semicolons/double quotes against the local style.

Integration state

git diff bc9aa3b2 HEAD is exactly 25 files, +820/-263, all auth/logging - matching the description, so the patch is intact against the dev tip it was integrated onto, and 83f8095a..HEAD touches no Kotlin.

origin/dev has since advanced to e9208143. git diff origin/dev HEAD shows six extra files this branch is simply behind on - BossTopBar.kt, DesktopGitService.kt, GitProviderWritesToRepoTest.kt, modules/boss-app-browser/build.gradle.kts, BrowserServiceImpl.kt, BrowserServiceImplTest.kt, which is #931's browser scheme gate and its neighbours. The next integration has to take dev's side on all six; replaying this branch's older copies is the exact mechanism that produced last round's finding 1. No passkey file and no LogSanitizer.kt moved in dev (the per-file counts are identical against both baselines), so the auth patch itself has no conflict surface this time.

Verification limits

deno is not installed on this runner and Gradle/network calls are denied, so I executed neither suite - the 179/179 claim is unverified by me and everything above is reading and hand-tracing aae298c1. I did check the mock harness rather than assume it: createMockSupabaseClient routes .upsert() onto the 'insert' queue (tests/helpers/mocks.ts:300-305) and mockAuthUser exists, so the new completion test's mocks line up with the production calls. Setting PASSKEY_RP_ID at module top level matches auth.test.ts:12 and e2e.test.ts:21, so it is the established convention here, not new coupling. CI on this head: ⚡ Edge Functions green, 🔨 Build & Test queued.


• branch fix/security-credential-logging

@fluck-boss

Copy link
Copy Markdown
Collaborator

Thanks @Raghav-Bansal-15 for this PR.

Before it can be reviewed or merged, it needs a rebase: against dev the branch carries about 91 commits of older history whose content already reached dev through squash merges, so the diff shows dozens of unrelated files on top of your change. We can't review or merge it as-is.

Could you rebase onto current dev, or put just your fix commit(s) on a fresh branch off dev (the branch tip is aae298c), so the PR shows only this change? Once it's down to the real diff and CI is green, we'll pick it up for review.

@fluck-boss

Copy link
Copy Markdown
Collaborator

Thanks for this, @Raghav-Bansal-15 - the change itself ("fix(auth): keep credentials out of authentication logs") looks worthwhile and we'd love to land it.

One thing blocking review: this branch currently carries 91 commits, most of them unrelated work from other branches, so the PR diff shows far more than its own change and can't be merged as-is.

Could you rebase onto the current dev branch (or re-cut the branch from fresh dev and cherry-pick just this change) and force-push? Once the PR contains only its own commit(s), we'll be happy to review it quickly. Thanks again!

@fluck-boss fluck-boss closed this Sep 24, 2026
@fluck-boss fluck-boss reopened this Sep 24, 2026
@kshivang
kshivang force-pushed the dev branch 3 times, most recently from 08da448 to 4077231 Compare September 24, 2026 23:30
@fluck-boss

Copy link
Copy Markdown
Collaborator

@claude Preliminary triage re-review of #567 at exact head 6b9707a. This branch carries unrelated stale-dev history; isolate the credential-log hardening feature and subsequent focused fixes (the last reviewed fix was aae298c), NOT the giant GitHub PR diff. Inspect whether all credential-bearing auth response/log paths are covered and whether the security fix is already in current dev; list blockers and a viability/close recommendation. Triage only, not a merge verdict; full gate after rebase.

@claude

claude Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Claude finished @fluck-boss's task in 4m 33s —— View job


Preliminary triage: #567 at 6b9707a2

  • Isolate the credential-log hardening feature from the stale-dev history
  • Check what is already in current dev (c3b2b5a4)
  • Sweep credential-bearing auth response/log paths for coverage
  • List blockers and give a viability/close recommendation

Triage only, not a merge verdict. Headline: the security value is real and mostly still absent from dev - do not close. But this branch cannot be mechanically rebased: replaying it over current dev would revert a landed security fix (service-role auth on /maintenance/cleanup) and delete a landed server-side masking module. Recommend re-cutting from dev and cherry-picking, split into three pieces.


Head identity

git diff aae298c1 HEAD is empty - 6b9707a2 is a tree-identical retrigger commit over the last reviewed fix. There is no new code since my aae298c1 review, so nothing below is a re-review of new work.

Isolating the feature

origin/dev is c3b2b5a4 and is no longer an ancestor (git merge-base origin/dev HEAD returns nothing in this shallow clone). bc9aa3b2 - the dev tip this branch was integrated onto - is in the ancestry, so the reviewable unit is:

git diff bc9aa3b2 HEAD   →  25 files, +820/-263

That matches the PR description exactly, and every file is an auth/logging file. The giant GitHub diff is the ~91 stale commits, not this.

The scale of the staleness, measured only inside the feature's own paths (supabase/functions/passkey, plugin-logging, the two Kotlin call sites): git diff origin/dev HEAD there is 42 files, +934/-1824. The branch would delete 1824 lines of dev from the very subsystem it touches.


What is already in dev (superseded - replaying it regresses)

Branch change State in dev
BossAppWithAuth.kt URI + sessionId masking Already landed, and better. dev:53/:138 use LogSanitizer.describeUri(uri) (drops the query entirely) and :63/:73/:85/:94 use maskSessionId(link.sessionId) off a parsed link. The branch's maskUriParams + Regex("sessionId=([^&]+)") is the weaker version of both.
CrossDeviceBrowserManager sessionId log Already landed (dev:148).
email, sessionid in sensitiveUriParamNames Already landed, with a comment naming the passkey ceremony.
Server-side email / user-id masking (my earlier finding C) Closed independently in dev by a different supabase/functions/passkey/utils/logging.ts exporting maskEmail/maskUserId/maskPasskeyId/maskSessionId, with 5 production importers and its own tests/logging.test.ts.
/maintenance/cleanup exposure (my "observation, worth a follow-up") Closed in dev - extracted to routes/maintenance.ts and gated on the service-role key, failing closed when it is unset.

What is not in dev (still genuine, and the reason to keep this)

  • authFailureDetails does not exist in dev at all (zero grep hits). dev still has ~40 console.error(..., error) sites handing raw PostgrestError/GoTrue objects to persistent logs - utils/database.ts ×12, utils/challenge.ts ×4, routes/*.ts ×8, utils/jwt.ts, utils/crypto.ts, utils/error-handler.ts.
  • The one concrete live leak on dev right now: CrossDeviceBrowserManager dev:92 and dev:132 log mapOf("url" to url) raw, and that URL is the passkey ceremony URL carrying challenge=, email=, sessionId=, credentialId=. dev's BossLogger still has no sanitizer on the data map (no LogSanitizer reference in it outside the KDoc), so this reaches the log file verbatim. This PR's two masked call sites are the fix and nothing else on dev covers them.
  • challenge, credentialId, credential_id are still missing from dev's sensitiveUriParamNames - so even dev's own maskUriParams would leak the challenge if someone wired it up at those two sites. The Kotlin half of this PR is therefore both halves: the call sites and the vocabulary.
  • nameMarksSecret's session adjacent-word rule.
  • The response-body (code) hardening in services/auth.ts / services/registration.ts - dev still returns the raw Postgres message (dev:auth.ts:400, dev:registration.ts:68, :270).
  • app.ts/client.ts entrypoint split and tests/app.test.ts's canary tests.

Contested - a policy decision, not a merge

maskToken → constant [REDACTED] and maskSessionId → maskToken. dev's newer utils/logging.ts docblock states the opposite policy in as many words: "The masked forms keep just enough of the value to correlate two lines from one request without storing the identifier." Landing this as-is leaves the host saying "no fragment, ever" while the edge function it talks to deliberately keeps a 4-char prefix - and my first review's point 2 (no correlation id was ever added to replace what the mask dropped) is still unaddressed by design. Someone has to pick one policy for both halves; that is not something a rebase resolves.


Blockers

  1. app.ts reverts dev's service-role gate on /maintenance/cleanup. The branch's app.ts:58 inlines app.post("/maintenance/cleanup", …) while dev deleted that inline route and replaced it with an authenticated routes/maintenance.ts. Replaying the branch removes a landed authentication check on an unauthenticated scan-and-delete primitive. Same mechanism as the index.ts revert I flagged at b53379dd, worse consequence. Also breaks tests/app.test.ts, which POSTs that path unauthenticated and asserts 500 - on dev it is 401.
  2. utils/logging.ts is an add/add collision with disjoint content. Branch = authFailureDetails; dev = the masking module with 5 importers. "Take ours" deletes dev's masking and breaks services/auth.ts, services/management.ts, services/mobile.ts, services/registration.ts, tests/auth.test.ts. The two need to coexist in one file (or authFailureDetails needs its own module name).
  3. tests/logging.test.ts is the same add/add collision.
  4. Test deletions. The branch drops files dev added: FileLogThresholdTest.kt (124), FileLoggingResolutionTest.kt (123), FreeTextUserInfoTest.kt (144), SupabaseKeyShapeTest.kt (80), tests/ceremony-abuse.test.ts (261), plus reverts inside auth.test.ts, mobile.test.ts, management.test.ts, registration.test.ts, rate-limit.test.ts, QueryParameterSanitizerTest.kt.
  5. BossLogger.kt reverted by 157 lines (dev's file-logging threshold/resolution work).
  6. ~16 of the 25 feature files have dev-side changes since bc9aa3b2, so this is not a two-or-three-hunk rebase.

Still-open inside the feature itself (carried from the aae298c1 review, unfixed)

Four raw database messages still reach HTTP bodies on this branch - and all four are still raw on dev too, so they are open either way:

  • services/auth.ts:210 → routes/auth.ts:196 400 on auth/complete (unauthenticated). This is the direct sibling of the case aae298c1 fixed twelve lines later at :406.
  • services/registration.ts:167 and :273 → routes/register.ts 400 on register/complete (resolveOptionalCaller, so also reachable unauthenticated).
  • services/management.ts:18 → manage/list (authenticated; its two neighbours in the same file already return constants).

Viability / recommendation

Keep, don't close - but close this branch and re-cut. Rebasing 6b9707a2 is not viable: it is not a history-cleanup problem, it is that the branch and dev independently rewrote the same subsystem and the branch's copy is older on the points where they overlap. Any rebase that resolves conflicts by favouring the branch re-opens a fixed auth hole.

Suggested split, off fresh dev, smallest-first:

  • A - Kotlin (small, highest value/line). Mask the two CrossDeviceBrowserManager URL sites (describeUri is the better fit and matches what dev's BossAppWithAuth already chose), add challenge/credentialId/credential_id to sensitiveUriParamNames, add the nameMarksSecret session pair rule, and port CredentialRedactionTest. Drop the BossAppWithAuth.kt changes entirely - superseded. This is a handful of lines and closes the one live leak.
  • B - Deno diagnostics. authFailureDetails added to dev's existing utils/logging.ts beside the mask helpers (not replacing it), the ~50 call-site wraps, the code-plumbing in storeChallenge/storeCompletedAuthentication, and the (code) response bodies - extended to cover the four sites above rather than the two already done. Rebuild tests/app.test.ts against dev's routes/maintenance.ts shape (401, not 500). The app.ts/client.ts entrypoint split is worth keeping - dev still has no test seam on index.ts - but it must mount dev's maintenance router, not re-inline the route.
  • C - separate decision. The maskToken/maskSessionId full-redaction policy, with whatever correlation mechanism replaces the dropped prefix, argued against dev's stated "keep enough to correlate" policy.

A and B are each independently mergeable and neither depends on the other.

Verification limits

Shallow checkout with no merge base to origin/dev, so everything above is git diff against explicit endpoints plus reading dev's current file contents. deno is not installed on this runner and Gradle is denied, so I executed neither suite. No CI runs are reported against this head.
• branch fix/security-credential-logging

@fluck-boss

Copy link
Copy Markdown
Collaborator

Thanks @Raghav-Bansal-15 for the credential-logging hardening. I triaged the focused work against current dev rather than the branch's much older history. There is real work here that dev still needs: CrossDeviceBrowserManager logs raw ceremony URLs at two sites, and the URL sanitizer still lacks challenge/credential-ID names; the Deno error helper and response diagnostics also have value. But this branch cannot be safely replayed as-is. Its app.ts restores an unauthenticated /maintenance/cleanup handler where dev now has a service-role gate, and its new utils/logging.ts collides with dev's masking module. Many tests and logging changes on dev would be lost if this branch won conflicts. Please re-cut the Kotlin URL sanitization as a small PR off fresh dev, and rebuild the Deno diagnostic work separately on top of dev's existing logging and authenticated maintenance router, covering the remaining raw auth/register/management error bodies. The full-redaction-versus-correlation policy also needs a maintainer choice. Claude's focused triage: #567 (comment). My recommendation is to close this stale branch after replacement PRs are opened, not to merge or force-rebase it. I will leave the closure decision to Kumar; this is not a judgment against the underlying fix.

This branch has not been deployed

No deployments
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.