Skip to content

Commit 37a5079

Browse files
authored
Merge pull request #569 from Chris0Jeky/feature/539-github-oauth
Add GitHub OAuth login for cloud instance
2 parents cacb0ab + 975b6e0 commit 37a5079

File tree

24 files changed

+2836
-9
lines changed

24 files changed

+2836
-9
lines changed

backend/src/Taskdeck.Api/Controllers/AuthController.cs

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
using System.Collections.Concurrent;
2+
using System.Security.Cryptography;
3+
using Microsoft.AspNetCore.Authentication;
14
using Microsoft.AspNetCore.Mvc;
25
using Microsoft.AspNetCore.RateLimiting;
36
using Taskdeck.Api.Contracts;
@@ -7,20 +10,28 @@
710
using Taskdeck.Application.DTOs;
811
using Taskdeck.Application.Services;
912
using Taskdeck.Domain.Exceptions;
13+
using AuthenticationService = Taskdeck.Application.Services.AuthenticationService;
1014

1115
namespace Taskdeck.Api.Controllers;
1216

1317
public record ChangePasswordRequest(Guid UserId, string CurrentPassword, string NewPassword);
18+
public record ExchangeCodeRequest(string Code);
1419

1520
[ApiController]
1621
[Route("api/auth")]
1722
public class AuthController : ControllerBase
1823
{
1924
private readonly AuthenticationService _authService;
25+
private readonly GitHubOAuthSettings _gitHubOAuthSettings;
2026

21-
public AuthController(AuthenticationService authService)
27+
// Short-lived, single-use authorization codes to avoid exposing JWT in URLs.
28+
// Key: code, Value: (token, expiry). Codes expire after 60 seconds.
29+
private static readonly ConcurrentDictionary<string, (AuthResultDto Result, DateTimeOffset Expiry)> _authCodes = new();
30+
31+
public AuthController(AuthenticationService authService, GitHubOAuthSettings gitHubOAuthSettings)
2232
{
2333
_authService = authService;
34+
_gitHubOAuthSettings = gitHubOAuthSettings;
2435
}
2536

2637
[HttpPost("login")]
@@ -56,4 +67,145 @@ public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest
5667
var result = await _authService.ChangePasswordAsync(request.UserId, request.CurrentPassword, request.NewPassword);
5768
return result.IsSuccess ? NoContent() : result.ToErrorActionResult();
5869
}
70+
71+
/// <summary>
72+
/// Initiates GitHub OAuth login flow. Only available when GitHub OAuth is configured.
73+
/// </summary>
74+
[HttpGet("github/login")]
75+
[EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)]
76+
public IActionResult GitHubLogin([FromQuery] string? returnUrl = null)
77+
{
78+
if (!_gitHubOAuthSettings.IsConfigured)
79+
return NotFound(new ApiErrorResponse(ErrorCodes.NotFound, "GitHub OAuth is not configured"));
80+
81+
// Validate returnUrl to prevent open redirect
82+
if (!string.IsNullOrWhiteSpace(returnUrl) && !Url.IsLocalUrl(returnUrl))
83+
return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Invalid return URL"));
84+
85+
var properties = new Microsoft.AspNetCore.Authentication.AuthenticationProperties
86+
{
87+
RedirectUri = Url.Action(nameof(GitHubCallback), new { returnUrl }),
88+
Items = { { "LoginProvider", "GitHub" } }
89+
};
90+
91+
return Challenge(properties, "GitHub");
92+
}
93+
94+
/// <summary>
95+
/// Handles the GitHub OAuth callback, creates/links the user, and redirects with a JWT token.
96+
/// </summary>
97+
[HttpGet("github/callback")]
98+
[EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)]
99+
public async Task<IActionResult> GitHubCallback([FromQuery] string? returnUrl = null)
100+
{
101+
if (!_gitHubOAuthSettings.IsConfigured)
102+
return NotFound(new ApiErrorResponse(ErrorCodes.NotFound, "GitHub OAuth is not configured"));
103+
104+
var authenticateResult = await HttpContext.AuthenticateAsync("GitHub");
105+
if (!authenticateResult.Succeeded || authenticateResult.Principal == null)
106+
{
107+
return Unauthorized(new ApiErrorResponse(
108+
ErrorCodes.AuthenticationFailed,
109+
"GitHub authentication failed"));
110+
}
111+
112+
var claims = authenticateResult.Principal.Claims.ToList();
113+
var providerUserId = claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
114+
var username = claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.Name)?.Value
115+
?? claims.FirstOrDefault(c => c.Type == "urn:github:login")?.Value;
116+
var email = claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.Email)?.Value;
117+
var displayName = claims.FirstOrDefault(c => c.Type == "urn:github:name")?.Value;
118+
var avatarUrl = claims.FirstOrDefault(c => c.Type == "urn:github:avatar")?.Value;
119+
120+
if (string.IsNullOrWhiteSpace(providerUserId))
121+
{
122+
return Unauthorized(new ApiErrorResponse(
123+
ErrorCodes.AuthenticationFailed,
124+
"GitHub did not return a user identifier"));
125+
}
126+
127+
// GitHub may not return an email if user's email is private
128+
if (string.IsNullOrWhiteSpace(email))
129+
email = $"{providerUserId}@users.noreply.github.com";
130+
131+
if (string.IsNullOrWhiteSpace(username))
132+
username = $"github-user-{providerUserId}";
133+
134+
var dto = new ExternalLoginDto(
135+
Provider: "GitHub",
136+
ProviderUserId: providerUserId,
137+
Username: username,
138+
Email: email,
139+
DisplayName: displayName,
140+
AvatarUrl: avatarUrl);
141+
142+
var result = await _authService.ExternalLoginAsync(dto);
143+
144+
if (!result.IsSuccess)
145+
return result.ToErrorActionResult();
146+
147+
// Sign out the temporary cookie used during the OAuth handshake
148+
await HttpContext.SignOutAsync("GitHub");
149+
150+
// Security: Do NOT put the JWT in the URL. Use a short-lived, single-use
151+
// authorization code that the frontend exchanges via POST.
152+
var code = GenerateAuthCode();
153+
_authCodes[code] = (result.Value, DateTimeOffset.UtcNow.AddSeconds(60));
154+
CleanupExpiredCodes();
155+
156+
var safeReturnUrl = !string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl)
157+
? returnUrl
158+
: "/";
159+
160+
var separator = safeReturnUrl.Contains('?') ? "&" : "?";
161+
return Redirect($"{safeReturnUrl}{separator}oauth_code={Uri.EscapeDataString(code)}");
162+
}
163+
164+
/// <summary>
165+
/// Exchanges a short-lived OAuth authorization code for a JWT token.
166+
/// The code is single-use and expires after 60 seconds.
167+
/// </summary>
168+
[HttpPost("github/exchange")]
169+
[EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)]
170+
public IActionResult ExchangeCode([FromBody] ExchangeCodeRequest request)
171+
{
172+
if (string.IsNullOrWhiteSpace(request.Code))
173+
return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Code is required"));
174+
175+
if (!_authCodes.TryRemove(request.Code, out var entry))
176+
return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, "Invalid or expired code"));
177+
178+
if (DateTimeOffset.UtcNow > entry.Expiry)
179+
return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, "Code has expired"));
180+
181+
return Ok(entry.Result);
182+
}
183+
184+
/// <summary>
185+
/// Returns whether GitHub OAuth login is available on this instance.
186+
/// </summary>
187+
[HttpGet("providers")]
188+
public IActionResult GetProviders()
189+
{
190+
return Ok(new
191+
{
192+
GitHub = _gitHubOAuthSettings.IsConfigured
193+
});
194+
}
195+
196+
private static string GenerateAuthCode()
197+
{
198+
var bytes = RandomNumberGenerator.GetBytes(32);
199+
return Convert.ToBase64String(bytes).Replace("+", "-").Replace("/", "_").TrimEnd('=');
200+
}
201+
202+
private static void CleanupExpiredCodes()
203+
{
204+
var now = DateTimeOffset.UtcNow;
205+
foreach (var kvp in _authCodes)
206+
{
207+
if (now > kvp.Value.Expiry)
208+
_authCodes.TryRemove(kvp.Key, out _);
209+
}
210+
}
59211
}

backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
1+
using System.Security.Claims;
12
using System.Text;
3+
using Microsoft.AspNetCore.Authentication;
24
using Microsoft.AspNetCore.Authentication.JwtBearer;
5+
using Microsoft.AspNetCore.Authentication.OAuth.Claims;
36
using Microsoft.IdentityModel.Tokens;
4-
using Microsoft.Net.Http.Headers;
57
using Taskdeck.Api.Contracts;
68
using Taskdeck.Application.Services;
79
using Taskdeck.Domain.Exceptions;
10+
using HeaderNames = Microsoft.Net.Http.Headers.HeaderNames;
811

912
namespace Taskdeck.Api.Extensions;
1013

1114
public static class AuthenticationRegistration
1215
{
1316
public static IServiceCollection AddTaskdeckAuthentication(
1417
this IServiceCollection services,
15-
JwtSettings jwtSettings)
18+
JwtSettings jwtSettings,
19+
GitHubOAuthSettings? gitHubOAuthSettings = null)
1620
{
1721
if (string.IsNullOrWhiteSpace(jwtSettings.SecretKey) ||
1822
jwtSettings.SecretKey.Length < 32 ||
@@ -22,7 +26,7 @@ public static IServiceCollection AddTaskdeckAuthentication(
2226
return services;
2327
}
2428

25-
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
29+
var authBuilder = services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
2630
.AddJwtBearer(options =>
2731
{
2832
options.TokenValidationParameters = new TokenValidationParameters
@@ -81,6 +85,34 @@ await context.Response.WriteAsJsonAsync(new ApiErrorResponse(
8185
};
8286
});
8387

88+
// Environment-gated: only add GitHub OAuth when configured
89+
if (gitHubOAuthSettings is { IsConfigured: true })
90+
{
91+
authBuilder.AddOAuth("GitHub", options =>
92+
{
93+
options.ClientId = gitHubOAuthSettings.ClientId;
94+
options.ClientSecret = gitHubOAuthSettings.ClientSecret;
95+
options.AuthorizationEndpoint = "https://github.com/login/oauth/authorize";
96+
options.TokenEndpoint = "https://github.com/login/oauth/access_token";
97+
options.UserInformationEndpoint = "https://api.github.com/user";
98+
options.CallbackPath = "/api/auth/github/oauth-redirect";
99+
options.SaveTokens = false;
100+
101+
options.Scope.Add("read:user");
102+
options.Scope.Add("user:email");
103+
104+
options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
105+
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "login");
106+
options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
107+
options.ClaimActions.MapJsonKey("urn:github:name", "name");
108+
options.ClaimActions.MapJsonKey("urn:github:login", "login");
109+
options.ClaimActions.MapJsonKey("urn:github:avatar", "avatar_url");
110+
111+
// OAuthHandler fetches UserInformationEndpoint automatically
112+
// and applies ClaimActions — no custom OnCreatingTicket needed.
113+
});
114+
}
115+
84116
return services;
85117
}
86118

backend/src/Taskdeck.Api/Extensions/SettingsRegistration.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ public static IServiceCollection AddTaskdeckSettings(
1010
IHostEnvironment environment,
1111
out ObservabilitySettings observabilitySettings,
1212
out RateLimitingSettings rateLimitingSettings,
13-
out JwtSettings jwtSettings)
13+
out JwtSettings jwtSettings,
14+
out GitHubOAuthSettings gitHubOAuthSettings)
1415
{
1516
observabilitySettings = configuration
1617
.GetSection("Observability")
@@ -41,6 +42,9 @@ public static IServiceCollection AddTaskdeckSettings(
4142
jwtSettings = configuration.GetSection("Jwt").Get<JwtSettings>() ?? new JwtSettings();
4243
services.AddSingleton(jwtSettings);
4344

45+
gitHubOAuthSettings = configuration.GetSection("GitHubOAuth").Get<GitHubOAuthSettings>() ?? new GitHubOAuthSettings();
46+
services.AddSingleton(gitHubOAuthSettings);
47+
4448
var sandboxSettings = configuration.GetSection("DevelopmentSandbox").Get<DevelopmentSandboxSettings>() ?? new DevelopmentSandboxSettings();
4549
sandboxSettings.Enabled = sandboxSettings.Enabled && environment.IsDevelopment();
4650
services.AddSingleton(sandboxSettings);

backend/src/Taskdeck.Api/Program.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
builder.Environment,
3030
out var observabilitySettings,
3131
out var rateLimitingSettings,
32-
out var jwtSettings);
32+
out var jwtSettings,
33+
out var gitHubOAuthSettings);
3334

3435
// Add Infrastructure (DbContext, Repositories)
3536
builder.Services.AddInfrastructure(builder.Configuration);
@@ -44,8 +45,8 @@
4445
builder.Services.AddHttpContextAccessor();
4546
builder.Services.AddScoped<Taskdeck.Application.Interfaces.IUserContext, Taskdeck.Infrastructure.Identity.UserContext>();
4647

47-
// Add JWT Authentication
48-
builder.Services.AddTaskdeckAuthentication(jwtSettings);
48+
// Add JWT Authentication (with optional GitHub OAuth)
49+
builder.Services.AddTaskdeckAuthentication(jwtSettings, gitHubOAuthSettings);
4950

5051
// Add OpenTelemetry observability
5152
builder.Services.AddTaskdeckObservability(observabilitySettings);

backend/src/Taskdeck.Api/appsettings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@
7373
"XFrameOptions": "DENY",
7474
"ReferrerPolicy": "no-referrer"
7575
},
76+
"GitHubOAuth": {
77+
"ClientId": "",
78+
"ClientSecret": ""
79+
},
7680
"AllowedHosts": "*",
7781
"ConnectionStrings": {
7882
"DefaultConnection": "Data Source=taskdeck.db"

backend/src/Taskdeck.Application/DTOs/UserDtos.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,11 @@ public record LoginDto(
2929
public record AuthResultDto(
3030
string Token,
3131
UserDto User);
32+
33+
public record ExternalLoginDto(
34+
string Provider,
35+
string ProviderUserId,
36+
string Username,
37+
string Email,
38+
string? DisplayName = null,
39+
string? AvatarUrl = null);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using Taskdeck.Domain.Entities;
2+
3+
namespace Taskdeck.Application.Interfaces;
4+
5+
public interface IExternalLoginRepository : IRepository<ExternalLogin>
6+
{
7+
Task<ExternalLogin?> GetByProviderAsync(string provider, string providerUserId, CancellationToken cancellationToken = default);
8+
Task<IEnumerable<ExternalLogin>> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
9+
}

backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public interface IUnitOfWork
2626
IAgentRunRepository AgentRuns { get; }
2727
IKnowledgeDocumentRepository KnowledgeDocuments { get; }
2828
IKnowledgeChunkRepository KnowledgeChunks { get; }
29+
IExternalLoginRepository ExternalLogins { get; }
2930

3031
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
3132
Task BeginTransactionAsync(CancellationToken cancellationToken = default);

0 commit comments

Comments
 (0)