Skip to content

feat(auth): harden device authorization and native admission - #5021

Merged
iscekic merged 52 commits into
mainfrom
audit-w2b-device-auth-8c25
Aug 5, 2026
Merged

feat(auth): harden device authorization and native admission#5021
iscekic merged 52 commits into
mainfrom
audit-w2b-device-auth-8c25

Conversation

@iscekic

@iscekic iscekic commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Device-auth and native admission security foundation

Summary

  • What: Harden GitHub install callbacks with random, expiring, single-use state, GitHub admin proof, app-type-aware uniqueness, and scoped lifecycle mutations.

  • Why: Prevent forged callback state, unauthorized installation claims, cross-owner attachment, and sibling app mutation.

  • How: Store and atomically consume state, prove GitHub administration when OAuth code exists, resolve duplicate claims, and scope lookups and mutations by app type and installation id.

  • What: Split device authorization into a displayed user code and a separate high-entropy device secret.

  • Why: A displayed code must not act as a polling credential.

  • How: Store only a SHA-256 device-secret digest, poll through POST, atomically consume approved requests, and protect denial with a viewer token.

  • What: Add refreshable per-device sessions and preserve legacy token compatibility.

  • Why: Native credentials need shorter lifetimes and revocation without signing out old app builds.

  • How: Issue one-hour access tokens and rotating 30-day refresh tokens for opted-in clients, exchange stored old tokens on updated launch, and revoke at refresh.

  • What: Add native Apple nonce binding, Google authorization-code exchange, and native admission verification.

  • Why: Native identity assertions and new-device sign-in need replay and abuse protection.

  • How: Bind Apple nonce, exchange Google server auth codes, verify App Attest and Play Integrity server-side, and keep a counted legacy path until rollout enforcement.

  • What: Block credential minting and shared-verifier access for blocked users.

  • Why: Blocked accounts must not receive fresh device credentials.

  • How: Deny approved device requests, revoke sessions and refresh tokens, and check blocked_reason in the existing shared verifier.

  • What: Preserve OTP codes through failed settlement and isolate attempt budgets.

  • Why: A server-side settlement failure must not consume a valid code or let another caller exhaust it.

  • How: Reserve, settle, and consume codes; add opaque challenge ids; equalize blocked-TLD responses with a 250ms floor.

  • What: Delay native sign-in analytics until authoritative gates pass.

  • Why: Refused attempts must not emit success events.

  • How: Defer the existing event payload in the native route and emit it only before a successful response.

  • What: Return GitHub app outcomes to the mobile app with a fallback.

  • Why: Users need a visible result when universal links do not open.

  • How: Use claimed /cloud/sessions routing, preserve organization context, show four outcome states, and retry with a fresh install state.

  • What: Prove compatibility and deterministic auth flows.

  • Why: Existing extension, web, and token consumers must remain usable.

  • How: Run compatibility suites and verify device auth, OTP, GitHub returns, refresh, revocation, and admission paths on iOS and Android.

Acceptance evidence

  • Full repository typecheck, lint, format check, and diff check passed on the final head.
  • Focused repaired tests passed: 122 web tests for GitHub, Sentry, Apple audience, and webhook paths; 88 auth and webhook security tests; 29 mobile outcome tests; 77 final auth repair tests.
  • C16 bot E2E passed on iOS and Android before later backend-only repairs. It covered split device auth, displayed-code refusal, OTP opacity, OTP verification, GitHub return and fallback, refresh rotation, replay revocation, session-ended UI, and legacy admission behavior.
  • A later C16 re-verification completed API checks but was blocked before mobile UI execution by a host Metro transform failure on Vite's dynamic import. The prior full mobile pass remains the runtime evidence; the blocker is recorded in the section decisions.

Rollout requirements

  • Deploy native admission in report mode first.
  • Confirm verdict rates and failure causes in logs.
  • Change to enforce only after native App Attest and Play Integrity builds are available and the E2E evidence passes.
  • Under enforce, clients that declare admission support fail closed on invalid, expired, replayed, or mismatched verdicts.
  • Clients that omit admission use the counted legacy path until legacy native sign-ins reach zero for 30 consecutive days.
  • Do not reject existing tokens without deviceSessionId; updated clients exchange them on first launch.
  • Refresh revocation takes effect at the next refresh, within one access-token lifetime.

Deployment precondition

  • C2 admin proof runs in report mode until the GitHub App setting “Request user authorization during installation” is enabled.
  • After that setting is enabled and verified, make the OAuth code requirement hard-fail when absent.

Known remaining work

  • Category A signature-only paths still need their own Worker database-binding section: Gastown, Cloud Agent Next, Wasteland, Session Ingest, AI Attribution, and the Git Token Service JWT path.
  • Category B KiloClaw checks the pepper but does not read blocked_reason; it needs its own section.
  • A real GitHub App installation and admin proof were not locally reproducible.
  • Live NATIVE_ADMISSION_MODE=enforce was not enabled in this environment; unit tests cover the enforce contract.

Non-goals

  • P2-A-02 and telemetry consent: moved to W2-A, which owns DEC-02 and the PostHog surface.
  • Consent gating: belongs to W2-A, not this ordering fix.
  • P1-D-29a: org-bound SSO destination recovery belongs to W5-C.
  • P2-E-29a: device-auth response parsing and poll bounds belong to W6-C; most behavior already exists.
  • P0-D-17a/b: logout revocation and push tombstones belong to W3-A.
  • P2-D-23a: iOS keychain accessibility belongs to W7-C.
  • Request-side OTP denial: existing email request limits bound it; this section closes verify-side budget burn.
  • Browser extension migration: its release train is separate; C15 proves it remains compatible.

Visual Changes

  • iOS sign-in completion: ios-d1-home.png
  • Android sign-in completion: android-d1-home.png

iscekic added 30 commits August 3, 2026 22:08
Restore consumed device requests after issuance failures, delete orphan sessions, and cover the repair paths.
Apply the repository formatter to the remaining changed test file.
Preserve refresh serialization and narrow device-auth poll terminal states under strict TypeScript.
@iscekic
iscekic requested a review from jeanduplessis August 5, 2026 07:06
@iscekic iscekic self-assigned this Aug 5, 2026
@iscekic
iscekic requested a review from eshurakov August 5, 2026 07:06
Comment thread apps/web/src/lib/auth/magic-link-tokens.ts Outdated
Comment thread apps/web/src/app/api/auth/native/token/route.ts Outdated
Comment thread apps/web/src/app/api/auth/native/token/route.ts Outdated
Comment thread apps/web/src/app/api/integrations/github/callback/route.ts Outdated
Comment thread packages/db/src/migrations/0204_brainy_baron_strucker.sql Outdated
Comment thread apps/mobile/src/components/security-agent/scope-entry-screen.tsx
@kilo-code-bot

kilo-code-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Executive Summary

Incremental review of commit 067889036 ("implement native admission with @expo/app-integrity"): the mobile client now produces real attestation payloads — iOS generates and attests a Secure Enclave key on first use (storing the key id only after a successful attestation), asserts with the stored key id thereafter, and clears and re-attests once on an unresolvable key id; Android prepares the Play Integrity provider once per launch, drops a rejected preparation so the next attempt retries, and binds standard requests through base64 SHA-256 of the challenge — while the server aligns both Apple paths on the hash-the-challenge-string-UTF-8-bytes convention via the single shared appAttestClientDataHash helper and accepts Play Integrity requestHash with a classic-nonce fallback, both compared against the identical digest; the challenge fetch remains outside the swallow-all catch so endpoint failures still surface as retryable errors, provider failures deliberately fall back to the server's counted legacy path, no memory leaks were introduced (one cached provider promise, no listeners or subscriptions), and the new @expo/app-integrity dependency and env-key wiring are consistent across package.json, lockfile, app config, and tests — no new issues found.

Files Reviewed (16 files)
  • ENVIRONMENT.md — Play Integrity project number correctly reclassified as a non-secret identifier that ships in the mobile bundle
  • apps/mobile/app.config.ts — App Attest production entitlement with documented dev-build behavior
  • apps/mobile/package.json@expo/app-integrity 57.0.1 dependency (matches lockfile)
  • apps/mobile/src/lib/auth/admission.ts — real iOS App Attest and Android Play Integrity admission flows
  • apps/mobile/src/lib/auth/admission.test.ts — capability gating, fresh-key attestation, stored-key assertion, invalid-key re-attest, Android requestHash binding, and failure-path coverage
  • apps/mobile/src/lib/auth/use-native-auth.test.ts — mocks for native modules pulled in by the real admission import
  • apps/mobile/src/lib/config.tsPLAY_INTEGRITY_PROJECT_NUMBER optional config
  • apps/mobile/src/lib/env-keys.js — env key wiring into Expo extra
  • apps/mobile/src/lib/storage-keys.tsATTEST_KEY_ID_KEY storage key
  • apps/web/src/lib/auth/native-admission-apple.ts — exported appAttestClientDataHash; attestation nonce check aligned to the client library convention
  • apps/web/src/lib/auth/native-admission-apple.test.ts — pins the clientDataHash convention (UTF-8 bytes, no base64url decode)
  • apps/web/src/lib/auth/native-admission-google.ts — challenge binding reads requestHash with classic nonce fallback
  • apps/web/src/lib/auth/native-admission-google.test.ts — standard-request binding, wrong-challenge refusal, and missing-binding refusal coverage
  • apps/web/src/lib/auth/native-admission.ts — assertion path uses the shared hash helper; createHash import dropped
  • apps/web/src/lib/auth/native-admission.test.ts — Apple module mock updated for the new export
  • pnpm-lock.yaml — generated lockfile update (not reviewed per rules)
Previous Review Summaries (4 snapshots, latest commit 2892037)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 2892037)

Status: No Issues Found | Recommendation: Merge

Executive Summary

Incremental review of commit 2892037ec ("parse App Attest with libraries instead of hand-rolled decoders"): the replacement of hand-rolled CBOR and DER decoders with cbor2, @peculiar/asn1-x509, and asn1js preserves the fail-closed verification contract — top-level CBOR must be a Map, every x5c entry must be a byte string before chain parsing, the nonce extension is now located by X.509 OID lookup instead of raw byte scanning with an exact SEQUENCE { [1] EXPLICIT OCTET STRING } grammar and no trailing bytes tolerated, and the COSE key flows through Node's JWK import producing canonical SPKI DER that remains verifiable by the existing assertion path; new dependencies and jest transform patterns match the lockfile tree, and updated tests cover the real Apple leaf/root certificates plus malformed grammar cases — no new issues found.

Files Reviewed (5 files)
  • apps/web/src/lib/auth/native-admission-apple.ts — CBOR/DER decoding delegated to cbor2/asn1js/@peculiar/asn1-x509; nonce extension located by OID; COSE key exported as SPKI via Node JWK import
  • apps/web/src/lib/auth/native-admission-apple.test.ts — grammar tests retargeted to exported parseAppleAttestNonceExtension; real-cert and OID-scan regression coverage added
  • apps/web/jest.config.tstransformIgnorePatterns extended for cbor2/@cto.af
  • apps/web/package.json — new runtime deps (cbor2, asn1js, @peculiar/asn1-schema, @peculiar/asn1-x509); devDependency reorder
  • pnpm-lock.yaml — generated lockfile update (not reviewed per rules)

Previous review (commit bc1e291)

Status: No Issues Found | Recommendation: Merge

Executive Summary

Incremental review of commit bc1e291cb ("reject blocked users in KiloClaw, allow members to mint install state"): blocked-user rejection is wired consistently through the KiloClaw auth middleware, DO API-key mint, and access-gateway redemption via a single findPepperByUserId select expansion against the existing kilocode_users.blocked_reason column, the member-role install-state mint matches OrganizationRole typing and the documented pre-C1 callback behavior, and the removed OTP compatibility wrapper has no remaining production callers — no new issues found.

Files Reviewed (8 files)
  • apps/web/src/lib/auth/magic-link-tokens.ts — removed caller-less verifyAndConsumeSignInCode wrapper; no remaining production references
  • apps/web/src/lib/auth/magic-link-tokens.test.ts — reserve/commit/release contract preserved via a test-local helper
  • apps/web/src/routers/github-apps-router.tsmintInstallState now permits org members; roles array matches OrganizationRole and the ensureOrganizationAccess contract
  • services/kiloclaw/src/auth/middleware.tsblocked_reason rejection after pepper validation
  • services/kiloclaw/src/auth/middleware.test.ts — DB mock extended with blocked_reason; blocked-user 401 coverage
  • services/kiloclaw/src/db/index.tsfindPepperByUserId selects blocked_reason (column exists on kilocode_users)
  • services/kiloclaw/src/durable-objects/kiloclaw-instance/config.tsmintFreshApiKey refuses blocked accounts
  • services/kiloclaw/src/routes/access-gateway.ts — code redemption refuses blocked accounts before signing the gateway token

Previous review (commit da44fa5)

Status: No Issues Found | Recommendation: Merge

Executive Summary

Incremental review of the follow-up commit da44fa578 ("resolve Kilobot findings"): all 6 previously reported issues are verifiably fixed — the legacy no-challengeId OTP path now matches the single live row per email, the googleClientId mismatch returns 400, install-state tokens are consumed before legacy-prefix dispatch, the populated-table indexes build CONCURRENTLY using the repo's COMMIT/BEGIN pattern, the Apple/Google eligibility recheck now runs before any credential persistence, and the mobile install-state mint is gated on the connect/reauthorize views — each covered by new regression tests, with no new issues introduced.

Files Reviewed (11 files)
  • apps/web/src/lib/auth/magic-link-tokens.ts - prior CRITICAL resolved (isNull(challenge_id) predicates removed; single-live-row-per-email invariant preserved by advisory-locked delete-then-insert)
  • apps/web/src/app/api/auth/native/token/route.ts - prior WARNING and SUGGESTION resolved (400 on client-id mismatch; eligibility recheck moved before session/refresh/attested-key persistence)
  • apps/web/src/app/api/integrations/github/callback/route.ts - prior WARNING resolved (DB consume now precedes legacy-prefix and bot-link dispatch)
  • packages/db/src/migrations/0204_brainy_baron_strucker.sql - prior WARNING resolved (CONCURRENTLY builds outside the transaction; dedup DO block committed before the unique index builds)
  • apps/mobile/src/components/security-agent/scope-entry-screen.tsx - prior SUGGESTION resolved (shouldMint gates minting to connect-github and URL-less reauthorize views)
  • packages/db/src/schema.ts - .concurrently() flags consistent with regenerated DDL
  • apps/web/src/lib/auth/magic-link-tokens.test.ts - new legacy-settle and budget tests match the intended behavior change
  • apps/web/src/app/api/auth/native/token/route.test.ts - new refusal-before-persistence coverage
  • apps/web/src/app/api/integrations/github/callback/route.test.ts - new prefixed-token routing coverage
  • apps/mobile/src/components/security-agent/scope-entry-screen.test.ts - new mint-gating contract coverage
  • packages/db/src/schema.test.ts - migration test correctly extracts DO block and de-CONCURRENTLYs the index inside its transaction
Skipped per review rules
  • Generated packages/db/src/migrations/meta/* snapshots and journal

Previous review (commit 74bbfac)

Status: 6 Issues Found | Recommendation: Address before merge

Executive Summary

A critical challenge-id regression in magic-link-tokens.ts makes the legacy no-challenge lookup unmatchable and breaks email OTP sign-in for all already-shipped mobile clients; secondary warnings cover a client-triggerable 500 in the native token route, an install-state prefix-collision in the GitHub callback, and non-concurrent index builds on populated tables.

Overview

Severity Count
CRITICAL 1
WARNING 3
SUGGESTION 2
Issue Details (click to expand)

CRITICAL

File Line Issue
apps/web/src/lib/auth/magic-link-tokens.ts 186 Legacy no-challengeId path requires isNull(challenge_id) but createSignInCode always sets it — shipped clients always get INVALID_CODE

WARNING

File Line Issue
apps/web/src/app/api/auth/native/token/route.ts 199 googleClientId mismatch throws generic Error → client-triggerable 500 instead of 4xx
apps/web/src/app/api/integrations/github/callback/route.ts 163 Legacy org_/user_ prefix dispatch runs before DB consume; random base64url install tokens can collide and fail deterministically
packages/db/src/migrations/0204_brainy_baron_strucker.sql 46 Indexes on populated device_auth_requests, magic_link_tokens, platform_integrations built without CONCURRENTLY

SUGGESTION

File Line Issue
apps/web/src/app/api/auth/native/token/route.ts 550 Apple/Google path persists session/refresh/attested-key rows before the post-settlement eligibility recheck
apps/mobile/src/components/security-agent/scope-entry-screen.tsx 83 Install-state minted on every mount even when connect/reauthorize UI is not shown
Files Reviewed (132 files)
  • apps/web/src/lib/auth/magic-link-tokens.ts - 1 issue
  • apps/web/src/app/api/auth/native/token/route.ts - 2 issues
  • apps/web/src/app/api/integrations/github/callback/route.ts - 1 issue
  • packages/db/src/migrations/0204_brainy_baron_strucker.sql - 1 issue
  • apps/mobile/src/components/security-agent/scope-entry-screen.tsx - 1 issue
  • apps/web/src/lib/device-auth/*, apps/web/src/app/api/device-auth/*, apps/web/src/app/device-auth/*, apps/web/src/app/api/cron/cleanup-device-auth/* - no issues (atomic consume, viewer-token HMAC, blocked-user denial verified)
  • apps/web/src/lib/auth/native-admission*.ts, native-id-tokens.ts, device-sessions.ts, tokens.ts, config.server.ts and native route tests - no issues
  • apps/web/src/lib/integrations/**, apps/web/src/routers/github-apps-router.ts, apps/web/src/components/integrations/*, apps/web/src/app/github-app/page.tsx - no other issues (state consume atomicity, return-path validation, app-type scoping verified)
  • apps/mobile/src/lib/auth/*, apps/mobile/src/lib/*, login/agents/profile screens - no other issues (poll-loop cleanup, secret hygiene, refresh rotation verified)
  • packages/db/src/schema.ts, 0205_acoustic_dorian_gray.sql, packages/worker-utils/*, services/** auth tests - no issues (schema/SQL consistency, blocked_reason verifier semantics verified)
  • apps/web/sentry.server.config.ts, apps/web/src/lib/user/*, ENVIRONMENT.md, dev/local/tmux.ts, misc UI files - no issues (no PII flags enabled; markdown images rule not triggered)
  • Generated packages/db/src/migrations/meta/* snapshots - skipped per review rules

Fix these issues in Kilo Cloud


Reviewed by kimi-k3 · Input: 101.5K · Output: 22.8K · Cached: 1.1M

Review guidance: REVIEW.md from base branch main

@iscekic iscekic added the human-ready The PR is ready for human review. label Aug 5, 2026
iscekic added 3 commits August 5, 2026 13:40
…stall state

Close the C15 Category B gap: KiloClaw read api_token_pepper but never
blocked_reason, so a blocked account kept access until its pepper rotated.
Three paths minted or accepted credentials for blocked users:

- authMiddleware: every user-facing route
- access-gateway: the cookie JWT minted after access-code redemption
- kiloclaw-instance/config: the fresh Kilo API key minted for a sandbox

Also relax mintInstallState to accept any organization member. The
pre-C1 callback called ensureOrganizationAccess with no role filter, so
restricting the mint to owner/billing_manager would have removed an
existing capability from members.

Drop verifyAndConsumeSignInCode, which lost its last production caller
when the token route moved to reserve/settle/commit. Its coverage moves
into the test file as a local helper.
…d decoders

The verifier carried its own CBOR decoder, DER TLV reader, and SPKI writer.
Hand-rolled parsers on untrusted attestation bytes are the wrong thing to own,
so delegate all three and keep only the App Attest rules.

- CBOR decoding moves to cbor2. It throws on truncated input and on trailing
  bytes, so malformed payloads still fail closed.
- The nonce extension is now located by parsing the certificate as X.509 and
  matching the extension OID, instead of scanning the raw DER for the OID
  bytes. A nonce-shaped byte run elsewhere in the certificate can no longer be
  mistaken for the extension.
- The inner SEQUENCE { [1] EXPLICIT OCTET STRING } grammar moves to asn1js and
  is exported as parseAppleAttestNonceExtension, so the grammar stays unit
  testable without re-signing a certificate.
- COSE coordinates go through a JWK and Node builds the SPKI encoding. The
  output is byte-identical to the previous writer, and Node now rejects a
  coordinate pair that is not a point on P-256.

Net 263 lines deleted, 98 added. Jest needs cbor2 and its ESM-only wtf8
dependency transformed, so both join the existing allowlist.
The client half of native admission was a stub: hasAttestationCapability
returned a hardcoded false and getAdmission returned empty payloads, so no
device ever sent an admission field and the server always took its counted
legacy path. Wire it to @expo/app-integrity, which covers App Attest on iOS
and Play Integrity on Android and ships with the Expo 57 SDK line.

iOS: attest a fresh Secure Enclave key on first use, assert with the stored
key id afterwards. An ERR_APP_INTEGRITY_INVALID_KEY rejection clears the
stored id and re-attests once, so a reinstall or a device restore recovers
instead of asserting against a key that no longer resolves. The key id is
stored only after the attestation succeeds.

Android: standard Play Integrity requests, chosen over classic for the warmed
token provider and its better quota behaviour. The provider is prepared once
per app launch behind a shared promise, and a rejected preparation is dropped
so the next sign-in retries.

Two server-side conventions had to match the library, and neither did:

- Expo hashes the UTF-8 bytes of the challenge string into clientDataHash
  (Data(challenge.utf8) through SHA256). The server was hashing the
  base64url-decoded challenge, so every attestation and every assertion would
  have failed with a nonce or signature mismatch. Both paths now share one
  exported appAttestClientDataHash so they cannot drift.
- Standard Play Integrity requests bind through requestDetails.requestHash and
  carry no nonce field. The server read only nonce, so every Android verdict
  would have been refused as NONCE_MISMATCH while the log said "missing
  nonce". It now reads requestHash and falls back to nonce so a classic-request
  build is not refused mid-rollout.

A provider failure returns undefined rather than aborting sign-in. Admission
is server-side policy and the server holds the mode switch, so failing closed
in the client would lock out rooted devices, emulators, and devices without
Play Services even while NATIVE_ADMISSION_MODE is off.

Adds the App Attest entitlement, so iOS needs a new native build. The Play
Integrity cloud project number reaches the client through the existing
optional-config map; absent, Android reports no capability and sends nothing.
@iscekic

iscekic commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Note to self:

Phase 1 — Pre-merge checks

1. Confirm CI is green on the real head. CI has not run yet for my three commits — pushing them in quick succession hit the cancel-in-progress concurrency group.
gh pr checks 5021
Wait for CI, kilo-app CI, CodeQL, Secret Scanning to all read success. Do not merge on a stale green from an older SHA.

2. Look at the 3 duplicate GitHub rows one final time. Migration 0204 will suspend 2 of them automatically. You said all 3 belong to one user id, so this is a no-op for that customer — but check the github_app_type column:
SELECT id, github_app_type, platform_installation_id, platform_account_login, installed_at
FROM platform_integrations
WHERE platform='github' AND platform_installation_id IS NOT NULL
  AND (platform, github_app_type, platform_installation_id) IN (
    SELECT platform, github_app_type, platform_installation_id
    FROM platform_integrations
    WHERE platform='github' AND platform_installation_id IS NOT NULL
    GROUP BY 1,2,3 HAVING count(*)>1)
ORDER BY installed_at DESC NULLS LAST, id DESC;
If all three show the same github_app_type, the newest survives and that user keeps working. Nothing to do. If they differ, they were never duplicates and something else is going on — stop and tell me.

3. Nothing else. Env vars are already set (Phase 3 explains when they take effect). The GitHub App "Request user authorization during installation" setting is not needed — admin proof is report-only in this PR.

Phase 2 — Hit merge

Merge the PR on GitHub. Understand what this fires, because there is no approval gate:

push to main
├── CI                          (tests again on main)
├── Deploy Web to Staging       ─┐ these run
└── Deploy to Production        ─┘ concurrently
     ├── check-production-db-startup
     ├── run-migrations          ← pnpm drizzle migrate against PRODUCTION
     ├── stage-app / stage-global-app
     ├── promote-app / promote-global-app   ← gated on run-migrations
     ├── deploy-workers          ← ships the KiloClaw blocked-user fix
     └── deploy-kiloclaw

Staging is not a gate. It deploys alongside production, not before it. There is no manual promote step and the production environment has zero required reviewers.

Phase 3 — Watch the deploy

gh run watch $(gh run list --branch main --workflow "Deploy to Production" --limit 1 --json databaseId --jq '.[0].databaseId')

Watch run-migrations closely. This applies 0204 and 0205 to production. Inside the Run Drizzle migrations step log, look for:
NOTICE:  [github-dedup] Scanning for duplicate GitHub installation rows...
NOTICE:  [github-dedup] Found 1 duplicate groups.
NOTICE:  [github-dedup] Group (app_type=..., inst_id=...): winner id=...
NOTICE:  [github-dedup] Suspended loser id=..., original_installation_id=...
Save that output. It is the only record of which rows were suspended.

If run-migrations fails: promote-app will not run, so production keeps serving the old code — good. But the migration is partly applied, because 0204 leaves the transaction three times for CREATE INDEX CONCURRENTLY. Check for an invalid index before retrying:
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
Drop any invalid index by name, fix the cause, then re-run the workflow.

When it succeeds, verify both concurrent indexes landed:
SELECT indexrelid::regclass, indisvalid FROM pg_index
WHERE indexrelid::regclass::text IN (
  'UQ_platform_integrations_github_platform_inst',
  'UQ_device_auth_requests_device_code_hash',
  'UQ_magic_link_tokens_challenge_id');
All three must show indisvalid = true.

This deploy is also what activates the three env vars I set. They were written to Vercel but no deployment was triggered, so NATIVE_ADMISSION_MODE=off, GITHUB_LEGACY_INSTALL_STATE=true, and APPLE_APP_BUNDLE_ID come into effect here. off and true both match the code's defaults, so nothing changes behaviourally — that is the point.

Phase 4 — Verify production

Five checks, in order of what breaks worst if wrong:

1. Web sign-in still works. Sign in at app.kilo.ai with email. This exercises the reserve→settle→commit rewrite.
2. GitHub App install from web works. Go to /integrations/github, click Install. This exercises mintInstallState + the DB-backed state + the rewritten callback. It is the largest behavioural change in the PR.
3. The VS Code extension still signs in. It polls the legacy GET /api/device-auth/codes/:code, which this PR rewrote to use atomic consume. Unchanged on the wire, but confirm it.
4. Existing mobile users are undisturbed. Any currently-installed app build keeps working on its long-lived token. Nothing exchanges until you ship a new build.
5. Watch Sentry for 24h for: legacy-poll-device-auth-count, native_token_legacy_long_lived_count, GitHub callback using legacy plaintext install state. These should all be non-zero and healthy. A sudden zero on the GitHub one means installs stopped working.

Phase 5 — What is NOT live after merge

Be clear on this, because "just works" has a boundary:

┌───────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────┐
│                               Thing                               │              State after merge              │
├───────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ Web auth hardening, GitHub install state, OTP reservation         │ Live                                        │
├───────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ KiloClaw blocked-user rejection                                   │ Live (via deploy-workers / deploy-kiloclaw) │
├───────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ Mobile refresh sessions, split device auth, install outcome cards │ In the repo, not on any phone               │
├───────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ Native admission (App Attest / Play Integrity)                    │ Off. Needs a native build and 4 env vars    │
└───────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────┘

The mobile release is a separate, later action, and its ordering is a hard requirement: the new app build must ship after this web deploy is live. The new client polls POST /api/device-auth/token, which does not exist on the old web deploy, and there is no fallback — a 404 is a terminal sign-in failure. Web first, always.

When you do cut the mobile build: it needs a new native build, not an OTA update, because this PR adds the App Attest entitlement to app.config.ts.

Phase 6 — Turning admission on (weeks later, not now)

Do not start this until the mobile build with @expo/app-integrity has been in the field long enough that you trust it.

1. Enable the App Attest capability on the App ID in the Apple Developer portal.
2. GCP: service account with the playintegrity scope, JSON key downloaded.
3. Play Console: link that GCP project to the Android app, enable Play Integrity, copy the SHA-256 signing cert digests (both upload and Play App Signing).
4. Set the four vars — note the project number is not a secret, answer n to the sensitivity prompt for it:
pnpm web:env set GOOGLE_PLAY_INTEGRITY_PROJECT_NUMBER       # sensitive? n
pnpm web:env set GOOGLE_PLAY_INTEGRITY_SERVICE_ACCOUNT_KEY   # sensitive? y
pnpm web:env set GOOGLE_PLAY_INTEGRITY_PACKAGE_NAME          # sensitive? n
pnpm web:env set GOOGLE_PLAY_INTEGRITY_CERT_DIGESTS          # sensitive? n
4. Also set GOOGLE_PLAY_INTEGRITY_PROJECT_NUMBER in the mobile build env — the client reads it too, to decide whether Android can attest at all.
5. Redeploy so the values take effect.
6. NATIVE_ADMISSION_MODE=report. Redeploy.
7. Test one real attestation on a physical iPhone. This is the only thing that proves the clientDataHash convention end to end. Watch for apple_attestation_failed and play_integrity_* in Sentry.
8. Only after report mode is clean and native_admission_legacy_count has drained: enforce. Understand that enforce locks rooted devices, de-Googled Android, and sideloaded APKs out of native sign-in — they fall back to "More sign-in options".

Rollback

Code: revert the merge commit and push. Production redeploys the old code.

Database: not reversible. There are no down migrations. Specifically, migration 0204 nulls platform_installation_id and suspends the 2 losing duplicate rows. A code revert does not restore them — you would have to restore those values by hand from the NOTICE output you saved in Phase 3. This is why saving that output matters.

The new tables are additive, so a code revert leaves them orphaned and harmless. No cleanup needed.```

iscekic added 2 commits August 5, 2026 14:55
…-8c25

Resolve conflicts:
- auth-context.tsx: keep the telemetry teardown from main before the
  branch credential-write lock in signOut.
- auth-context.test.ts / .test.tsx / trpc.test.ts: mock react-native,
  Sentry and the telemetry modules that auth-context now imports.
- vitest.pure.config.ts: include both new test globs.
- pnpm-lock.yaml: regenerate.
…-8c25

Resolve the drizzle migration conflict: main added 0204_drop_cost_insight_tables,
so replace the branch migrations 0204 and 0205 with one regenerated 0205 that
chains off main's snapshot. Re-apply the hand-written parts: the transaction
splits around the CONCURRENTLY indexes and the GitHub installation dedup
backfill.
@iscekic
iscekic enabled auto-merge (squash) August 5, 2026 13:50
@iscekic
iscekic merged commit a2300a5 into main Aug 5, 2026
71 checks passed
@iscekic
iscekic deleted the audit-w2b-device-auth-8c25 branch August 5, 2026 13:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

human-ready The PR is ready for human review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants