Skip to content

Commit fe091a1

Browse files
authored
Merge pull request #969 from Chris0Jeky/fix/sec-31-oauth-scope-validation
SEC-31: Add OAuth scope validation for GitHub login
2 parents b244b16 + 5a92d55 commit fe091a1

5 files changed

Lines changed: 574 additions & 2 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
2828
services.AddScoped<AuthenticationService>();
2929
services.AddScoped<AuthorizationService>();
3030
services.AddScoped<MfaService>();
31+
services.AddSingleton<OAuthScopeValidator>();
3132
services.AddScoped<IAuthorizationService>(sp => sp.GetRequiredService<AuthorizationService>());
3233
services.AddScoped<UserService>();
3334
services.AddScoped<BoardAccessService>();

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

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
using Microsoft.AspNetCore.Authentication;
44
using Microsoft.AspNetCore.Authentication.Cookies;
55
using Microsoft.AspNetCore.Authentication.JwtBearer;
6+
using Microsoft.AspNetCore.Authentication.OAuth;
67
using Microsoft.AspNetCore.Authentication.OAuth.Claims;
78
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
89
using Microsoft.AspNetCore.Http;
10+
using Microsoft.Extensions.DependencyInjection;
911
using Microsoft.Extensions.Http;
12+
using Microsoft.Extensions.Logging;
1013
using Microsoft.IdentityModel.Tokens;
1114
using Polly;
1215
using Polly.Extensions.Http;
@@ -135,15 +138,69 @@ await context.Response.WriteAsJsonAsync(new ApiErrorResponse(
135138
options.Scope.Add("read:user");
136139
options.Scope.Add("user:email");
137140

141+
// Ensure any additional RequiredScopes from configuration are also
142+
// requested from GitHub. Without this, configuring a required scope
143+
// that isn't requested causes all logins to be rejected.
144+
foreach (var scope in gitHubOAuthSettings.RequiredScopes ?? Enumerable.Empty<string>())
145+
{
146+
if (!options.Scope.Contains(scope))
147+
options.Scope.Add(scope);
148+
}
149+
foreach (var scope in gitHubOAuthSettings.ExpectedScopes ?? Enumerable.Empty<string>())
150+
{
151+
if (!options.Scope.Contains(scope))
152+
options.Scope.Add(scope);
153+
}
154+
138155
options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
139156
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "login");
140157
options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
141158
options.ClaimActions.MapJsonKey("urn:github:name", "name");
142159
options.ClaimActions.MapJsonKey("urn:github:login", "login");
143160
options.ClaimActions.MapJsonKey("urn:github:avatar", "avatar_url");
144161

145-
// OAuthHandler fetches UserInformationEndpoint automatically
146-
// and applies ClaimActions — no custom OnCreatingTicket needed.
162+
// Validate OAuth scopes after token exchange.
163+
// GitHub returns the granted scopes in the token response body as "scope"
164+
// (comma-separated). Reject authentication if required scopes are missing.
165+
var scopeSettings = gitHubOAuthSettings;
166+
options.Events = new OAuthEvents
167+
{
168+
OnCreatingTicket = context =>
169+
{
170+
var scopeValidator = context.HttpContext.RequestServices
171+
.GetRequiredService<OAuthScopeValidator>();
172+
173+
// GitHub returns granted scopes in the token response "scope" field.
174+
// The X-OAuth-Scopes header appears on API responses, but the token
175+
// response body is the authoritative source at auth time.
176+
var grantedScopesRaw = context.TokenResponse?.Response?.RootElement
177+
.TryGetProperty("scope", out var scopeElement) == true
178+
? scopeElement.GetString()
179+
: null;
180+
181+
var validationResult = scopeValidator.Validate(
182+
grantedScopesRaw,
183+
scopeSettings.RequiredScopes,
184+
scopeSettings.ExpectedScopes);
185+
186+
if (!validationResult.IsValid)
187+
{
188+
var logger = context.HttpContext.RequestServices
189+
.GetRequiredService<ILoggerFactory>()
190+
.CreateLogger("Taskdeck.Api.OAuth.ScopeValidation");
191+
192+
logger.LogError(
193+
"GitHub OAuth authentication rejected: missing required scopes. " +
194+
"Missing: [{MissingScopes}]. User must re-authorize with required permissions.",
195+
string.Join(", ", validationResult.MissingRequiredScopes));
196+
197+
context.Fail(validationResult.ErrorMessage
198+
?? "GitHub OAuth scope validation failed");
199+
}
200+
201+
return Task.CompletedTask;
202+
}
203+
};
147204

148205
// Circuit breaker for the OAuth backchannel (token exchange + user info).
149206
if (circuitBreakerTracker is not null && circuitBreakerSettings is not null)

backend/src/Taskdeck.Application/Services/GitHubOAuthSettings.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@ public class GitHubOAuthSettings
99
public string ClientId { get; set; } = string.Empty;
1010
public string ClientSecret { get; set; } = string.Empty;
1111

12+
/// <summary>
13+
/// Scopes that must be present in the GitHub OAuth response.
14+
/// Authentication is rejected if any required scope is missing.
15+
/// Default: "user:email" (needed to retrieve the user's email address).
16+
/// </summary>
17+
public List<string> RequiredScopes { get; set; } = new() { "user:email" };
18+
19+
/// <summary>
20+
/// Scopes that are expected but not mandatory.
21+
/// A warning is logged if any expected scope is missing, but authentication proceeds.
22+
/// Default: "read:user", "user:email".
23+
/// </summary>
24+
public List<string> ExpectedScopes { get; set; } = new() { "read:user", "user:email" };
25+
1226
/// <summary>
1327
/// Returns true when GitHub OAuth is fully configured and should be active.
1428
/// </summary>
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
using Microsoft.Extensions.Logging;
2+
3+
namespace Taskdeck.Application.Services;
4+
5+
/// <summary>
6+
/// Validates OAuth scopes granted by external providers against required and expected scopes.
7+
/// GitHub returns granted scopes as a comma-separated list in the token response body.
8+
/// </summary>
9+
public class OAuthScopeValidator
10+
{
11+
private readonly ILogger<OAuthScopeValidator> _logger;
12+
13+
public OAuthScopeValidator(ILogger<OAuthScopeValidator> logger)
14+
{
15+
_logger = logger;
16+
}
17+
18+
/// <summary>
19+
/// Validates the granted scopes against the configured required and expected scopes.
20+
/// </summary>
21+
/// <param name="grantedScopesHeader">
22+
/// The raw scope string from the provider (e.g. from GitHub's token response "scope" field).
23+
/// GitHub uses comma-separated scopes; this method handles both comma and space separators.
24+
/// </param>
25+
/// <param name="requiredScopes">Scopes that must be present — authentication fails if any are missing.</param>
26+
/// <param name="expectedScopes">Scopes that should be present — a warning is logged if missing, but auth proceeds.</param>
27+
/// <returns>A result indicating whether validation passed, with details about any issues.</returns>
28+
public OAuthScopeValidationResult Validate(
29+
string? grantedScopesHeader,
30+
IReadOnlyList<string>? requiredScopes,
31+
IReadOnlyList<string>? expectedScopes)
32+
{
33+
var grantedScopes = ParseScopes(grantedScopesHeader);
34+
// GitHub scopes are case-sensitive (e.g. "read:user" != "Read:User").
35+
// Use Ordinal comparison to match exactly what the provider returns.
36+
var grantedSet = new HashSet<string>(grantedScopes, StringComparer.Ordinal);
37+
38+
var effectiveRequired = requiredScopes ?? Array.Empty<string>();
39+
var effectiveExpected = expectedScopes ?? Array.Empty<string>();
40+
41+
// Check required scopes
42+
var missingRequired = effectiveRequired
43+
.Where(scope => !grantedSet.Contains(scope))
44+
.ToList();
45+
46+
if (missingRequired.Count > 0)
47+
{
48+
// Caller in AuthenticationRegistration logs a terminal LogError;
49+
// this LogWarning provides structured detail for diagnostics.
50+
_logger.LogWarning(
51+
"OAuth scope validation failed: required scopes missing. Required: [{RequiredScopes}], Granted: [{GrantedScopes}], Missing: [{MissingScopes}]",
52+
string.Join(", ", effectiveRequired),
53+
string.Join(", ", grantedScopes),
54+
string.Join(", ", missingRequired));
55+
56+
return OAuthScopeValidationResult.Failed(missingRequired, grantedScopes);
57+
}
58+
59+
// Check expected (non-required) scopes
60+
var missingExpected = effectiveExpected
61+
.Where(scope => !grantedSet.Contains(scope))
62+
.ToList();
63+
64+
if (missingExpected.Count > 0)
65+
{
66+
_logger.LogWarning(
67+
"OAuth scope validation warning: expected scopes missing. Expected: [{ExpectedScopes}], Granted: [{GrantedScopes}], Missing: [{MissingScopes}]. Authentication will proceed.",
68+
string.Join(", ", effectiveExpected),
69+
string.Join(", ", grantedScopes),
70+
string.Join(", ", missingExpected));
71+
}
72+
73+
return OAuthScopeValidationResult.Succeeded(grantedScopes, missingExpected);
74+
}
75+
76+
/// <summary>
77+
/// Parses a scope string into a list of individual scopes.
78+
/// Handles GitHub's comma-separated format and standard space-separated format.
79+
/// Strips tabs and other whitespace to guard against malformed responses.
80+
/// </summary>
81+
internal static List<string> ParseScopes(string? scopeHeader)
82+
{
83+
if (string.IsNullOrWhiteSpace(scopeHeader))
84+
return new List<string>();
85+
86+
// GitHub uses comma-separated scopes (e.g. "read:user, user:email")
87+
// OAuth2 standard uses space-separated scopes
88+
// Handle both by splitting on commas, spaces, and tabs, then trimming
89+
var scopes = scopeHeader
90+
.Split(new[] { ',', ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
91+
.Select(s => s.Trim())
92+
.Where(s => !string.IsNullOrWhiteSpace(s))
93+
.ToList();
94+
95+
return scopes;
96+
}
97+
}
98+
99+
/// <summary>
100+
/// Result of OAuth scope validation.
101+
/// </summary>
102+
public class OAuthScopeValidationResult
103+
{
104+
public bool IsValid { get; private init; }
105+
public IReadOnlyList<string> GrantedScopes { get; private init; } = Array.Empty<string>();
106+
public IReadOnlyList<string> MissingRequiredScopes { get; private init; } = Array.Empty<string>();
107+
public IReadOnlyList<string> MissingExpectedScopes { get; private init; } = Array.Empty<string>();
108+
109+
/// <summary>
110+
/// A user-facing error message when validation fails. Null when valid.
111+
/// </summary>
112+
public string? ErrorMessage { get; private init; }
113+
114+
public static OAuthScopeValidationResult Failed(
115+
IReadOnlyList<string> missingRequired,
116+
IReadOnlyList<string> grantedScopes)
117+
{
118+
var scopeList = string.Join(", ", missingRequired);
119+
return new OAuthScopeValidationResult
120+
{
121+
IsValid = false,
122+
GrantedScopes = grantedScopes,
123+
MissingRequiredScopes = missingRequired,
124+
MissingExpectedScopes = Array.Empty<string>(),
125+
ErrorMessage = $"GitHub did not grant the required OAuth scopes: {scopeList}. " +
126+
"Please re-authorize the application with the required permissions."
127+
};
128+
}
129+
130+
public static OAuthScopeValidationResult Succeeded(
131+
IReadOnlyList<string> grantedScopes,
132+
IReadOnlyList<string> missingExpected)
133+
{
134+
return new OAuthScopeValidationResult
135+
{
136+
IsValid = true,
137+
GrantedScopes = grantedScopes,
138+
MissingRequiredScopes = Array.Empty<string>(),
139+
MissingExpectedScopes = missingExpected,
140+
ErrorMessage = null
141+
};
142+
}
143+
}

0 commit comments

Comments
 (0)