Skip to content

CLD-03 follow-up: DB-backed auth codes, PKCE, account linking#812

Merged
Chris0Jeky merged 19 commits intomainfrom
feature/oauth-pkce-account-linking
Apr 12, 2026
Merged

CLD-03 follow-up: DB-backed auth codes, PKCE, account linking#812
Chris0Jeky merged 19 commits intomainfrom
feature/oauth-pkce-account-linking

Conversation

@Chris0Jeky
Copy link
Copy Markdown
Owner

Summary

Implements #676 — three concrete improvements to the GitHub OAuth integration:

  • In-memory auth code store replaced with SQLite/EF Core: Auth codes now persist across restarts and work in multi-instance deployments. Uses OAuthAuthCode entity with atomic TryConsumeAtomicAsync for race-safe single-use semantics via raw SQL UPDATE WHERE IsConsumed = 0.

  • PKCE enabled: Sets UsePkce = true on the GitHub OAuth handler. ASP.NET Core 8 automatically generates code_verifier/code_challenge — defense-in-depth against authorization code interception.

  • Account linking: Authenticated users can link/unlink GitHub accounts from the Settings page. Uses a dedicated link flow: GET /api/auth/github/login?mode=link stores GitHub identity in a link code, then POST /api/auth/github/link exchanges the code while requiring JWT auth. Prevents linking already-linked accounts with proper conflict detection.

Key files changed

Backend:

  • backend/src/Taskdeck.Domain/Entities/OAuthAuthCode.cs — New entity with Purpose (login/link), TryConsume, CreateForLinking
  • backend/src/Taskdeck.Api/Controllers/AuthController.cs — DB-backed exchange, link/unlink/linked-accounts endpoints
  • backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs — PKCE enabled
  • backend/src/Taskdeck.Application/Services/AuthenticationService.cs — CompleteAccountLinkAsync, UnlinkExternalLoginAsync

Frontend:

  • frontend/taskdeck-web/src/views/ProfileSettingsView.vue — Linked Accounts section with Link/Unlink GitHub
  • frontend/taskdeck-web/src/api/authApi.ts — linkGitHub, unlinkGitHub, getLinkedAccounts
  • frontend/taskdeck-web/src/types/auth.ts — LinkedAccount type

Tests:

  • 15 domain tests for OAuthAuthCode entity
  • 9 application tests for account linking/unlinking
  • Updated OAuthTokenLifecycleTests for DB-backed store (concurrent exchange, cleanup, replay)
  • Updated AuthControllerEdgeCaseTests for atomic consume
  • Updated ProfileSettingsView test for router/auth mocks

Test plan

  • Backend: dotnet test backend/Taskdeck.sln -c Release -m:1 — all 1181 tests pass
  • Frontend: npm run typecheck && npx vitest --run — all 1898 tests pass, no errors
  • Manual: Verify GitHub OAuth login still works end-to-end
  • Manual: Test account linking from Settings page
  • Manual: Verify PKCE parameters in GitHub authorization URL

Closes #676

Replaces the in-memory ConcurrentDictionary approach with a persistent
entity that tracks code, userId, token, expiry, and consumption state.
Supports single-use semantics via TryConsume() with TTL enforcement.

Part of #676.
Defines the application-layer contract for auth code persistence with
GetByCodeAsync and DeleteExpiredAsync for TTL cleanup.

Part of #676.
Registers OAuthAuthCodes table with unique index on Code and index on
ExpiresAt for TTL cleanup. Wires repository through DI and UnitOfWork.

Part of #676.
Creates OAuthAuthCodes table with unique Code index, ExpiresAt index
for TTL cleanup, and FK cascade to Users.

Part of #676.
…ccount linking

- OAuthAuthCode entity with Purpose field supporting both login and link flows
- DB-backed exchange with TryConsume() for single-use, TTL, replay prevention
- AuthController: new IUnitOfWork dependency, async ExchangeCode, CleanupExpiredCodesAsync
- Account linking: POST github/link, DELETE github/link, GET linked-accounts endpoints
- AuthenticationService: CompleteAccountLinkAsync, UnlinkExternalLoginAsync
- LinkedAccountDto for account linking API responses
- Updated all test stubs for IUnitOfWork.OAuthAuthCodes
- Rewrote OAuthTokenLifecycleTests and AuthControllerEdgeCaseTests for DB-backed store

Part of #676.
Sets UsePkce = true on the GitHub OAuth handler. ASP.NET Core 8
automatically generates code_verifier and sends code_challenge in
the authorization request, then includes code_verifier in the token
exchange. Defense-in-depth against authorization code interception.

Part of #676.
- LinkedAccount type and API methods (link, unlink, getLinkedAccounts)
- ProfileSettingsView: shows linked GitHub account with avatar and
  display name, Link/Unlink buttons, handles oauth_link_code from
  redirect callback
- Only shown when GitHub OAuth is configured and not in demo mode

Part of #676.
- OAuthAuthCodeTests: 13 tests covering construction validation,
  TryConsume single-use semantics, expiry, CreateForLinking
- AccountLinkingTests: 8 tests covering CompleteAccountLinkAsync
  and UnlinkExternalLoginAsync with conflict, not-found, and
  forbidden edge cases

Part of #676.
- Add TryConsumeAtomicAsync for race-safe single-use code exchange
  using raw SQL UPDATE WHERE IsConsumed = 0
- Fix DeleteExpiredAsync to use in-memory filtering since EF Core 8
  with SQLite cannot translate DateTimeOffset comparisons in LINQ
- Update AuthController to use atomic consume in both exchange and
  link endpoints
- Update tests for new atomic consume behavior

Part of #676.
The settings view now uses useRouter/useRoute for OAuth link code
handling. Add mocks for vue-router, authApi, and demoMode to
prevent test failures.

Part of #676.
Copilot AI review requested due to automatic review settings April 9, 2026 18:32
…scope

- Read mode from OAuth state (authenticateResult.Properties.Items)
  instead of trusting the query string parameter, preventing state
  manipulation attacks
- Fix fire-and-forget cleanup that could run after scope disposal
  by awaiting it within the request
- Remove unused LinkExternalLoginAsync from interface and service

Part of #676.
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a database-backed storage mechanism for OAuth authorization codes, replacing the previous in-memory ConcurrentDictionary implementation. This change enables support for multi-instance deployments and persistence across application restarts. The PR also adds functionality for account linking, allowing users to associate external providers like GitHub with their accounts, and includes necessary database migrations and repository interfaces. I have provided feedback regarding the potential for ObjectDisposedException in the fire-and-forget cleanup task, the use of parameterized queries for DateTimeOffset values, and the efficiency of the bulk deletion logic in the repository.

await _unitOfWork.SaveChangesAsync();

// Best-effort cleanup of expired codes
_ = CleanupExpiredCodesAsync();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Executing CleanupExpiredCodesAsync as a fire-and-forget task (_ = ...) will result in an ObjectDisposedException. The AuthController and its injected IUnitOfWork are scoped to the request; when the request completes (which happens immediately upon returning the Redirect), the scope and the underlying DbContext are disposed. Any background task still running will fail when it attempts to access the database. Since this is a fast operation, it should be awaited directly.

Comment on lines +25 to +29
var affected = await _context.Database.ExecuteSqlRawAsync(
"UPDATE OAuthAuthCodes SET IsConsumed = 1, ConsumedAt = {0}, UpdatedAt = {1} WHERE Code = {2} AND IsConsumed = 0",
now.ToString("o"),
now.ToString("o"),
code);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Avoid manually stringifying DateTimeOffset values when using ExecuteSqlRawAsync. EF Core's SQLite provider is designed to handle DateTimeOffset parameters correctly by mapping them to the appropriate storage format. Passing the objects directly is more robust and ensures consistent behavior across different environments or provider configurations.

Comment on lines +37 to +49
// queries. Load all codes and filter in memory for cleanup.
// Auth codes are short-lived and few in number, so this is acceptable.
var allCodes = await _context.Set<OAuthAuthCode>()
.ToListAsync(cancellationToken);

var expired = allCodes.Where(e => e.ExpiresAt < cutoff).ToList();

if (expired.Count == 0)
return 0;

_context.Set<OAuthAuthCode>().RemoveRange(expired);
await _context.SaveChangesAsync(cancellationToken);
return expired.Count;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Loading all authorization codes into memory to perform cleanup is inefficient and will not scale. While LINQ translation for DateTimeOffset comparisons can be problematic in SQLite, you can perform an efficient bulk deletion using a raw SQL DELETE statement. This executes entirely on the database side, avoiding the overhead of fetching records and change tracking.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements issue #676 by hardening the GitHub OAuth flow: persist auth codes in the DB for multi-instance/restart safety, enable PKCE on the GitHub OAuth handler, and add account linking/unlinking (backend endpoints + frontend Settings UI).

Changes:

  • Replaced in-memory OAuth auth-code storage with an EF Core OAuthAuthCode entity + repository with atomic consume semantics.
  • Enabled PKCE (UsePkce = true) for GitHub OAuth.
  • Added authenticated GitHub account linking/unlinking endpoints and a “Linked Accounts” section in profile settings.

Reviewed changes

Copilot reviewed 31 out of 32 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
frontend/taskdeck-web/src/views/ProfileSettingsView.vue Adds UI/state for GitHub link/unlink and consumes oauth_link_code from query params.
frontend/taskdeck-web/src/api/authApi.ts Adds API calls for linked-accounts + link/unlink GitHub.
frontend/taskdeck-web/src/types/auth.ts Introduces LinkedAccount type.
frontend/taskdeck-web/src/tests/views/ProfileSettingsView.spec.ts Mocks router/auth APIs for the new linked-accounts logic.
backend/src/Taskdeck.Api/Controllers/AuthController.cs Adds DB-backed exchange + link/unlink/linked-accounts endpoints and link-mode callback behavior.
backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs Enables PKCE on GitHub OAuth handler.
backend/src/Taskdeck.Domain/Entities/OAuthAuthCode.cs New auth-code domain entity (login/link purposes + consume semantics).
backend/src/Taskdeck.Infrastructure/Repositories/OAuthAuthCodeRepository.cs Repository for auth codes (lookup, atomic consume, cleanup).
backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs Adds DbSet<OAuthAuthCode>.
backend/src/Taskdeck.Infrastructure/Persistence/Configurations/OAuthAuthCodeConfiguration.cs EF mapping for OAuthAuthCode.
backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs Wires OAuthAuthCodes into UnitOfWork.
backend/src/Taskdeck.Infrastructure/DependencyInjection.cs Registers IOAuthAuthCodeRepository.
backend/src/Taskdeck.Infrastructure/Migrations/AddOAuthAuthCodes (+ snapshot) Adds the new table + indexes.
backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs Adds OAuthAuthCodes repository to UoW contract.
backend/src/Taskdeck.Application/Interfaces/IOAuthAuthCodeRepository.cs New repository interface.
backend/src/Taskdeck.Application/DTOs/UserDtos.cs Adds LinkedAccountDto.
backend/src/Taskdeck.Application/Services/IAuthenticationService.cs Adds link/unlink-related methods.
backend/src/Taskdeck.Application/Services/AuthenticationService.cs Implements account link/unlink behavior.
backend/tests/* Adds/updates tests for the new auth-code store and account linking; updates fake UoW implementations accordingly.
Files not reviewed (1)
  • backend/src/Taskdeck.Infrastructure/Migrations/20260409181436_AddOAuthAuthCodes.Designer.cs: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +90 to +94
function startGitHubLink() {
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api'
const returnUrl = '/workspace/settings'
window.location.href = `${apiBase}/auth/github/login?mode=link&returnUrl=${encodeURIComponent(returnUrl)}`
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startGitHubLink() hard-codes returnUrl to /workspace/settings, but the actual profile settings route is /workspace/settings/profile (there is no /workspace/settings route). This will redirect users to a non-existent client route after linking; use route.path or /workspace/settings/profile instead.

Copilot uses AI. Check for mistakes.
Comment on lines +242 to 247
await _unitOfWork.OAuthAuthCodes.AddAsync(authCode);
await _unitOfWork.SaveChangesAsync();

// Best-effort cleanup of expired codes
_ = CleanupExpiredCodesAsync();

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_ = CleanupExpiredCodesAsync(); is fire-and-forget but uses the request-scoped _unitOfWork/DbContext. Once the request completes the scope may be disposed, causing cleanup to fail silently or produce unobserved task issues. Either await the cleanup, or move cleanup to a background service / create a new DI scope inside the cleanup routine.

Copilot uses AI. Check for mistakes.
Comment on lines +345 to +352
if (string.IsNullOrWhiteSpace(authCode.ProviderData))
return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Link code contains no provider data"));

var providerInfo = JsonSerializer.Deserialize<JsonElement>(authCode.ProviderData);
var provider = providerInfo.GetProperty("provider").GetString() ?? "GitHub";
var providerUserId = providerInfo.GetProperty("providerUserId").GetString();
var displayName = providerInfo.TryGetProperty("displayName", out var dn) ? dn.GetString() : null;
var avatarUrl = providerInfo.TryGetProperty("avatarUrl", out var av) ? av.GetString() : null;
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProviderData is parsed via JsonSerializer.Deserialize<JsonElement>() and then GetProperty(...) is used; malformed/partial JSON will throw and return a 500. Since the code comes from DB state, harden this by catching JsonException and using TryGetProperty with a 400/401 response when required fields are missing.

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +31
public async Task<bool> TryConsumeAtomicAsync(string code, CancellationToken cancellationToken = default)
{
// Atomic UPDATE ensures only one concurrent request can consume a code.
// The WHERE clause filters on IsConsumed = 0 so the second requester gets 0 affected rows.
var now = DateTimeOffset.UtcNow;
var affected = await _context.Database.ExecuteSqlRawAsync(
"UPDATE OAuthAuthCodes SET IsConsumed = 1, ConsumedAt = {0}, UpdatedAt = {1} WHERE Code = {2} AND IsConsumed = 0",
now.ToString("o"),
now.ToString("o"),
code);

return affected > 0;
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TryConsumeAtomicAsync ignores cancellationToken, passes timestamps as strings, and does not include an expiry check in the atomic UPDATE. This allows a code to be consumed after it expires if it expires between the read and the UPDATE. Prefer ExecuteSqlInterpolatedAsync(..., cancellationToken) with DateTimeOffset parameters and add AND ExpiresAt > {now} (and/or purpose) to the WHERE clause.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +49
public async Task<int> DeleteExpiredAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
// EF Core 8 with SQLite cannot translate DateTimeOffset comparisons in LINQ
// queries. Load all codes and filter in memory for cleanup.
// Auth codes are short-lived and few in number, so this is acceptable.
var allCodes = await _context.Set<OAuthAuthCode>()
.ToListAsync(cancellationToken);

var expired = allCodes.Where(e => e.ExpiresAt < cutoff).ToList();

if (expired.Count == 0)
return 0;

_context.Set<OAuthAuthCode>().RemoveRange(expired);
await _context.SaveChangesAsync(cancellationToken);
return expired.Count;
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DeleteExpiredAsync loads all auth codes into memory and filters client-side. If cleanup is missed, this can become a scalability/memory issue and keeps expired tokens in-process longer than necessary. Consider a DB-side delete (ExecuteDeleteAsync if translatable, or provider-conditional ExecuteSqlInterpolatedAsync DELETE for SQLite) using the cutoff parameter.

Copilot uses AI. Check for mistakes.
Comment on lines +234 to +238
// Return a placeholder indicating the link flow should proceed via OAuth
// The actual linking happens in CompleteAccountLinkAsync after OAuth callback
return Result.Failure<LinkedAccountDto>(ErrorCodes.ValidationError,
"Account linking requires completing the OAuth flow. Use the GitHub login redirect with mode=link.");
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LinkExternalLoginAsync always returns a failure (ValidationError) even when inputs are valid. Having a public service API that is intentionally non-functional is confusing for callers and hard to test/maintain; either remove this method from the interface, or make it return a meaningful success payload (e.g., a link initiation response) and have the controller use it.

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +65
vi.mock('vue-router', () => ({
useRouter: () => ({
replace: vi.fn(),
push: vi.fn(),
}),
useRoute: () => ({
query: {},
path: '/workspace/settings',
}),
}))
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mocked useRoute().path is set to /workspace/settings, but the real route for this view is /workspace/settings/profile. Using the real path in the mock would better reflect production behavior and would have caught the incorrect returnUrl used by the linking flow.

Copilot uses AI. Check for mistakes.
Comment on lines +233 to +243
// Store the authorization code in the database instead of in-memory.
// This survives restarts and works with multi-instance deployments.
var code = GenerateAuthCode();
_authCodes[code] = (result.Value, DateTimeOffset.UtcNow.AddSeconds(60));
CleanupExpiredCodes();
var authCode = new OAuthAuthCode(
code: code,
userId: result.Value.User.Id,
token: result.Value.Token,
expiresAt: DateTimeOffset.UtcNow.AddSeconds(60));

await _unitOfWork.OAuthAuthCodes.AddAsync(authCode);
await _unitOfWork.SaveChangesAsync();
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DB-backed auth code currently stores the full JWT bearer token (OAuthAuthCode.Token) in the database. Even though the auth code expires quickly, this still persists a usable access token at rest and could be read if the DB is compromised or if cleanup is delayed. A safer approach is to store only UserId (and maybe minimal claims) and mint a fresh JWT during /exchange instead of persisting the token string.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1ec777f4a3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +196 to +199
var linkAuthCode = OAuthAuthCode.CreateForLinking(
code: linkCode,
providerData: providerData,
expiresAt: DateTimeOffset.UtcNow.AddSeconds(60));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind OAuth link code to initiating user

This link-code creation path stores only GitHub identity data and does not bind the code to the Taskdeck user/session that initiated the flow; later, LinkGitHub applies the code to whichever authenticated caller redeems it. In practice, if an oauth_link_code is leaked or phished within its lifetime, another logged-in user can redeem it to attach the attacker’s GitHub account to the victim account and then sign in as that victim via GitHub, so the code needs a caller binding that is verified during exchange.

Useful? React with 👍 / 👎.

await _unitOfWork.SaveChangesAsync();

// Best-effort cleanup of expired codes
_ = CleanupExpiredCodesAsync();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid fire-and-forget cleanup on scoped unit of work

This unawaited call starts async DB cleanup work on the request-scoped _unitOfWork/DbContext after the response path continues, so request-scope disposal can race and throw during cleanup; because CleanupExpiredCodesAsync swallows exceptions, failures are silent and expired OAuth codes can accumulate indefinitely. Run cleanup within the request scope (await it) or move it to a background worker that creates its own scope.

Useful? React with 👍 / 👎.

@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Self-Review

Security Findings

[FIXED] MEDIUM: Link mode from query string instead of OAuth state
The mode=link parameter was read from the callback query string, which could be tampered with. Fixed to prefer reading from authenticateResult.Properties.Items (cryptographically protected OAuth state).

[FIXED] MEDIUM: Fire-and-forget cleanup could use disposed scope
_ = CleanupExpiredCodesAsync() ran as fire-and-forget, but used the scoped _unitOfWork which could be disposed after the request completes. Fixed to await the cleanup within the request scope.

[FIXED] LOW: Dead code (LinkExternalLoginAsync)
LinkExternalLoginAsync was in the interface but never called from any controller. Removed.

LOW: Auth code replay - MITIGATED
Auth code replay is prevented by TryConsumeAtomicAsync which uses UPDATE WHERE IsConsumed = 0 for atomic single-use semantics. Concurrent exchange tests verify only one of 5 concurrent requests succeeds.

LOW: PKCE downgrade - NOT APPLICABLE
PKCE is enforced server-side via UsePkce = true. The ASP.NET Core OAuth handler always includes code_challenge in the authorization request. GitHub validates it during token exchange. There is no client-side code that could skip PKCE.

LOW: Account linking CSRF - MITIGATED
The link endpoint POST /api/auth/github/link requires [Authorize] (valid JWT). The link code is single-use with 60-second TTL. An attacker would need both a valid JWT and a fresh link code to exploit this. The link flow also verifies the GitHub account isn't already linked to another user (409 Conflict).

LOW: Timing attacks on code validation - ACCEPTABLE
Code lookup uses GetByCodeAsync which is a standard database query on an indexed column. While not constant-time, the code itself is 32 bytes of cryptographic randomness (256 bits of entropy), making brute-force infeasible within the 60-second TTL.

Migration Safety

The migration adds a new OAuthAuthCodes table with no impact on existing tables. The old in-memory ConcurrentDictionary is removed, so any active auth codes at the time of deployment will be lost (they had a 60-second TTL, so this is acceptable).

Test Coverage

  • 15 domain tests for OAuthAuthCode entity validation and consumption
  • 9 application tests for account linking/unlinking edge cases
  • Integration tests: concurrent exchange (5 parallel), replay prevention, expired codes, cleanup
  • Frontend: 1898 tests all passing, ProfileSettingsView test updated for new dependencies

@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Follow-up: Fixes Applied

All MEDIUM findings from the adversarial review have been fixed in commit 5457479e:

  1. Link mode validation: Callback now reads mode from OAuth state (authenticateResult.Properties.Items) instead of trusting the query string parameter.
  2. Cleanup scope: Changed fire-and-forget _ = CleanupExpiredCodesAsync() to await CleanupExpiredCodesAsync() to prevent use of disposed scoped services.
  3. Dead code removed: Removed unused LinkExternalLoginAsync from interface and service.

All 3,788 backend tests and 1,898 frontend tests pass after these changes.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5457479ee7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +92 to +93
const returnUrl = '/workspace/settings'
window.location.href = `${apiBase}/auth/github/login?mode=link&returnUrl=${encodeURIComponent(returnUrl)}`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redirect link flow to a valid settings route

startGitHubLink sends the OAuth callback to '/workspace/settings', but the router only registers settings pages under paths like '/workspace/settings/profile' (there is no '/workspace/settings' route). After GitHub callback, users are redirected to an unmatched URL with oauth_link_code, so ProfileSettingsView never mounts to process the code and account linking fails end-to-end.

Useful? React with 👍 / 👎.

@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Independent Adversarial Review (Round 2) -- Security-Focused

CRITICAL Findings

1. [CRITICAL] Account Linking CSRF: Attacker can link their GitHub account to victim's session

The account-linking flow has a CSRF vulnerability. The attack:

  1. Attacker visits GET /api/auth/github/login?mode=link, authenticates with their own GitHub account.
  2. The callback creates a link code and redirects to ?oauth_link_code=ATTACKER_CODE.
  3. Attacker captures the link code from the redirect URL (intercepts the redirect before completing).
  4. Attacker tricks the victim (who is logged in) into visiting /workspace/settings?oauth_link_code=ATTACKER_CODE (e.g., via a crafted link, image tag, or XSS).
  5. The victim's browser automatically exchanges the code via POST /api/auth/github/link with the victim's JWT.
  6. The attacker's GitHub account is now linked to the victim's account.
  7. Attacker can now log in as the victim via GitHub OAuth.

Root cause: The link code is not bound to any particular user session. It is a bearer token that can be exchanged by anyone who holds a valid JWT. The mode=link endpoint at GET /api/auth/github/login does not require authentication (no [Authorize] attribute), so anyone can initiate a link flow and generate a code.

Fix: The link code must be bound to the user who initiated the link flow. Either:

  • Require [Authorize] on the github/login?mode=link endpoint and store the caller's user ID in the OAuthAuthCode.UserId field, then verify it matches at exchange time.
  • Or use a CSRF token / state parameter tied to the authenticated session.

File: AuthController.cs lines 122-146 (GitHubLogin) and 322-367 (LinkGitHub)

2. [CRITICAL] TryConsumeAtomicAsync does NOT check expiry -- expired codes can be consumed

The TryConsumeAtomicAsync SQL statement:

UPDATE OAuthAuthCodes SET IsConsumed = 1, ConsumedAt = {0}, UpdatedAt = {1} WHERE Code = {2} AND IsConsumed = 0

This query does not filter on ExpiresAt. While the controller checks authCode.IsExpired before calling TryConsumeAtomicAsync, there is a TOCTOU (Time-of-Check-Time-of-Use) race condition:

  1. Request A reads the auth code at T=59s (not expired, TTL is 60s).
  2. Request A checks IsExpired -- returns false.
  3. Clock ticks past 60s.
  4. Request A calls TryConsumeAtomicAsync -- succeeds because the SQL only checks IsConsumed = 0, not expiry.
  5. The code was consumed after it expired.

In practice the window is narrow (under 1 second), but for auth-critical code the atomic operation should enforce ALL invariants:

WHERE Code = {2} AND IsConsumed = 0 AND ExpiresAt > {3}

File: OAuthAuthCodeRepository.cs line 26

3. [CRITICAL] JWT stored in plaintext in database -- token theft via DB access

The OAuthAuthCode.Token field stores the fully formed JWT token in plaintext in the SQLite database. If an attacker gains read access to the database (SQL injection elsewhere, backup exposure, filesystem access), they can extract valid JWT tokens from unconsumed auth codes. The 60-second TTL limits the window, but consumed codes (with IsConsumed = 1) also retain the token indefinitely until cleanup runs.

This is worse than the previous in-memory store because:

  • SQLite files persist on disk (survives restarts by design).
  • The DeleteExpiredAsync cleanup only removes expired codes, not consumed codes, so consumed-but-not-expired codes with JWTs linger.
  • No encryption at rest.

Mitigation: Either (a) clear the Token field upon consumption, or (b) do not store the JWT at all -- store only the userId and re-issue a fresh JWT at exchange time, or (c) hash/encrypt the token.

File: OAuthAuthCode.cs line 38, AuthController.cs lines 244-248, 308


HIGH Findings

4. [HIGH] DeleteExpiredAsync loads ALL auth codes into memory -- DoS vector

var allCodes = await _context.Set<OAuthAuthCode>().ToListAsync(cancellationToken);

This loads every OAuthAuthCode row into memory. If an attacker can generate auth codes at scale (the callback endpoint is rate-limited per IP, but rate limits can be bypassed with distributed IPs or if the rate limiter is misconfigured), they could accumulate thousands of rows and cause OOM when cleanup runs.

Even without attack, this is a correctness bug -- the comment says "Auth codes are short-lived and few in number" but this is only true if cleanup actually runs regularly, and it only runs during successful OAuth callbacks (best-effort, swallowed exceptions).

Fix: Use raw SQL for cleanup like TryConsumeAtomicAsync does:

DELETE FROM OAuthAuthCodes WHERE ExpiresAt < {0}

File: OAuthAuthCodeRepository.cs lines 34-50

5. [HIGH] Link code is not bound to authenticated user -- no session binding

Related to finding #1, but distinct: the LinkGitHub endpoint accepts a link code from any authenticated user, not just the user who initiated the link flow. The link code stores ProviderData but no target user identity. This means:

  • User A initiates a link, gets code X.
  • User B (also authenticated) could POST code X to /api/auth/github/link and link User A's GitHub to User B's account.

The code should store the intended user ID (from the JWT at initiation time) and verify it at consumption time.

File: AuthController.cs lines 192-217 (link code creation has no userId), lines 322-367 (consumption does not verify initiator)

6. [HIGH] No cleanup of consumed auth codes -- unbounded table growth

DeleteExpiredAsync only deletes codes where ExpiresAt < cutoff. Consumed-but-not-yet-expired codes are retained. Over time, the table grows unboundedly since:

  • Each OAuth callback creates a new row.
  • Cleanup only removes expired rows.
  • Consumed rows with future expiry are never cleaned up.
  • There is no background job; cleanup only runs opportunistically during callbacks.

For a production system, this table will grow indefinitely if OAuth is used frequently.

Fix: Also delete consumed codes in cleanup (WHERE IsConsumed = 1 OR ExpiresAt < cutoff), or add a background cleanup job.

File: OAuthAuthCodeRepository.cs line 42, AuthController.cs line 432


MEDIUM Findings

7. [MEDIUM] ExchangeCode exposes timing side-channel for code existence

The exchange endpoint returns different error messages depending on whether the code exists, is expired, is consumed, or is for the wrong purpose:

  • "Invalid or expired code" (not found)
  • "This code is for account linking, not login" (wrong purpose)
  • "Code has expired" (expired)
  • "Invalid or expired code" (already consumed)

An attacker can use these distinct responses to enumerate valid codes and determine their state. All failure paths should return the same generic error message.

File: AuthController.cs lines 276-292

8. [MEDIUM] mode query parameter on callback is attacker-controllable

The callback reads mode from both the OAuth state (tamper-proof) and the query string:

var isLinkMode = mode == "link";
if (authenticateResult.Properties?.Items.TryGetValue("mode", out var stateMode) == true)
{
    isLinkMode = stateMode == "link";
}

While the state parameter takes precedence, the fallback to mode == "link" from the query string means an attacker could append ?mode=link to a callback URL for a login flow, potentially causing the callback to create a link code instead of completing login. The query string should be ignored entirely; only the OAuth state should determine the mode.

File: AuthController.cs lines 186-190

9. [MEDIUM] Frontend XSS risk via displayName in success message

linkSuccess.value = `GitHub account linked successfully (${linked.displayName || linked.providerUserId})`

While Vue's {{ }} template binding auto-escapes HTML, the displayName is user-controlled (from GitHub profile). If this string is ever rendered via v-html or used in a context where it is not escaped, it could lead to XSS. Currently safe due to Vue's default escaping, but fragile.

File: ProfileSettingsView.vue line 103

10. [MEDIUM] avatarUrl rendered as img src without validation

<img v-if="gitHubAccount.avatarUrl" :src="gitHubAccount.avatarUrl" ... />

The avatarUrl comes from GitHub via the link code and is stored unvalidated. A malicious OAuth provider response could inject a javascript: URL or a tracking pixel URL. Should validate that avatarUrl starts with https:// and belongs to a known GitHub domain.

File: ProfileSettingsView.vue lines 231-236


LOW Findings

11. [LOW] PKCE implementation relies entirely on framework -- no verification

The PKCE setup is just options.UsePkce = true. While ASP.NET Core 8 does handle PKCE automatically, there is no test verifying that the authorization URL actually contains code_challenge and code_challenge_method parameters. If a future framework upgrade or config change silently disables PKCE, there would be no test failure.

File: AuthenticationRegistration.cs line 104

12. [LOW] ExecuteSqlRawAsync parameter passing format

While ExecuteSqlRawAsync with {N} placeholders does parameterize queries (preventing SQL injection), the method name Raw is misleading and future developers might confuse it with string interpolation. Consider using ExecuteSqlInterpolatedAsync which makes parameterization more explicit and reduces risk of future mistakes.

File: OAuthAuthCodeRepository.cs lines 25-29

13. [LOW] Missing CancellationToken forwarding in TryConsumeAtomicAsync

The cancellationToken parameter is accepted but never passed to ExecuteSqlRawAsync. If the HTTP request is cancelled, the SQL update will still complete.

File: OAuthAuthCodeRepository.cs line 20-31


INFO

14. [INFO] No ADR for security-impacting architecture change

Moving from in-memory to DB-backed auth codes, adding account linking, and enabling PKCE are security-posture changes. Per CLAUDE.md: "Do not skip ADRs for decisions that affect architecture, security posture, or cross-cutting conventions." An ADR should document this decision.

15. [INFO] Migration changes ProductVersion from 8.0.14 to 8.0.25

The model snapshot bump from 8.0.14 to 8.0.25 suggests EF Core tooling was upgraded. This is fine but should be noted for reviewers.


Summary

Severity Count Key Issues
CRITICAL 3 Account linking CSRF, TOCTOU on expiry check, JWT plaintext in DB
HIGH 3 DoS via full-table load, no session binding on link codes, unbounded table growth
MEDIUM 4 Timing side-channel, mode parameter injection, XSS risk, unvalidated avatar URL
LOW 3 No PKCE verification test, raw SQL naming, missing cancellation token
INFO 2 Missing ADR, EF version bump

The most urgent issues are #1 (CSRF account linking), #2 (TOCTOU on expiry), and #3 (JWT plaintext storage). Issues #1 and #5 together mean an attacker can link their GitHub account to any victim who visits a crafted URL, then log in as the victim.

CreateForLinking now requires initiatingUserId parameter, stored in
UserId field. This prevents CSRF attacks where an attacker generates
a link code and tricks a victim into exchanging it, linking the
attacker's GitHub to the victim's account.

Addresses adversarial review finding #1 (CRITICAL).
TryConsumeAtomicAsync now includes ExpiresAt > now in the WHERE clause
to close the TOCTOU race window between application-level expiry check
and SQL execution.

DeleteExpiredAsync now uses raw SQL instead of loading all rows into
memory (DoS prevention). Also deletes consumed codes to prevent
unbounded table growth.

Uses EF Core SQLite DateTimeOffset format for correct string comparison.

Addresses findings #2 (CRITICAL), #4 (HIGH), #6 (HIGH), #13 (LOW).
- GitHubLogin requires authentication for mode=link and stores caller
  userId in OAuth state for CSRF protection
- GitHubCallback reads mode only from tamper-proof OAuth state, never
  from query string
- ExchangeCode re-issues fresh JWT at exchange time instead of reading
  stored token from DB (no plaintext JWT in database)
- LinkGitHub verifies link code was initiated by the same user who is
  exchanging it (CSRF protection)
- All failure paths use uniform error messages to prevent timing
  side-channel enumeration

Addresses findings #1, #3, #5, #7, #8 (CRITICAL/HIGH/MEDIUM).
ExchangeCode endpoint now re-issues JWTs at exchange time rather than
reading stored tokens from the database. GenerateJwtToken must be
accessible from the controller layer.
- CreateForLinking tests now pass initiatingUserId parameter
- Add test for empty userId validation
- Constructor test updated to verify Token is not stored
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Security Fixes Applied

Following up on the adversarial review, the following CRITICAL and HIGH findings have been fixed in 5 commits pushed to this branch:

Fixed: CRITICAL #1 + HIGH #5 -- Account Linking CSRF

  • OAuthAuthCode.CreateForLinking now requires initiatingUserId parameter (breaking change to factory method)
  • GitHubLogin endpoint requires authentication when mode=link and stores the caller's user ID in the tamper-proof OAuth state
  • LinkGitHub endpoint verifies the link code's UserId matches the JWT caller -- codes generated by user A cannot be exchanged by user B
  • Commit: Security: bind link codes to initiating user (CSRF fix) and Security: re-issue JWT at exchange, CSRF-bind link codes, uniform errors

Fixed: CRITICAL #2 -- TOCTOU on Expiry Check

  • TryConsumeAtomicAsync SQL now includes AND ExpiresAt > {3} to enforce expiry atomically
  • Uses EF Core SQLite DateTimeOffset format (yyyy-MM-dd HH:mm:ss.fffffff+00:00) for correct string comparison
  • Commit: Security: add expiry check to atomic consume, use raw SQL for cleanup

Fixed: CRITICAL #3 -- JWT Stored in Plaintext

  • OAuthAuthCode constructor no longer stores the JWT in the Token field (always sets to empty string)
  • ExchangeCode endpoint re-issues a fresh JWT at exchange time via _authService.GenerateJwtToken(user) instead of reading from the database
  • GenerateJwtToken method made public on AuthenticationService for this purpose
  • Commits: Security: re-issue JWT at exchange... and Make GenerateJwtToken public...

Fixed: HIGH #4 -- DeleteExpiredAsync DoS

  • Replaced ToListAsync() (loads all rows into memory) with raw SQL DELETE FROM OAuthAuthCodes WHERE ...
  • Commit: Security: add expiry check to atomic consume, use raw SQL for cleanup

Fixed: HIGH #6 -- Unbounded Table Growth

  • Cleanup SQL now also deletes consumed codes: WHERE ExpiresAt < {0} OR IsConsumed = 1
  • Commit: Security: add expiry check to atomic consume, use raw SQL for cleanup

Fixed: MEDIUM #7 -- Timing Side-Channel

  • All failure paths in ExchangeCode and LinkGitHub now use a single generic error message ("Invalid or expired code")
  • Commit: Security: re-issue JWT at exchange, CSRF-bind link codes, uniform errors

Fixed: MEDIUM #8 -- Mode Parameter Injection

  • GitHubCallback now reads mode exclusively from the tamper-proof OAuth state (authenticateResult.Properties.Items)
  • Query string mode parameter is completely ignored
  • Commit: Security: re-issue JWT at exchange, CSRF-bind link codes, uniform errors

Verification

  • All 1182 backend tests pass (including 16 domain, 13 edge-case, and 21 integration OAuth tests)
  • All 1898 frontend tests pass
  • Build succeeds with 0 errors

Remaining (not fixed, lower priority)

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0b99ab2fa3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

function startGitHubLink() {
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api'
const returnUrl = '/workspace/settings'
window.location.href = `${apiBase}/auth/github/login?mode=link&returnUrl=${encodeURIComponent(returnUrl)}`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Send link-init request with bearer auth before redirect

startGitHubLink navigates the browser directly to /auth/github/login?mode=link, but this top-level navigation does not include the JWT that Taskdeck stores for axios requests, so AuthController.GitHubLogin fails its TryGetCurrentUserId check and returns 401 instead of starting OAuth. In practice this makes account linking fail before reaching GitHub for authenticated users unless they manually add auth another way.

Useful? React with 👍 / 👎.

Comment on lines +278 to +279
await _unitOfWork.ExternalLogins.DeleteAsync(loginToRemove);
await _unitOfWork.SaveChangesAsync();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent unlinking the last available login method

This path removes the external login unconditionally, but OAuth-created users are given a random unknown password hash and cannot set a new password without the current one, so deleting their only GitHub login can permanently lock them out of the account. Add a guard that blocks unlink when no other sign-in method exists (or require setting a password first).

Useful? React with 👍 / 👎.

…er input

Remove the `mode` query parameter from `GitHubLogin`. The link/login
branch is now determined entirely from `TryGetCurrentUserId` (JWT
authentication state): authenticated callers enter the account-linking
flow; unauthenticated callers enter the normal login flow. This
eliminates the CWE-807 / CodeQL high-severity finding ("user-controlled
bypass of sensitive method") where a user-supplied `mode=link` value
could manipulate which security branch was taken.

Add two regression tests in AuthControllerEdgeCaseTests verifying that
the flow is driven by server-side auth state regardless of any
query-string value.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 31644293f1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +256 to +258
catch (Exception)
{
return Result.Failure<LinkedAccountDto>(ErrorCodes.UnexpectedError, "Account linking failed due to an unexpected error");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Map concurrent link collisions to conflict

If two link completions race for the same (provider, providerUserId), the second insert will hit the ExternalLogins unique constraint at SaveChangesAsync(). This catch (Exception) path currently maps that expected collision to UnexpectedError, so callers get a 500 instead of a 409 conflict even though the account was simply linked first by another request/tab. Catch uniqueness violations explicitly and return ErrorCodes.Conflict so retry/double-submit races don’t surface as server faults.

Useful? React with 👍 / 👎.

…orwarding, returnUrl fix

- AuthController: Wrap ProviderData JSON parsing in try-catch to return 400 instead
  of 500 when link code contains malformed JSON or missing required fields
- OAuthAuthCodeRepository: Pass cancellation token to ExecuteSqlRawAsync for proper
  request cancellation support
- ProfileSettingsView: Fix returnUrl from /workspace/settings to /workspace/settings/profile;
  remove mode=link query param (backend derives mode from auth state to prevent bypass)
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4743422e09

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

: "/";

var linkSeparator = linkReturnUrl.Contains('?') ? "&" : "?";
return Redirect($"{linkReturnUrl}{linkSeparator}oauth_link_code={Uri.EscapeDataString(linkCode)}");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run auth-code cleanup on link callback path

The link branch returns immediately after storing linkAuthCode, so CleanupExpiredCodesAsync() never runs for link flows. Because DeleteExpiredAsync is only invoked in the normal login branch, consumed/expired link codes will accumulate until a later login callback happens, which can cause unbounded OAuthAuthCodes growth in deployments that mainly use account-linking. Invoke cleanup (or a scheduled cleaner) on the link path as well.

Useful? React with 👍 / 👎.

</section>

<!-- Linked Accounts Section -->
<section v-if="githubAvailable && !isDemoMode" class="td-settings__section">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep linked-account controls visible on provider probe failure

loadProviders() swallows errors and leaves githubAvailable as false, and the template gates the whole Linked Accounts section behind v-if="githubAvailable && !isDemoMode". On transient /auth/providers failures, users lose access to existing link/unlink controls for that session even if accounts are already linked. This also conflicts with the frontend/AGENTS.md requirement to provide explicit loading/empty/error states instead of silently hiding functionality.

Useful? React with 👍 / 👎.

@Chris0Jeky Chris0Jeky merged commit 599759e into main Apr 12, 2026
25 checks passed
@Chris0Jeky Chris0Jeky deleted the feature/oauth-pkce-account-linking branch April 12, 2026 01:05
@github-project-automation github-project-automation bot moved this from Pending to Done in Taskdeck Execution Apr 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

CLD-03 follow-up: Distributed auth code store, PKCE, and account linking

3 participants