|
| 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