Skip to content

Angular 18 #80

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions rubberduckvba.Server/Api/Auth/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,9 @@ public IActionResult SessionSignIn(SignInViewModel vm)
[HttpPost("auth/github")]
[EnableCors(CorsPolicies.AllowAll)]
[AllowAnonymous]
public IActionResult OnGitHubCallback(SignInViewModel vm)
public async Task<IActionResult> OnGitHubCallback(SignInViewModel vm)
{
return GuardInternalAction(() =>
return await GuardInternalAction(async () =>
{
Logger.LogInformation("OAuth code was received. State: {state}", vm.State);
var clientId = configuration.Value.ClientId;
Expand All @@ -127,7 +127,7 @@ public IActionResult OnGitHubCallback(SignInViewModel vm)
var github = new GitHubClient(new ProductHeaderValue(agent));
var request = new OauthTokenRequest(clientId, clientSecret, vm.Code);

var token = github.Oauth.CreateAccessToken(request).GetAwaiter().GetResult();
var token = await github.Oauth.CreateAccessToken(request);

if (token is null)
{
Expand All @@ -136,7 +136,7 @@ public IActionResult OnGitHubCallback(SignInViewModel vm)
}

Logger.LogInformation("OAuth access token was created. Authorizing...");
var authorizedToken = AuthorizeAsync(token.AccessToken).GetAwaiter().GetResult();
var authorizedToken = await AuthorizeAsync(token.AccessToken);

return authorizedToken is null ? Unauthorized() : Ok(vm with { Token = authorizedToken });
});
Expand Down Expand Up @@ -176,7 +176,7 @@ public IActionResult OnGitHubCallback(SignInViewModel vm)
Thread.CurrentPrincipal = HttpContext.User;

Logger.LogInformation("GitHub user with login {login} has signed in with role authorizations '{role}'.", githubUser.Login, configuration.Value.OwnerOrg);
Response.Cookies.Append(GitHubAuthenticationHandler.AuthCookie, token, new CookieOptions
Response.Cookies.Append(GitHubAuthenticationHandler.AuthTokenHeader, token, new CookieOptions
{
IsEssential = true,
HttpOnly = true,
Expand Down
10 changes: 8 additions & 2 deletions rubberduckvba.Server/Api/Auth/ClaimsPrincipalExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@ namespace rubberduckvba.Server.Api.Auth;

public static class ClaimsPrincipalExtensions
{
public static string AsJWT(this ClaimsPrincipal principal, string secret, string issuer, string audience)
public static ClaimsPrincipal ToClaimsPrincipal(this JwtPayload payload)
{
var identity = new ClaimsIdentity(payload.Claims, "github");
return new ClaimsPrincipal(identity);
}

public static string ToJWT(this ClaimsPrincipal principal, string secret, string issuer, string audience, int expirationMinutes = 60)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
var token = new JwtSecurityToken(issuer, audience,
claims: principal?.Claims,
notBefore: new DateTimeOffset(DateTime.UtcNow).UtcDateTime,
expires: new DateTimeOffset(DateTime.UtcNow.AddMinutes(60)).UtcDateTime,
expires: new DateTimeOffset(DateTime.UtcNow.AddMinutes(expirationMinutes)).UtcDateTime,
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

return new JwtSecurityTokenHandler().WriteToken(token);
Expand Down
115 changes: 56 additions & 59 deletions rubberduckvba.Server/GitHubAuthenticationHandler.cs
Original file line number Diff line number Diff line change
@@ -1,112 +1,100 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using rubberduckvba.Server.Api.Auth;
using rubberduckvba.Server.Services;
using System.Collections.Concurrent;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text.Encodings.Web;

namespace rubberduckvba.Server;

public class GitHubAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public static readonly string AuthCookie = "x-access-token";
public static readonly string AuthTokenHeader = "x-access-token";
public static readonly string AuthCookie = "x-auth";

private readonly IGitHubClientService _github;
private readonly IMemoryCache _cache;

private static readonly MemoryCacheEntryOptions _options = new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(60),
};
private readonly string _audience;
private readonly string _issuer;
private readonly string _secret;

public GitHubAuthenticationHandler(IGitHubClientService github, IMemoryCache cache,
IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder)
public GitHubAuthenticationHandler(IGitHubClientService github, IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger,
UrlEncoder encoder, IOptions<ApiSettings> apiOptions)
: base(options, logger, encoder)
{
_github = github;
_cache = cache;
}

private static readonly ConcurrentDictionary<string, Task<AuthenticateResult?>> _authApiTask = new();
_audience = apiOptions.Value.Audience;
_issuer = apiOptions.Value.Issuer;
_secret = apiOptions.Value.SymetricKey;
}

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
try
{
var token = Context.Request.Cookies[AuthCookie]
?? Context.Request.Headers[AuthCookie];

if (string.IsNullOrWhiteSpace(token))
if (TryAuthenticateJWT(out var jwtResult))
{
return Task.FromResult(AuthenticateResult.Fail("Access token was not provided"));
return Task.FromResult(jwtResult!);
}

if (TryAuthenticateFromCache(token, out var cachedResult))
var token = Context.Request.Headers[AuthTokenHeader].SingleOrDefault();
if (!string.IsNullOrEmpty(token))
{
return Task.FromResult(cachedResult)!;
}

if (TryAuthenticateGitHubToken(token, out var result)
&& result is AuthenticateResult
&& result.Ticket is AuthenticationTicket ticket)
{
CacheAuthenticatedTicket(token, ticket);
return Task.FromResult(result!);
}

if (TryAuthenticateFromCache(token, out cachedResult))
{
return Task.FromResult(cachedResult!);
if (TryAuthenticateGitHubToken(token, out var result)
&& result is AuthenticateResult
&& result.Ticket is AuthenticationTicket)
{
return Task.FromResult(result!);
}
}

return Task.FromResult(AuthenticateResult.Fail("Missing or invalid access token"));
}
catch (InvalidOperationException e)
{
Logger.LogError(e, e.Message);
Logger.LogError(e, "{Message}", e.Message);
return Task.FromResult(AuthenticateResult.NoResult());
}
}

private void CacheAuthenticatedTicket(string token, AuthenticationTicket ticket)
{
if (!string.IsNullOrWhiteSpace(token) && ticket.Principal.Identity?.IsAuthenticated == true)
{
_cache.Set(token, ticket, _options);
}
}

private bool TryAuthenticateFromCache(string token, out AuthenticateResult? result)
private bool TryAuthenticateJWT(out AuthenticateResult? result)
{
result = null;
if (_cache.TryGetValue(token, out var cached) && cached is AuthenticationTicket cachedTicket)

var jsonContent = Context.Request.Cookies[AuthCookie];
if (!string.IsNullOrEmpty(jsonContent))
{
var cachedPrincipal = cachedTicket.Principal;
var payload = JwtPayload.Deserialize(jsonContent);
if (!payload.Iss.Equals(_issuer, StringComparison.OrdinalIgnoreCase))
{
Logger.LogWarning("Invalid issuer in JWT payload: {Issuer}", payload.Iss);
return false;
}
if (!payload.Aud.Contains(_audience))
{
Logger.LogWarning("Invalid audience in JWT payload: {Audience}", payload.Aud);
return false;
}

Context.User = cachedPrincipal;
Thread.CurrentPrincipal = cachedPrincipal;
var principal = payload.ToClaimsPrincipal();
Context.User = principal;
Thread.CurrentPrincipal = principal;

Logger.LogInformation($"Successfully retrieved authentication ticket from cached token for {cachedPrincipal.Identity!.Name}; token will not be revalidated.");
result = AuthenticateResult.Success(cachedTicket);
var ticket = new AuthenticationTicket(principal, "github");
result = AuthenticateResult.Success(ticket);
return true;
}

return false;
}

private bool TryAuthenticateGitHubToken(string token, out AuthenticateResult? result)
{
result = null;
if (_authApiTask.TryGetValue(token, out var task) && task is not null)
{
result = task.GetAwaiter().GetResult();
return result is not null;
}
var task = AuthenticateGitHubAsync(token);
result = task.GetAwaiter().GetResult();

_authApiTask[token] = AuthenticateGitHubAsync(token);
result = _authApiTask[token].GetAwaiter().GetResult();

_authApiTask[token] = null!;
return result is not null;
}

Expand All @@ -118,6 +106,15 @@ private bool TryAuthenticateGitHubToken(string token, out AuthenticateResult? re
Context.User = principal;
Thread.CurrentPrincipal = principal;

var jwt = principal.ToJWT(_secret, _issuer, _audience);
Context.Response.Cookies.Append(AuthCookie, jwt, new CookieOptions
{
IsEssential = true,
HttpOnly = true,
Secure = true,
Expires = DateTimeOffset.UtcNow.AddHours(1)
});

var ticket = new AuthenticationTicket(principal, "github");
return AuthenticateResult.Success(ticket);
}
Expand Down
3 changes: 3 additions & 0 deletions rubberduckvba.Server/GitHubSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ namespace rubberduckvba.Server;
public record class ApiSettings
{
public string SymetricKey { get; set; } = default!;

public string Audience { get; set; } = default!;
public string Issuer { get; set; } = default!;
}

public record class ConnectionSettings
Expand Down
32 changes: 31 additions & 1 deletion rubberduckvba.Server/RubberduckApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,37 @@ protected RubberduckApiController(ILogger logger)
}

protected ILogger Logger => _logger;
protected async Task<IActionResult> GuardInternalAction(Func<Task<IActionResult>> method, [CallerMemberName] string name = default!)
{
var sw = Stopwatch.StartNew();
IActionResult result = NoContent();
var success = false;
try
{
_logger.LogTrace("GuardInternalAction:{name} | ▶ Invoking controller action", name);
result = await method.Invoke();
success = true;
}
catch (Exception exception)
{
_logger.LogError(exception, "GuardInternalAction:{name} | ❌ An exception was caught", name);
throw;
}
finally
{
sw.Stop();
if (success)
{
_logger.LogTrace("GuardInternalAction:{name} | ✔️ Controller action completed | ⏱️ {elapsed}", name, sw.Elapsed);
}
else
{
_logger.LogWarning("GuardInternalAction:{name} | ⚠️ Controller action completed with errors", name);
}
}

return result;
}

protected IActionResult GuardInternalAction(Func<IActionResult> method, [CallerMemberName] string name = default!)
{
Expand Down Expand Up @@ -48,7 +79,6 @@ protected IActionResult GuardInternalAction(Func<IActionResult> method, [CallerM
}
}

//Response.Headers.AccessControlAllowOrigin = "*";
return result;
}
}
3 changes: 2 additions & 1 deletion rubberduckvba.client/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
},
"index": "src/index.html",
"polyfills": [
"zone.js"
"zone.js",
"@angular/localize/init"
],
"tsConfig": "tsconfig.app.json",
"assets": [
Expand Down
Loading
Loading