Skip to content

fix: stop embedding OAuth token in Inngest audit event payload#212

Closed
anshul23102 wants to merge 2 commits into
Coder-s-OG-s:mainfrom
anshul23102:fix/204-oauth-token-inngest-exposure
Closed

fix: stop embedding OAuth token in Inngest audit event payload#212
anshul23102 wants to merge 2 commits into
Coder-s-OG-s:mainfrom
anshul23102:fix/204-oauth-token-inngest-exposure

Conversation

@anshul23102
Copy link
Copy Markdown

Fixes #204

Problem

bootstrapProfile was passing the user's live GitHub OAuth provider token as accessToken inside the inngest.send() payload for the audit/run event. Inngest persists event payloads to its own cloud storage, so a long-lived OAuth credential was leaving the application's trust boundary every time a new user completed sign-in.

Root cause

// before
const providerToken = (await sb.auth.getSession()).data.session?.provider_token;
if (providerToken) {
  await inngest.send({
    name: 'audit/run',
    data: { userId, githubHandle, githubId, accessToken: providerToken },
  });
}

The OAuth token has a lifespan measured in hours or days. Once it lands in Inngest event storage it persists indefinitely and cannot be rotated from the application side.

Fix

The audit-run function already supports two safer auth paths:

  1. installationId in event -- the function mints a short-lived GitHub App installation token at execution time. These tokens expire in one hour and are never stored anywhere.
  2. Live DB lookup -- if no installationId is provided, the function queries github_installations at execution time and mints an install token from whatever it finds.

bootstrapProfile now:

  1. Queries github_installations for the user's active install at event-send time and passes installationId if found.
  2. If no install exists yet (common on first login, before the install webhook arrives), installationId is omitted. The function performs its own DB lookup at execution time, by which point the webhook will typically have landed.
  3. Removes the sb.auth.getSession() call entirely since the token is no longer needed.
  4. Queues the audit unconditionally instead of gating on token existence -- the function exits cleanly with { skipped: true, reason: 'no_auth_source' } in the rare case where neither path finds auth.

The accessToken field is retained in AuditEvent and in the function's fallback path to handle any events already in the queue, but is marked @deprecated so no new code reaches for it.

Files changed

File Change
src/app/actions/profile.ts Replace token retrieval + conditional send with install lookup + unconditional send
src/inngest/functions/audit-run.ts Update JSDoc to reflect new auth precedence; mark accessToken as deprecated
src/app/actions/recommendations.ts Fix TS strict-null false positive on rows[0] after explicit length guard (caught by tsc --noEmit)

Testing

  • tsc --noEmit passes with zero errors.
  • Existing audit-run fallback logic is unchanged; only the call site is modified.
  • Users who sign in before installing the GitHub App will still get audited once the install webhook fires (the DB lookup in audit-run covers this).

Fixes Coder-s-OG-s#205

The previous implementation enforced the 3-claim cap with two independent
database round-trips: a COUNT query, then a separate UPDATE. Between those
two steps, a second concurrent request for the same user could also read
count < 3, pass the guard, and commit its own UPDATE. Sending multiple
claim requests in parallel (trivially via Promise.all) reproduced the bug
reliably -- the user ended up with more than 3 active claims.

Fix: replaced the two-step sequence with a call to a new database function,
claim_recommendation_atomic, added in migration 0012. The function merges
the count check and the write into a single UPDATE statement. The subquery
that evaluates the claim count and the row that gets written are handled
atomically by the database engine, so concurrent calls are serialised at
the row level and only one can succeed when the count is at the limit.

Migration 0012 (supabase/migrations/0012_claim_recommendation_atomic.sql):
  - Defines claim_recommendation_atomic(p_rec_id bigint, p_user_id uuid)
    returning TABLE(id bigint).
  - The UPDATE only proceeds if the caller's current claimed count is
    strictly less than 3 AND the target rec is still open. Zero rows
    returned covers both rejection cases (limit reached or rec not open).
  - SECURITY DEFINER with SET search_path = public prevents search-path
    injection.
  - GRANT EXECUTE given to both authenticated and service_role so server
    actions and background functions can call it without privilege changes.

recommendations.ts:
  - Removed the COUNT query and the subsequent maybeSingle UPDATE.
  - Added service.rpc('claim_recommendation_atomic', ...) in their place.
  - Interprets zero returned rows as a unified claim_limit_or_not_open
    error; the UI already re-fetches state on any error so the distinction
    between the two rejection reasons is not needed client-side.
  - data.id reference updated to claimedId extracted from the RPC result.
bootstrapProfile was passing the user's live GitHub OAuth provider token
directly inside inngest.send() for the audit/run event. Inngest persists
event payloads to third-party cloud storage, so this exposed long-lived
credentials outside the application's trust boundary.

The audit-run function already supports a three-tier auth fallback:
1. installationId from the event (minted into a short-lived install token
   at execution time)
2. Live DB lookup of github_installations at execution time
3. accessToken from the event (legacy path, retained for backward compat)

bootstrapProfile now looks up the user's active installation at event-send
time and passes installationId instead of accessToken. If no installation
exists yet (common at first login), installationId is omitted and the
function performs the DB lookup at execution time, by which point the
install webhook will typically have landed.

Also removes the getSession() call that was only needed to retrieve the
provider token, and fixes a TypeScript strict-null error in
claimRecommendation (rows[0] after an explicit length guard).

Closes Coder-s-OG-s#204
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 25, 2026

@anshul23102 is attempting to deploy a commit to the codersogs-3057's projects Team on Vercel.

A member of the Team first needs to authorize it.

@anshul23102
Copy link
Copy Markdown
Author

Ready for review. The fix removes the GitHub OAuth token from the Inngest event payload entirely and replaces it with an installation ID lookup. TypeScript passes clean with tsc --noEmit.

@anshul23102
Copy link
Copy Markdown
Author

Closing in favour of #222, which supersedes this with a complete unit test suite (3 tests covering the installationId path, the no-install skip path, and the already-audited path) and a cleaner implementation that avoids the unconditional send.

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.

Security: GitHub OAuth access token transmitted to Inngest and persisted in third-party event storage

1 participant