diff --git a/prdeploy-api/src/PrDeploy.Api.Business/Clients/GitHubAuthClient.cs b/prdeploy-api/src/PrDeploy.Api.Business/Clients/GitHubAuthClient.cs index 7b1f67b..9d38127 100644 --- a/prdeploy-api/src/PrDeploy.Api.Business/Clients/GitHubAuthClient.cs +++ b/prdeploy-api/src/PrDeploy.Api.Business/Clients/GitHubAuthClient.cs @@ -3,12 +3,14 @@ using System.Security.Claims; using System.Text; using FluentValidation; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Octokit; using PrDeploy.Api.Business.Auth; using PrDeploy.Api.Business.Auth.Interfaces; using PrDeploy.Api.Business.Clients.Interfaces; +using PrDeploy.Api.Business.Mapping; using PrDeploy.Api.Business.Options; using PrDeploy.Api.Models.Auth; using RestSharp; @@ -18,6 +20,7 @@ namespace PrDeploy.Api.Business.Clients public class GitHubAuthClient : IGitHubAuthClient { private readonly ICipherService _cipherService; + private readonly IServiceProvider _serviceProvider; private readonly IValidator _validator; private readonly IRestClient _client; private readonly GitHubAuthOptions _gitHubOptions; @@ -26,10 +29,12 @@ public class GitHubAuthClient : IGitHubAuthClient public GitHubAuthClient(IRestClientInstance restClientInstance, IOptions jwtOptions, ICipherService cipherService, + IServiceProvider serviceProvider, IValidator validator) { _jwtOptions = jwtOptions.Value; _cipherService = cipherService; + _serviceProvider = serviceProvider; _validator = validator; _client = restClientInstance.RestClient; _gitHubOptions = restClientInstance.Options; @@ -58,7 +63,16 @@ public async Task GetAccessTokenAsync(AccessTokenRequest ac return response!; } - public string CreateJwt(string gitHubToken) + public async Task GetUserInfoAsync() + { + // We have to resolve this here to make sure there is a token. + var gitHubClient = _serviceProvider.GetRequiredService(); + var user = await gitHubClient.User.Current(); + var userInfo = Map.UserInfo(user); + return userInfo ?? new UserInfo(); + } + + private string CreateJwt(string gitHubToken) { var encryptedToken = _cipherService.Encrypt(gitHubToken); var key = Encoding.ASCII.GetBytes(_jwtOptions.Key); diff --git a/prdeploy-api/src/PrDeploy.Api.Business/Clients/Interfaces/IGitHubAuthClient.cs b/prdeploy-api/src/PrDeploy.Api.Business/Clients/Interfaces/IGitHubAuthClient.cs index 757e912..953ccbf 100644 --- a/prdeploy-api/src/PrDeploy.Api.Business/Clients/Interfaces/IGitHubAuthClient.cs +++ b/prdeploy-api/src/PrDeploy.Api.Business/Clients/Interfaces/IGitHubAuthClient.cs @@ -6,4 +6,5 @@ namespace PrDeploy.Api.Business.Clients.Interfaces; public interface IGitHubAuthClient { Task GetAccessTokenAsync(AccessTokenRequest accessTokenRequest); + Task GetUserInfoAsync(); } \ No newline at end of file diff --git a/prdeploy-api/src/PrDeploy.Api.Business/Mapping/Map.Objects.cs b/prdeploy-api/src/PrDeploy.Api.Business/Mapping/Map.Objects.cs index 2adb278..4db39d9 100644 --- a/prdeploy-api/src/PrDeploy.Api.Business/Mapping/Map.Objects.cs +++ b/prdeploy-api/src/PrDeploy.Api.Business/Mapping/Map.Objects.cs @@ -1,10 +1,25 @@ -using PrDeploy.Api.Models.OwnerRepo; +using Octokit; +using PrDeploy.Api.Models.Auth; using PrDeploy.Api.Models.PullRequests; -using PrDeploy.Api.Models.Settings; +using PullRequest = PrDeploy.Api.Models.PullRequests.PullRequest; +using Repository = PrDeploy.Api.Models.OwnerRepo.Repository; namespace PrDeploy.Api.Business.Mapping; public static partial class Map { + public static UserInfo? UserInfo(User? source) => + source != null + ? new UserInfo + { + Id = source.Id, + AvatarUrl = source.AvatarUrl, + Login = source.Login, + Name = !string.IsNullOrEmpty(source.Name) ? source.Name : source.Login, + Admin = source.SiteAdmin, + Type = (GitHubAccountType)source.Type.GetValueOrDefault() + } + : null; + public static PullRequest? PullRequest(Octokit.PullRequest? source) => source != null ? new PullRequest diff --git a/prdeploy-api/src/PrDeploy.Api.Models/Auth/GitHubAccountType.cs b/prdeploy-api/src/PrDeploy.Api.Models/Auth/GitHubAccountType.cs new file mode 100644 index 0000000..fbb6d6b --- /dev/null +++ b/prdeploy-api/src/PrDeploy.Api.Models/Auth/GitHubAccountType.cs @@ -0,0 +1,10 @@ +namespace PrDeploy.Api.Models.Auth +{ + public enum GitHubAccountType + { + User = 0, + Organization = 1, + Bot = 2, + Mannequin = 3 + } +} diff --git a/prdeploy-api/src/PrDeploy.Api.Models/Auth/UserInfo.cs b/prdeploy-api/src/PrDeploy.Api.Models/Auth/UserInfo.cs new file mode 100644 index 0000000..38447a1 --- /dev/null +++ b/prdeploy-api/src/PrDeploy.Api.Models/Auth/UserInfo.cs @@ -0,0 +1,17 @@ +namespace PrDeploy.Api.Models.Auth +{ + public class UserInfo + { + public long Id { get; set; } + + public string AvatarUrl { get; set; } = string.Empty; + + public string Login { get; set; } + + public string Name { get; set; } + + public bool Admin { get; set; } + + public GitHubAccountType? Type { get; set; } + } +} diff --git a/prdeploy-api/src/PrDeploy.Api/Program.cs b/prdeploy-api/src/PrDeploy.Api/Program.cs index a972834..85c0ead 100644 --- a/prdeploy-api/src/PrDeploy.Api/Program.cs +++ b/prdeploy-api/src/PrDeploy.Api/Program.cs @@ -1,3 +1,5 @@ +using System.Net; +using System.Text.Json; using HotChocolate.AspNetCore; using PrDeploy.Api; using PrDeploy.Api.Business; @@ -5,7 +7,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.IdentityModel.Tokens; +using Octokit; using PrDeploy.Api.Auth; using PrDeploy.Api.Builder; using PrDeploy.Api.Business.Clients.Interfaces; @@ -14,8 +16,9 @@ using PrDeploy.Api.Models.Auth; using Serilog; using Serilog.Formatting.Json; -using IServiceCollectionExtensions = PrDeploy.Api.IServiceCollectionExtensions; using Path = System.IO.Path; +using GreenDonut; +using System.Text.Json.Serialization; try { @@ -85,6 +88,12 @@ app.UseEndpoints(endpoints => { // Simple GitHub Access Token proxy. + var apiJsonSerializerOptions = new JsonSerializerOptions + { + Converters = { new JsonStringEnumConverter() }, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + endpoints.MapPost("/api/oauth/access_token", async (HttpRequest request, IGitHubAuthClient authClient) => { var form = await request.ReadFormAsync(); @@ -118,6 +127,24 @@ }) .AllowAnonymous(); + // User info endpoint since GitHub does not provide one for OAuth. + endpoints.MapGet("/api/oauth/userinfo", async (HttpRequest request, IGitHubAuthClient authClient) => + { + IResult result; + try + { + var userInfo = await authClient.GetUserInfoAsync(); + result = Results.Json(userInfo, apiJsonSerializerOptions, statusCode: StatusCodes.Status200OK); + } + catch (HttpRequestException e) + { + Log.Logger.Error(e, $"Error getting user info ({e.StatusCode})."); + result = Results.StatusCode((int)e.StatusCode!); + } + + return result; + }); + endpoints.MapGraphQL().WithOptions(new GraphQLServerOptions { Tool = { Enable = false } // Use Apollo Playground instead of Banana Cake Pop. }); diff --git a/prdeploy-app/src/app/shared/components/user-panel/user-panel.component.html b/prdeploy-app/src/app/shared/components/user-panel/user-panel.component.html index 9df7ab9..8fcadf2 100644 --- a/prdeploy-app/src/app/shared/components/user-panel/user-panel.component.html +++ b/prdeploy-app/src/app/shared/components/user-panel/user-panel.component.html @@ -1,7 +1,11 @@
@if (menuMode === 'context') { diff --git a/prdeploy-app/src/app/shared/components/user-panel/user-panel.component.scss b/prdeploy-app/src/app/shared/components/user-panel/user-panel.component.scss index 52c41f3..eaa8f53 100644 --- a/prdeploy-app/src/app/shared/components/user-panel/user-panel.component.scss +++ b/prdeploy-app/src/app/shared/components/user-panel/user-panel.component.scss @@ -26,6 +26,11 @@ margin: 0 auto; font-size: 18px; } + + img { + width: 32px; + height: 32px; + } } } diff --git a/prdeploy-app/src/app/shared/components/user-panel/user-panel.component.ts b/prdeploy-app/src/app/shared/components/user-panel/user-panel.component.ts index 42f63fd..e124e6d 100644 --- a/prdeploy-app/src/app/shared/components/user-panel/user-panel.component.ts +++ b/prdeploy-app/src/app/shared/components/user-panel/user-panel.component.ts @@ -1,6 +1,9 @@ -import { Component, Input } from '@angular/core'; +import { Component, DestroyRef, Input } from '@angular/core'; import { DxListModule } from 'devextreme-angular/ui/list'; import { DxContextMenuModule } from 'devextreme-angular/ui/context-menu'; +import { AuthService } from '../../services'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { User } from '../../models'; @Component({ selector: 'app-user-panel', @@ -16,5 +19,12 @@ export class UserPanelComponent { @Input() menuMode!: string; - constructor() {} + user: User = null; + + constructor( + private _authService: AuthService, + private _destroyRef: DestroyRef + ) { + this._authService.user$.pipe(takeUntilDestroyed(this._destroyRef)).subscribe(user => (this.user = user)); + } } diff --git a/prdeploy-app/src/app/shared/models/github-account-type.ts b/prdeploy-app/src/app/shared/models/github-account-type.ts new file mode 100644 index 0000000..61c5d11 --- /dev/null +++ b/prdeploy-app/src/app/shared/models/github-account-type.ts @@ -0,0 +1,6 @@ +export enum GitHubAccountType { + User = 'User', + Organization = 'Organization', + Bot = 'Bot', + Mannequin = 'Mannequin' +} diff --git a/prdeploy-app/src/app/shared/models/index.ts b/prdeploy-app/src/app/shared/models/index.ts index 5e5af9f..6ecf985 100644 --- a/prdeploy-app/src/app/shared/models/index.ts +++ b/prdeploy-app/src/app/shared/models/index.ts @@ -1,2 +1,4 @@ export * from './repository'; export * from './tab'; +export * from './user'; +export * from './github-account-type'; diff --git a/prdeploy-app/src/app/shared/models/user.ts b/prdeploy-app/src/app/shared/models/user.ts new file mode 100644 index 0000000..65c6aa3 --- /dev/null +++ b/prdeploy-app/src/app/shared/models/user.ts @@ -0,0 +1,10 @@ +import { GitHubAccountType } from './github-account-type'; + +export interface User { + id: number; + avatarUrl: string; + login: string; + name: string; + admin: boolean; + type: GitHubAccountType; +} diff --git a/prdeploy-app/src/app/shared/services/auth/auth-config.ts b/prdeploy-app/src/app/shared/services/auth/auth-config.ts index f8d334c..a4564a4 100644 --- a/prdeploy-app/src/app/shared/services/auth/auth-config.ts +++ b/prdeploy-app/src/app/shared/services/auth/auth-config.ts @@ -8,10 +8,12 @@ export const authConfig = (options: OAuthOptions, document: Document) => { issuer: 'https://github.com', loginUrl: 'https://github.com/login/oauth/authorize', tokenEndpoint: '/api/oauth/access_token', + userinfoEndpoint: '/api/oauth/userinfo', clientId: options.clientId, // The "Auth Code + PKCE" client responseType: 'code', redirectUri: document.location.origin + '/login/callback', scope: 'repo read:org', + oidc: false, sessionChecksEnabled: true, showDebugInformation: true, // Also requires enabling "Verbose" level in devtools clearHashAfterLogin: false, // https://github.com/manfredsteyer/angular-oauth2-oidc/issues/457#issuecomment-431807040, diff --git a/prdeploy-app/src/app/shared/services/auth/auth.service.ts b/prdeploy-app/src/app/shared/services/auth/auth.service.ts index 8fecbfa..8286252 100644 --- a/prdeploy-app/src/app/shared/services/auth/auth.service.ts +++ b/prdeploy-app/src/app/shared/services/auth/auth.service.ts @@ -3,34 +3,38 @@ import { Router } from '@angular/router'; import { AuthConfig, OAuthErrorEvent, OAuthService } from 'angular-oauth2-oidc'; import { BehaviorSubject } from 'rxjs'; import { filter } from 'rxjs/operators'; +import { User } from '../../models'; @Injectable({ providedIn: 'root' }) export class AuthService { private static readonly DefaultUrl = '/deployments'; private static readonly LoginUrl = '/login'; + private userSubject$ = new BehaviorSubject(null); + user$ = this.userSubject$.asObservable(); + private isAuthenticatedSubject$ = new BehaviorSubject(false); - public isAuthenticated$ = this.isAuthenticatedSubject$.asObservable(); + isAuthenticated$ = this.isAuthenticatedSubject$.asObservable(); private isDoneLoadingSubject$ = new BehaviorSubject(false); - public isDoneLoading$ = this.isDoneLoadingSubject$.asObservable(); - - private navigateToLoginPage() { - this._router.navigateByUrl(AuthService.LoginUrl); - } + isDoneLoading$ = this.isDoneLoadingSubject$.asObservable(); constructor( private _oauthService: OAuthService, private _router: Router ) {} - public login(targetUrl?: string) { + get user(): User { + return this.userSubject$.value; + } + + login(targetUrl?: string) { // Note: before version 9.1.0 of the library you needed to // call encodeURIComponent on the argument to the method. this._oauthService.initLoginFlow(targetUrl || this._router.url); } - public async runInitialLoginSequence(): Promise { + async runInitialLoginSequence(): Promise { await this._oauthService.tryLogin(); this.updateIsAuthenticated(); if (!this.isAuthenticatedSubject$.value) { @@ -52,18 +56,18 @@ export class AuthService { this.isDoneLoadingSubject$.next(true); } - public logout(forbidden = false) { + logout(forbidden = false) { this._oauthService.logOut(); this._router.navigate([AuthService.LoginUrl], { queryParams: !forbidden ? null : { forbidden } }); } - public hasValidToken() { + hasValidToken() { return this._oauthService.hasValidAccessToken(); } - public initialize(authConfig: AuthConfig): void { + initialize(authConfig: AuthConfig): void { this._oauthService.configure(authConfig); // Useful for debugging: @@ -102,17 +106,35 @@ export class AuthService { this.updateIsAuthenticated(); this._oauthService.events.pipe(filter(e => ['token_received'].includes(e.type))).subscribe(async () => { - // const user = (await this._oauthService.loadUserProfile()) as User; - // this.userSubject$.next(user); + await this.tryLoadUser(); this.updateIsAuthenticated(); }); this._oauthService.events .pipe(filter(e => ['session_terminated', 'session_error'].includes(e.type))) .subscribe(() => this.navigateToLoginPage()); + + this.isDoneLoading$.subscribe(async done => { + if (!done) { + return; + } + await this.tryLoadUser(); + }); + } + + private async tryLoadUser() { + const isAuthenticated = this._oauthService.hasValidAccessToken(); + if (isAuthenticated && !this.user) { + const userInfo: any = await this._oauthService.loadUserProfile(); + this.userSubject$.next(userInfo.info as User); + } } private updateIsAuthenticated(): void { this.isAuthenticatedSubject$.next(this._oauthService.hasValidAccessToken()); } + + private navigateToLoginPage() { + this._router.navigateByUrl(AuthService.LoginUrl); + } }