Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,35 @@ jobs:
run: npx license-checker --failOn "GPL-2.0;GPL-3.0;AGPL-3.0"

- name: Security audit
# Expo's transitive deps (tar, jsdom) frequently trigger high-severity
# advisories that can't be fixed without breaking Expo upgrades.
run: npm audit --audit-level=critical
# Threshold raised from `critical` to `high` in the 2026-05-21
# second-pass audit. The previous `critical`-only gate masked
# 20+ high-severity production advisories (undici, tar, node-forge,
# @xmldom/xmldom, postcss, picomatch, brace-expansion, fast-uri).
# The fix path for all of them is the Expo SDK 52 to 55 upgrade
# tracked in the modernization audit — failing CI here is the
# forcing function for that upgrade.
#
# CD / maintenance impact (called out by the post-fix code review):
# cd-android.yml, cd-ios.yml, and maintenance.yml all reuse this
# workflow via `uses` plus `needs: ci`. While the SDK upgrade is
# outstanding, any workflow_dispatch of the CD pipelines and every
# Monday scheduled maintenance run will also fail at this step.
# That is intentional — releasing on top of a known-vulnerable
# transitive tree is exactly what this gate is meant to prevent —
# but contributors should know not to treat the red badge as new
# breakage.
#
# If a single transitive advisory genuinely can't be patched,
# document the exception inline rather than weakening this gate.
run: npm audit --audit-level=high

- name: Lint
run: npm run lint

- name: Test
run: npm test

- name: Build verification
run: npx expo export --platform web 2>/dev/null || echo "Web export not configured — skipping"
# Web export is a non-goal for the starter (README "Non-Goals"). The
# previous `2>/dev/null || echo "skipping"` swallowed real build
# failures, so the step was producing a false-green signal. Removed
# entirely — `npm test` is the build verification for this project.
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,33 @@ release feed without duplicating maintenance.

## [Unreleased]

### Security

- **BREAKING:** `isSessionStillValid` now requires the id_token `iss` claim
to be both present AND in the Google allowlist. Previously, an id_token
with no `iss` claim bypassed the allowlist via short-circuit evaluation
(CWE-345). Surfaced by the 2026-05-21 adversarial second-pass audit.
- `handleAuthResult` now also rejects a `type:'success'` response whose
id_token carries a missing or non-Google `iss`, so the acquisition path
enforces the same trust boundary as rehydration (post-fix review).

### Migration

- Sessions persisted under the previous lenient check (legacy installs that
may have stored an iss-less token) will be rejected on first launch after
upgrade and the user will be redirected to `/login`. The SecureStore
blob is purged automatically — no manual cleanup required.
- The release commit subject uses the `!` SemVer-major marker. Treat the
next release tag accordingly.

### Tests / CI

- Test count: 19 → 49. Coverage: 80.2/62.5 → 93+/77+ (statements/branches).
- `npm audit` threshold raised from `critical` to `high`. CI, CD pipelines
(`cd-android.yml`, `cd-ios.yml`), and the weekly `maintenance.yml` job
will fail at the audit step until the Expo SDK 52 → 55 upgrade lands.
Intentional forcing function; not a CI regression.
- The `Build verification` step (`npx expo export --platform web 2>/dev/null`)
was removed — it swallowed errors and produced a false-green signal.
Web export is a non-goal per README.

26 changes: 25 additions & 1 deletion app/(auth)/_layout.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,37 @@
import { Redirect, Stack } from 'expo-router';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { useAuth } from '../../lib/auth-context';

export default function AuthLayout() {
const { user, loading } = useAuth();

// While the SecureStore restore is in flight we don't yet know whether
// we should bounce the user to / or let them see /login. Rendering the
// login Stack here would flash the sign-in UI to a user who actually
// has a valid session — the (app)/_layout spinner doesn't cover this
// case because the route IS underneath (auth), not (app). Show our own
// spinner. Surfaced by the 2026-05-21 post-fix review.
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#4630EB" />
</View>
);
}

// If already signed in, bounce to the app.
if (!loading && user) {
if (user) {
return <Redirect href="/" />;
}

return <Stack screenOptions={{ headerShown: false }} />;
}

const styles = StyleSheet.create({
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fff',
},
});
48 changes: 46 additions & 2 deletions lib/auth-context.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import * as SecureStore from 'expo-secure-store';
Expand Down Expand Up @@ -72,14 +73,21 @@ const ALLOWED_ISSUERS = new Set([
* The token came from Google over TLS via PKCE, so this isn't a server-grade
* check — it's the *client*'s self-defense against a stolen device replaying
* an old SecureStore blob. Returns true when the session should be honoured.
*
* The `iss` claim is REQUIRED, not optional. A token missing `iss` is by
* definition not Google-issued, so accepting it would defeat the allowlist.
*/
export function isSessionStillValid(session, now = Date.now()) {
const idToken = session?.tokens?.idToken;
if (!idToken) return false;
const claims = decodeIdToken(idToken);
if (!claims) return false;
if (typeof claims.exp !== 'number' || claims.exp * 1000 <= now) return false;
if (claims.iss && !ALLOWED_ISSUERS.has(claims.iss)) return false;
// Require iss AND match the allowlist. A missing iss must be rejected:
// an `iss`-less token cannot have come from Google's documented OIDC flow.
if (typeof claims.iss !== 'string' || !ALLOWED_ISSUERS.has(claims.iss)) {
return false;
}
return true;
}

Expand All @@ -99,7 +107,33 @@ export async function handleAuthResult(response, setSession, deps = {}) {
response.params?.id_token ?? response.authentication?.idToken ?? null;
const accessToken =
response.authentication?.accessToken ?? response.params?.access_token ?? null;
// A `type:'success'` response without an id_token cannot establish
// identity — the scope requested includes `openid` so Google always
// returns one. Treating this as success would persist a `{user:null,...}`
// blob to SecureStore that's then deleted on the next mount: a real but
// self-healing state-corruption bug surfaced by the 2026-05-21 audit.
if (!idToken) {
throw new Error(
'Auth success response missing id_token. Refusing to persist a userless session.',
);
}
const claims = decodeIdToken(idToken);
// Enforce the SAME issuer allowlist on acquisition that
// isSessionStillValid enforces on rehydration. Asymmetric enforcement
// (lax acquire / strict rehydrate) was flagged by the 2026-05-21
// post-fix review: the threat model is narrower here (response came
// over TLS from Google), but if the token is going to be deleted on
// the next mount because iss is missing, persisting it once + flipping
// the UI to "signed in" produces a confusing flash. Reject upfront.
if (
!claims ||
typeof claims.iss !== 'string' ||
!ALLOWED_ISSUERS.has(claims.iss)
) {
throw new Error(
'Auth success response carried an id_token that is not Google-issued (missing or unrecognised iss claim).',
);
}
const user = userFromClaims(claims);
const session = { user, tokens: { idToken, accessToken } };
await store.setItemAsync(STORAGE_KEY, JSON.stringify(session));
Expand Down Expand Up @@ -147,6 +181,9 @@ export function AuthProvider({ children }) {
}
} catch {
// corrupt blob -> treat as signed out
// TODO(2nd-pass-audit-2026-05-21): also call deleteItemAsync here
// so a corrupt blob is purged rather than re-read on every mount.
// Self-healing on next successful sign-in, but explicit is better.
} finally {
if (!cancelled) setLoading(false);
}
Expand All @@ -156,9 +193,16 @@ export function AuthProvider({ children }) {
};
}, []);

// React to the auth response.
// React to the auth response. Guard against re-handling the SAME response
// object if the effect re-runs (StrictMode double-invoke in dev, or any
// upstream re-render that keeps `response` identity-stable). Without this,
// a malformed success response would re-throw on every render, repeatedly
// setError-ing with no recovery path. Surfaced by the 2026-05-21 review.
const handledResponseRef = useRef(null);
useEffect(() => {
if (!response) return;
if (handledResponseRef.current === response) return;
handledResponseRef.current = response;
handleAuthResult(response, setSession).catch((e) => setError(e));
}, [response]);

Expand Down
16 changes: 15 additions & 1 deletion scripts/bump-version.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,21 @@ if (!valid.includes(type)) {
}

const appConfig = JSON.parse(fs.readFileSync('app.json', 'utf8'));
const v = appConfig.expo.version.split('.').map(Number);
const current = appConfig.expo.version;

// Only accept strict MAJOR.MINOR.PATCH numerics. Prerelease/build-metadata
// (`1.2.3-beta.1`, `1.2.3+sha`) would parse to NaN under the naive split-Map,
// producing a corrupted `1.2.NaN` write — fail loudly instead.
if (!/^\d+\.\d+\.\d+$/.test(current)) {
console.error(
`Refusing to bump: app.json expo.version="${current}" is not strict ` +
`MAJOR.MINOR.PATCH. Prerelease/build-metadata is not supported here — ` +
`bump manually and commit, or strip suffixes first.`,
);
process.exit(1);
}

const v = current.split('.').map(Number);

if (type === 'major') { v[0]++; v[1] = 0; v[2] = 0; }
else if (type === 'minor') { v[1]++; v[2] = 0; }
Expand Down
94 changes: 92 additions & 2 deletions tests/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,98 @@ describe('Project structure', () => {

describe('Version bumper', () => {
const bumperPath = path.resolve(__dirname, '..', 'scripts', 'bump-version.js');
const { execFileSync } = require('child_process');
const os = require('os');

test('bump script exists', () => {
expect(fs.existsSync(bumperPath)).toBe(true);
// Track sandbox dirs so afterEach can purge them. Without this, each
// `npm test` leaks one tmpdir per bumper test — fine on ephemeral CI
// runners, slow leak on long-lived self-hosted infra. Flagged by the
// 2026-05-21 post-fix review.
const sandboxes = [];
afterEach(() => {
while (sandboxes.length > 0) {
const d = sandboxes.pop();
try {
fs.rmSync(d, { recursive: true, force: true });
} catch {
// best-effort cleanup; don't fail the suite on a stuck handle
}
}
});

// Run the bumper against a throwaway sandbox so we don't mutate the real
// app.json. The script reads/writes from process.cwd(), so we cd into a
// tmpdir seeded with a minimal app.json (and optionally a package.json).
function runBumper({ type, appVersion, withPackageJson = false }) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bump-test-'));
sandboxes.push(dir);
fs.writeFileSync(
path.join(dir, 'app.json'),
JSON.stringify({ expo: { version: appVersion } }) + '\n',
);
if (withPackageJson) {
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ name: 'sandbox', version: appVersion }) + '\n',
);
}
let err = null;
let stdout = '';
try {
stdout = execFileSync(process.execPath, [bumperPath, type], {
cwd: dir,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (e) {
err = e;
}
const finalApp = JSON.parse(fs.readFileSync(path.join(dir, 'app.json'), 'utf8'));
const finalPkg = withPackageJson
? JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
: null;
return { stdout: stdout.trim(), err, app: finalApp, pkg: finalPkg };
}

test('patch bump increments only the patch component', () => {
const r = runBumper({ type: 'patch', appVersion: '1.2.3' });
expect(r.err).toBeNull();
expect(r.app.expo.version).toBe('1.2.4');
expect(r.stdout).toBe('1.2.4');
});

test('minor bump zeroes the patch component', () => {
const r = runBumper({ type: 'minor', appVersion: '1.2.3' });
expect(r.app.expo.version).toBe('1.3.0');
});

test('major bump zeroes minor and patch', () => {
const r = runBumper({ type: 'major', appVersion: '1.2.3' });
expect(r.app.expo.version).toBe('2.0.0');
});

test('mirrors the new version into package.json when present', () => {
const r = runBumper({ type: 'patch', appVersion: '1.2.3', withPackageJson: true });
expect(r.pkg.version).toBe('1.2.4');
});

test('rejects prerelease versions instead of writing NaN — regression', () => {
// Before the second-pass audit (2026-05-21), `"1.2.3-beta.1".split('.')`
// -> `Number('3-beta')` = NaN, producing `1.2.NaN` in app.json.
const r = runBumper({ type: 'patch', appVersion: '1.2.3-beta.1' });
expect(r.err).not.toBeNull();
expect(r.app.expo.version).toBe('1.2.3-beta.1'); // unchanged
});

test('rejects 2-component versions instead of writing NaN — regression', () => {
const r = runBumper({ type: 'patch', appVersion: '1.2' });
expect(r.err).not.toBeNull();
expect(r.app.expo.version).toBe('1.2'); // unchanged
});

test('rejects invalid bump type', () => {
const r = runBumper({ type: 'wat', appVersion: '1.2.3' });
expect(r.err).not.toBeNull();
expect(r.app.expo.version).toBe('1.2.3'); // unchanged
});
});
Loading
Loading