Skip to content

fix(auth)!: second-pass audit + post-fix review (iss bypass + 15 review findings)#51

Open
heznpc wants to merge 5 commits into
mainfrom
fix/second-pass-iss-allowlist
Open

fix(auth)!: second-pass audit + post-fix review (iss bypass + 15 review findings)#51
heznpc wants to merge 5 commits into
mainfrom
fix/second-pass-iss-allowlist

Conversation

@heznpc
Copy link
Copy Markdown
Member

@heznpc heznpc commented May 28, 2026

Summary

PR #46 (closed) was the second-pass audit fix; CI went red on the
npm audit --audit-level=high raise (intentional forcing function for
the Expo SDK 52 → 55 upgrade). This is the re-opened replacement with:

  1. The original second-pass audit Critical + Major fixes (carried over verbatim from fix: second-pass audit — iss bypass + 7 catch-rate gaps #46)
  2. All 15 findings from the post-fix /code-review pass, addressed

Critical (carried from #46) — iss bypass (CWE-345)

isSessionStillValid had if (claims.iss && !ALLOWED_ISSUERS.has(claims.iss)) — the leading claims.iss && short-circuited for tokens missing the iss claim entirely, bypassing the Google allowlist promised by the docstring. Fix: require typeof claims.iss === 'string' AND allowlist membership.

Major (carried from #46)

  • M1+M5: tests/app.test.js trivially-passing replaced with 7 behavioural bumper tests; bump-version.js rejects non-strict SemVer (was producing 1.2.NaN on 1.2.3-beta.1).
  • M2: handleAuthResult rejects success-without-id_token instead of persisting a userless blob.
  • M3: New tests/layout.test.js covers (app)/(auth) route-group gating.
  • M4: New tests/signin-error.test.js covers assertGoogleEnv throw → setError integration.
  • M6: Removed false-green Build verification step.
  • M7: Raised audit threshold critical → high.

Post-fix code-review fixes (this commit)

15 findings, all addressed except V5 (refuted) and F7 (documented):

ID Finding Resolution
F1 tests/env.test.js non-deterministic (babel inlines EXPO_PUBLIC_* at compile time) Deleted. Coverage now via tests/signin-error.test.js.
F2 (auth)/_layout.js rendered <Stack> during loading → flash of login on session restore Fixed — own spinner mirroring (app)/_layout.
F3 --audit-level=high blocks CD/maintenance, not just feature PRs Documented in ci.yml comment + CHANGELOG.md.
F4 handleAuthResult acquisition path missing iss check (asymmetric with rehydration) Fixed — same allowlist applied; 2 regression tests added.
F5 layout.test.js cemented flash-of-login as contract Fixed alongside F2; now asserts spinner-only.
F6 signIn happy-path test tautological (only checked error===null) Fixed — promptAsync + assertGoogleEnv now module-level jest.fn refs, both asserted invoked.
F7 bump-version.js coverage invisible to istanbul (child process) Documented — behavioural assertions still lock every branch via stdout/file-state.
F8 handleAuthResult throw untested via useEffect integration Fixed — new E2E test through mockUseAuthRequestResponse.
F9 assertGoogleEnv mock call count not cleared in beforeEach Fixed.
F10 toHaveBeenCalledWith({href:'/login'}) exact-match brittle Fixedexpect.objectContaining.
F11 signin-error.test.js promptAsync mock returned undefined not Promise Fixedjest.fn(async () => ({type:'cancel'})).
F12 fs.mkdtempSync sandboxes never cleaned up FixedafterEach purge.
F13 fix(auth)! breaking change had no CHANGELOG entry Fixed[Unreleased] migration note added.
F14 response useEffect could re-fire on StrictMode double-invoke FixedhandledResponseRef guard.
F15 "dismiss no-op" test was pre-existing fall-through, not audit-related Fixed — renamed to make conscious-pin intent explicit.
V5 package-lock.json out of sync after merge Refuted — local pre-merge artifact only.

Test results

Pre-#46 baseline After #46 (Critical+Major) After this PR (+review)
Tests 19/19 43/43 44/44
Statements 80.2% 93.57% 94.64%
Branches 62.5% 78.04% 80.45%
Functions 75% 86.36% 90.47%
Lines 85.88% 96.96% 98.01%

Expected CI behaviour

npm audit --audit-level=high will fail until Expo SDK 52 → 55 lands.
That is the intended forcing function and is now documented inline in
ci.yml and CHANGELOG.md. The CD pipelines (cd-android, cd-ios)
and maintenance.yml will also fail at that step for the same reason
— releasing on top of a known-vulnerable transitive tree is exactly
what this gate is meant to prevent.

Two paths to land:

  1. Land the SDK upgrade first; this PR's CI goes green automatically.
  2. Admin-merge with the gate-acknowledged red.

heznpc added 5 commits May 21, 2026 21:59
isSessionStillValid had `if (claims.iss && !ALLOWED_ISSUERS.has(claims.iss))`,
which short-circuits when iss is missing entirely — an iss-less id_token
slipped past the Google-only allowlist that the function's docstring
explicitly promises to enforce.

Threat model: an attacker who can write to SecureStore (rooted/jailbroken
device, shared-keychain entitlement bug, hostile companion app) could craft
an iss-less blob and the client would honour it. Real-world likelihood is
low for any single user, but this is a starter template — the reference
implementation propagates to every downstream fork, so shipping a broken
boundary here amplifies the harm.

Fix: require `typeof claims.iss === 'string'` AND allowlist membership.

Regression coverage: 6 new tests in tests/auth-context.test.js exercising
the issuer/expiry boundary directly (the original suite only fed a fixed
Google-iss happy-path token, which is why the bug survived).

Surfaced by an adversarial second-pass audit on 2026-05-21. Previously
declared done by the same session that wrote the function — A3 (happy-path
only) catch-rate gap.
Layered on top of the iss-bypass fix in this same branch. All seven
items were surfaced by the 2026-05-21 adversarial second-pass on a
session that had reported "tests pass / CI green" — pure A3/A2/D1
catch-rate gaps in the prior audit.

M1 + M5 — Version bumper had a trivially-passing test (file-existence
check only) and crashed on prerelease versions:
  - scripts/bump-version.js: reject anything not strict MAJOR.MINOR.PATCH
    (previously `"1.2.3-beta.1".split('.').map(Number)` produced NaN
    and would have committed `1.2.NaN` to app.json)
  - tests/app.test.js: replace the existence check with 7 behavioural
    tests that exercise the bumper in a sandboxed tmpdir

M2 — handleAuthResult silently persisted a `{user:null,tokens:{...:null}}`
blob when the OAuth response was `type:'success'` with no id_token.
Self-healing on next mount but a real state-corruption bug.
  - lib/auth-context.js: throw on missing id_token, do not persist
  - tests/auth-context.test.js: regression test

M3 — (app)/_layout.js gating ("Currently implemented" in README) was
never exercised by any test. screens.test.js mocked useAuth to always
return a user, so the redirect/loading branches were 0% covered.
  - tests/layout.test.js: NEW file. Covers both (app) and (auth)
    layouts across loading / signed-out / signed-in states. The "D1
    promised, not verified" gap on the auth boundary is now closed.

M4 — assertGoogleEnv throw + signIn body were uncovered:
  - tests/env.test.js: NEW. Locks the throw branch contract. Caveat
    in the file header: Expo's babel preset inlines EXPO_PUBLIC_* at
    compile time, which restricts how we can probe the positive case.
  - tests/signin-error.test.js: NEW. Isolated mock of lib/env to
    cover signIn's catch → setError → rethrow path.

M6 — ci.yml "Build verification" step swallowed errors via
`2>/dev/null || echo "skipping"`. Removed. Web export is a non-goal
per README, and the step's only effect was producing a false-green
signal.

M7 — ci.yml `npm audit --audit-level=critical` masked 23 production
advisories. Raised to `--audit-level=high`. The fix path is the
Expo SDK 52 → 55 upgrade tracked in the modernization audit; CI
going red here is the intended forcing function until that upgrade
lands. If a single transitive advisory turns out to be unfixable,
document the exception inline rather than re-weakening the gate.

Test results:
- Before second-pass: 19/19, 80.2 / 62.5 / 75 / 85.88
- After (Critical + Major): 43/43, 93.57 / 78.04 / 86.36 / 96.96

CI may go red on the audit step until SDK 52 → 55 lands. Explicit
user decision in the audit session: "둘 다 지금 — CI 빨개질 가능성을 의도된 경고로 받음".
Minor finding from the 2026-05-21 second-pass: lib/auth-context.js:165
swallows a SecureStore JSON.parse failure without deleting the blob.
Self-healing on next successful sign-in (setItemAsync overwrites), but
the explicit-purge is cheap and the silent-swallow is the kind of thing
that hides the next bug. Filed as inline TODO per audit policy — no
separate PR.
The /code-review pass over the second-pass audit PR (#46) surfaced 15
defects. All addressed in this commit; one was REFUTED (lockfile drift
was a local-pre-merge artifact, not on the tree under review).

Code:
- F4 lib/auth-context.js: handleAuthResult now enforces the same iss
  allowlist on acquisition that isSessionStillValid enforces on
  rehydration. The asymmetric enforcement let a success response with
  a missing or non-Google iss claim flip the UI to "signed in" and
  persist a blob that the next mount would delete — a visible flash
  of signed-in state for a session the system already knew was bad.
- F2 app/(auth)/_layout.js: render an ActivityIndicator while loading
  instead of falling through to <Stack>. Without this, a signed-in
  user opening a /login deep link (or hitting /login during in-flight
  nav) flashed the login UI for ~50-200ms before the redirect kicked
  in. (app)/_layout's spinner does NOT cover this case because the
  route isn't underneath (app).
- F14 lib/auth-context.js: response useEffect now keeps a `useRef` of
  the last-handled response to guard against re-firing handleAuthResult
  on StrictMode double-invoke or re-renders that keep `response`
  identity-stable. Without this, a malformed success response would
  re-throw on every render with no recovery path.

Tests:
- F1 tests/env.test.js: deleted. The babel-preset-expo
  inline-environment-variables plugin captures EXPO_PUBLIC_* at babel
  transform time, which makes the test non-deterministic between CI
  (no .env loaded -> compiled WEB === undefined -> throw fires) and
  local (.env loaded -> compiled WEB === 'real-value' -> throw doesn't
  fire). Verified by running EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID=x npx
  jest tests/env.test.js -> 2 failed; unset -> 2 passed. The
  assertGoogleEnv throw branch is still locked by signin-error.test.js
  which mocks lib/env entirely.
- F5 + F10 tests/layout.test.js: rewrote the (auth)/_layout "while
  loading" test as a positive assertion that NEITHER Redirect NOR
  Stack fires during loading (own spinner). The prior wording cemented
  the flash-of-login bug as a contract. Also switched the redirect
  assertions to `expect.objectContaining` so future Redirect prop
  additions (e.g. `replace`) don't false-alarm the gate test.
- F6 tests/auth-context.test.js: signIn happy-path test now hoists
  promptAsync + assertGoogleEnv to module-level jest.fn references and
  asserts both were called exactly once. The prior assertion was
  `expect(captured.error).toBeNull()` which is the initial state AND
  signIn's first line — passed even if signIn was a no-op.
- F8 tests/auth-context.test.js: added an end-to-end test that drives
  a malformed success response through the mocked useAuthRequest
  channel and asserts the error surfaces to context.error. Locks the
  useEffect -> .catch(setError) integration that the security claim
  depends on.
- F9 tests/auth-context.test.js: beforeEach now clears mockPromptAsync
  + mockAssertGoogleEnv + mockUseAuthRequestResponse. Without this,
  call counts accumulated across tests and any toHaveBeenCalledTimes(N)
  assertion would have been order-coupled.
- F11 tests/signin-error.test.js: promptAsync mock now returns
  `Promise<{type:'cancel'}>` instead of bare `undefined`. The original
  unfaithful shape would silently let future tests pass against a
  non-Promise stand-in.
- F12 tests/app.test.js: bumper sandbox tmpdirs are now tracked and
  purged in afterEach. Was leaking one tmpdir per test invocation.
- F15 tests/auth-context.test.js: the "dismiss is a no-op" test was
  exercising pre-existing fall-through behaviour, not anything the
  second-pass audit added; renamed to make explicit that it's a
  conscious pin against a future "throw on unknown type" refactor,
  not a regression guard from the audit. Added F4 acquisition-iss
  regression tests alongside.

CI / docs:
- F3 .github/workflows/ci.yml: documented the CD / maintenance impact
  of the `--audit-level=high` raise. cd-android.yml, cd-ios.yml, and
  maintenance.yml reuse this workflow with `needs: ci`, so all three
  also fail at the audit step until Expo SDK 52 -> 55 lands. That's
  intentional; the comment now says so.
- F13 CHANGELOG.md: added an [Unreleased] entry covering the security
  breaking change + migration guidance for users on legacy installs
  whose persisted session may be invalidated on first launch after
  upgrade.

Refuted in verification:
- V5 (package-lock.json out-of-sync) was an artifact of the local
  pre-merge branch tip. After merging origin/main the lockfile is
  consistent at eslint 10.4.0.

Not addressable in code:
- F7 (bump-version.js coverage invisible to istanbul) is real — the
  bumper runs via child_process so its lines don't count toward the
  coverage report. The behavioural tests still exercise every branch
  via stdout / file-state assertions, but the coverage gate has no
  visibility. Documented here rather than restructured, since the
  alternative (extracting bumper into a require()-able module) would
  bloat a 30-line script.

Tests: 19 -> 44 (43 net + 1 from F8 integration, minus 4 deleted env
tests, plus F4 regression tests). Coverage: 80.2 / 62.5 / 75 / 85.88 ->
94.64 / 80.45 / 90.47 / 98.01.
@heznpc heznpc enabled auto-merge (squash) May 28, 2026 21:47
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