Skip to content

Authenticate with GitHub OAuth #57

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 3 commits into from
Feb 7, 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
129 changes: 64 additions & 65 deletions rubberduckvba.Server/Api/Auth/AuthController.cs
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Octokit;
using Octokit.Internal;
using System.Security.Claims;
using System.Text;

namespace rubberduckvba.Server.Api.Auth;

public record class UserViewModel
{
public static UserViewModel Anonymous { get; } = new UserViewModel { Name = "(anonymous)", HasOrgRole = false };
public static UserViewModel Anonymous { get; } = new UserViewModel { Name = "(anonymous)", IsAuthenticated = false, IsAdmin = false };

public string Name { get; init; } = default!;
public bool HasOrgRole { get; init; }
public bool IsAuthenticated { get; init; }
public bool IsAdmin { get; init; }
}


public record class SignInViewModel
{
public string? State { get; init; }
public string? Code { get; init; }
public string? Token { get; init; }
}

[ApiController]
[AllowAnonymous]
public class AuthController(IOptions<GitHubSettings> configuration, IOptions<ApiSettings> api) : ControllerBase
public class AuthController(IOptions<GitHubSettings> configuration, IOptions<ApiSettings> api, ILogger<AuthController> logger) : ControllerBase
{
[HttpGet("auth")]
[AllowAnonymous]
public ActionResult<UserViewModel> Index()
public IActionResult Index()
{
var claims = HttpContext.User.Claims.ToDictionary(claim => claim.Type, claim => claim.Value);
var hasName = claims.TryGetValue(ClaimTypes.Name, out var name);
Expand All @@ -37,10 +39,12 @@ public ActionResult<UserViewModel> Index()
return BadRequest();
}

var isAuthenticated = HttpContext.User.Identity?.IsAuthenticated ?? false;
var model = new UserViewModel
{
Name = name,
HasOrgRole = (HttpContext.User.Identity?.IsAuthenticated ?? false) && role == configuration.Value.OwnerOrg
IsAuthenticated = isAuthenticated,
IsAdmin = role == configuration.Value.OwnerOrg
};

return Ok(model);
Expand All @@ -52,12 +56,13 @@ public ActionResult<UserViewModel> Index()
}

[HttpPost("auth/signin")]
[AllowAnonymous]
public async Task<ActionResult> SignIn()
public IActionResult SessionSignIn(SignInViewModel vm)
{
var xsrf = Guid.NewGuid().ToString();
HttpContext.Session.SetString("xsrf:state", xsrf);
await HttpContext.Session.CommitAsync();
if (User.Identity?.IsAuthenticated ?? false)
{
logger.LogInformation("Signin was requested, but user is already authenticated. Redirecting to home page...");
return Redirect("/");
}

var clientId = configuration.Value.ClientId;
var agent = configuration.Value.UserAgent;
Expand All @@ -67,53 +72,45 @@ public async Task<ActionResult> SignIn()
{
AllowSignup = false,
Scopes = { "read:user", "read:org" },
State = xsrf
State = vm.State
};

logger.LogInformation("Requesting OAuth app GitHub login url...");
var url = github.Oauth.GetGitHubLoginUrl(request);
if (url is null)
{
logger.LogInformation("OAuth login was cancelled by the user or did not return a url.");
return Forbid();
}

// TODO log url
//return Redirect(url.ToString());
return RedirectToAction("Index", "Home");
logger.LogInformation("Returning the login url for the client to redirect. State: {xsrf}", vm.State);
return Ok(url.ToString());
}

[HttpGet("auth/github")]
[AllowAnonymous]
public async Task<ActionResult> GitHubCallback(string code, string state)
[HttpPost("auth/github")]
public async Task<IActionResult> OnGitHubCallback(SignInViewModel vm)
{
if (string.IsNullOrWhiteSpace(code))
{
return BadRequest();
}

var expected = HttpContext.Session.GetString("xsrf:state");
HttpContext.Session.Clear();
await HttpContext.Session.CommitAsync();

if (state != expected)
{
return BadRequest();
}

logger.LogInformation("OAuth token was received. State: {state}", vm.State);
var clientId = configuration.Value.ClientId;
var clientSecret = configuration.Value.ClientSecret;
var agent = configuration.Value.UserAgent;

var github = new GitHubClient(new ProductHeaderValue(agent));

var request = new OauthTokenRequest(clientId, clientSecret, code);
var request = new OauthTokenRequest(clientId, clientSecret, vm.Code);
var token = await github.Oauth.CreateAccessToken(request);
if (token is null)
{
logger.LogWarning("OAuth access token was not created.");
return Unauthorized();
}

await AuthorizeAsync(token.AccessToken);

return Ok();
logger.LogInformation("OAuth access token was created. Authorizing...");
var authorizedToken = await AuthorizeAsync(token.AccessToken);
return authorizedToken is null ? Unauthorized() : Ok(vm with { Token = authorizedToken });
}

private async Task AuthorizeAsync(string token)
private async Task<string?> AuthorizeAsync(string token)
{
try
{
Expand All @@ -122,42 +119,44 @@ private async Task AuthorizeAsync(string token)
var githubUser = await github.User.Current();
if (githubUser.Suspended)
{
throw new InvalidOperationException("User is suspended");
logger.LogWarning("User {name} with login '{login}' ({url}) is a suspended GitHub account and will not be authorized.", githubUser.Name, githubUser.Login, githubUser.Url);
return default;
}

var emailClaim = new Claim(ClaimTypes.Email, githubUser.Email);

var identity = new ClaimsIdentity("github", ClaimTypes.Name, ClaimTypes.Role);
if (identity != null)
{
identity.AddClaim(new Claim(ClaimTypes.Name, githubUser.Login));

var orgs = await github.Organization.GetAllForUser(githubUser.Login);
var rdOrg = orgs.SingleOrDefault(org => org.Id == configuration.Value.RubberduckOrgId);
identity.AddClaim(new Claim(ClaimTypes.Name, githubUser.Login));
logger.LogInformation("Creating claims identity for GitHub login '{login}'...", githubUser.Login);

if (rdOrg != null)
{
identity.AddClaim(new Claim(ClaimTypes.Role, configuration.Value.OwnerOrg));
identity.AddClaim(new Claim(ClaimTypes.Authentication, token));
identity.AddClaim(new Claim("access_token", token));
var orgs = await github.Organization.GetAllForUser(githubUser.Login);
var rdOrg = orgs.SingleOrDefault(org => org.Id == configuration.Value.RubberduckOrgId);

var principal = new ClaimsPrincipal(identity);
if (rdOrg != null)
{
identity.AddClaim(new Claim(ClaimTypes.Role, configuration.Value.OwnerOrg));
identity.AddClaim(new Claim(ClaimTypes.Authentication, token));
identity.AddClaim(new Claim("access_token", token));
logger.LogDebug("GitHub Organization claims were granted. Creating claims principal...");

var issued = DateTime.UtcNow;
var expires = issued.Add(TimeSpan.FromMinutes(50));
var roles = string.Join(",", identity.Claims.Where(claim => claim.Type == ClaimTypes.Role).Select(claim => claim.Value));
var principal = new ClaimsPrincipal(identity);
var roles = string.Join(",", identity.Claims.Where(claim => claim.Type == ClaimTypes.Role).Select(claim => claim.Value));

HttpContext.User = principal;
Thread.CurrentPrincipal = HttpContext.User;
HttpContext.User = principal;
Thread.CurrentPrincipal = HttpContext.User;

var jwt = principal.AsJWT(api.Value.SymetricKey, configuration.Value.JwtIssuer, configuration.Value.JwtAudience);
HttpContext.Session.SetString("jwt", jwt);
}
logger.LogInformation("GitHub user with login {login} has signed in with role authorizations '{role}'.", githubUser.Login, configuration.Value.OwnerOrg);
return token;
}
else
{
logger.LogWarning("User {name} ({email}) with login '{login}' is not a member of organization ID {org} and will not be authorized.", githubUser.Name, githubUser.Email, githubUser.Login, configuration.Value.RubberduckOrgId);
return default;
}
}
catch (Exception)
{
// just ignore: configuration needs the org (prod) client app id to avoid throwing this exception
logger.LogWarning("An exception was thrown. Verify GitHub:ClientId and GitHub:ClientSecret configuration; authorization fails.");
return default;
}
}
}
27 changes: 20 additions & 7 deletions rubberduckvba.Server/GitHubAuthenticationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,28 @@ public GitHubAuthenticationHandler(IGitHubClientService github,

protected async override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var token = Context.Request.Headers["X-ACCESS-TOKEN"].SingleOrDefault();
if (token is null)
try
{
var token = Context.Request.Headers["X-ACCESS-TOKEN"].SingleOrDefault();
if (string.IsNullOrWhiteSpace(token))
{
return AuthenticateResult.NoResult();
}

var principal = await _github.ValidateTokenAsync(token);
if (principal is ClaimsPrincipal)
{
Context.User = principal;
Thread.CurrentPrincipal = principal;
return AuthenticateResult.Success(new AuthenticationTicket(principal, "github"));
}

return AuthenticateResult.NoResult();
}
catch (InvalidOperationException e)
{
Logger.LogError(e, e.Message);
return AuthenticateResult.NoResult();
}

var principal = await _github.ValidateTokenAsync(token);
return principal is ClaimsPrincipal
? AuthenticateResult.Success(new AuthenticationTicket(principal, "github"))
: AuthenticateResult.NoResult();
}
}
10 changes: 6 additions & 4 deletions rubberduckvba.Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,7 @@ public static void Main(string[] args)
builder.Services.AddAuthentication(options =>
{
options.RequireAuthenticatedSignIn = false;

options.DefaultAuthenticateScheme = "github";
options.DefaultScheme = "anonymous";

options.AddScheme("github", builder =>
{
Expand Down Expand Up @@ -90,6 +88,7 @@ public static void Main(string[] args)
app.UseHttpsRedirection();

app.UseRouting();
app.UseSession();
app.UseAuthentication();
app.UseAuthorization();

Expand All @@ -98,9 +97,12 @@ public static void Main(string[] args)

app.UseCors(policy =>
{
policy.SetIsOriginAllowed(origin => true);
policy
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
.SetIsOriginAllowed(origin => true);
});
app.UseSession();

StartHangfire(app);
app.Run();
Expand Down
6 changes: 3 additions & 3 deletions rubberduckvba.Server/Services/GitHubClientService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ public class GitHubClientService(IOptions<GitHubSettings> configuration, ILogger
var user = await client.User.Current();
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, user.Name),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Name, user.Login),
new Claim(ClaimTypes.Role, config.OwnerOrg),
new Claim(ClaimTypes.Authentication, token),
new Claim("access_token", token)
});
}, "github");
return new ClaimsPrincipal(identity);
}

Expand Down
12 changes: 9 additions & 3 deletions rubberduckvba.client/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';

import { DataService } from './services/data.service';
import { ApiClientService } from './services/api-client.service';
import { AdminApiClientService, ApiClientService } from './services/api-client.service';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { BrowserModule } from '@angular/platform-browser';
Expand Down Expand Up @@ -33,6 +33,8 @@ import { AnnotationComponent } from './routes/annotation/annotation.component';
import { QuickFixComponent } from './routes/quickfixes/quickfix.component';

import { DefaultUrlSerializer, UrlTree } from '@angular/router';
import { AuthMenuComponent } from './components/auth-menu/auth-menu.component';
import { AuthComponent } from './routes/auth/auth.component';

/**
* https://stackoverflow.com/a/39560520
Expand All @@ -52,6 +54,7 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer {
declarations: [
AppComponent,
HomeComponent,
AuthComponent,
FeaturesComponent,
FeatureComponent,
TagDownloadComponent,
Expand All @@ -70,7 +73,8 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer {
InspectionComponent,
AnnotationComponent,
QuickFixComponent,
AboutComponent
AboutComponent,
AuthMenuComponent
],
bootstrap: [AppComponent],
imports: [
Expand All @@ -83,7 +87,8 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer {
{ path: 'inspections/:name', component: InspectionComponent },
{ path: 'annotations/:name', component: AnnotationComponent },
{ path: 'quickfixes/:name', component: QuickFixComponent },
{ path: 'about', component: AboutComponent},
{ path: 'about', component: AboutComponent },
{ path: 'auth/github', component: AuthComponent },
// legacy routes:
{ path: 'inspections/details/:name', redirectTo: 'inspections/:name' },
]),
Expand All @@ -93,6 +98,7 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer {
providers: [
DataService,
ApiClientService,
AdminApiClientService,
provideHttpClient(withInterceptorsFromDi()),
{
provide: UrlSerializer,
Expand Down
Loading