feat: implement authentication flow and dynamic API configuration #5

This commit is contained in:
Marek Lesko
2025-07-31 17:41:18 +02:00
parent 42f84e878f
commit 0ab0402172
14 changed files with 152 additions and 52 deletions

View File

@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Authorization;
namespace Api.Controllers namespace Api.Controllers
{ {
[ApiController] [ApiController]
//[Authorize] [Authorize]
[Route("api/product")] [Route("api/product")]
public class ProductController : ControllerBase public class ProductController : ControllerBase
{ {

View File

@@ -8,6 +8,8 @@ namespace Api
{ {
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Api.Models; using Api.Models;
using Microsoft.AspNetCore.Rewrite;
public static class Program public static class Program
{ {
public static void Main(string[] args) public static void Main(string[] args)
@@ -22,12 +24,7 @@ namespace Api
}) })
.AddJwtBearer(options => .AddJwtBearer(options =>
{ {
// options.Events = new JwtBearerEvents
// {
// OnTokenValidated = context => Task.CompletedTask,
// OnChallenge = context => Task.CompletedTask
// };
options.Authority = builder.Configuration["Authentication:PocketId:Authority"]; options.Authority = builder.Configuration["Authentication:PocketId:Authority"];
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters() options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters()
{ {
@@ -61,47 +58,43 @@ namespace Api
var app = builder.Build(); var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
// app.Use(async (context, next) =>
// {
// if (context.Request.Method == HttpMethods.Options)
// {
// context.Response.Headers.Add("Access-Control-Allow-Origin", "*");
// context.Response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
// context.Response.Headers.Add("Access-Control-Allow-Headers", "Content-Type");
// context.Response.StatusCode = StatusCodes.Status204NoContent;
// return;
// }
// await next();
// });
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
app.UseHttpsRedirection(); app.UseHttpsRedirection();
} }
if (!app.Environment.IsDevelopment()) var routes = new[] { "api", "swagger" };
var rewriteString = String.Join("|", routes);
var rewriteOptions = new RewriteOptions()
.AddRewrite(@$"^(?!.*?\b({rewriteString}))^(?!.*?\.\b(jpg|jpeg|png|svg|ttf|woff|woff2|html|js|json|css|ico))", "index.html", false);
app.UseRewriter(rewriteOptions);
if (app.Environment.IsDevelopment())
{
var currentDirectory = Directory.GetCurrentDirectory();
var staticFilePath = Path.Combine(currentDirectory,"../Web/dist/Web/browser");
app.UseDefaultFiles(new DefaultFilesOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(staticFilePath),
DefaultFileNames = new List<string> { "index.html" }
});
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(staticFilePath),
RequestPath = ""
});
}
else
{ {
app.UseDefaultFiles(); // Uses wwwroot by default app.UseDefaultFiles(); // Uses wwwroot by default
app.UseStaticFiles(); app.UseStaticFiles();
// Angular routing fallback for production
app.Use(async (context, next) =>
{
await next();
var path = context.Request.Path.Value ?? string.Empty;
if (context.Response.StatusCode == 404 &&
!System.IO.Path.HasExtension(path) &&
!path.StartsWith("/api"))
{
context.Request.Path = "/index.html";
await next();
}
});
} }
app.UseCors("AllowAll"); app.UseCors("AllowAll");
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();

3
Web/public/config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"apiEndpoint": "https://localhost:5001"
}

View File

@@ -1,15 +1,17 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core'; import { ApplicationConfig, inject, provideAppInitializer, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { provideOAuthClient } from 'angular-oauth2-oidc'; import { provideOAuthClient } from 'angular-oauth2-oidc';
import { AppConfigService } from './services/config.service';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(), provideZonelessChangeDetection(),
provideRouter(routes), provideRouter(routes),
provideAppInitializer(() => inject(AppConfigService).loadConfig()),
provideHttpClient(), provideHttpClient(),
provideOAuthClient({ provideOAuthClient({
resourceServer: { resourceServer: {

View File

@@ -0,0 +1,9 @@
import { CanActivateFn } from '@angular/router';
import { inject } from '@angular/core';
import { OAuthService } from 'angular-oauth2-oidc';
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(OAuthService);
return authService.hasValidAccessToken(); // returns boolean, Promise, or Observable
};

View File

@@ -1,9 +1,20 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { Login } from './login/login'; import { Login } from './login/login';
import { authGuard } from './app.route.guard';
export const routes: Routes = [ export const routes: Routes = [
{ {
path: 'login', path: 'login',
component: Login component: Login,
},
{
path: 'content',
children: [
{
path: '',
loadComponent: () => import('./content/content').then(m => m.Content),
canActivate: [authGuard],
}
],
} }
]; ];

View File

@@ -1,16 +1,19 @@
import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { Component, OnInit } from '@angular/core'; import { APP_INITIALIZER, Component, inject, OnInit, provideAppInitializer } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router'; import { Router, RouterOutlet } from '@angular/router';
import { DefaultOAuthInterceptor, OAuthService } from 'angular-oauth2-oidc'; import { DefaultOAuthInterceptor, OAuthService } from 'angular-oauth2-oidc';
import { AppConfigService } from './services/config.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet], imports: [RouterOutlet],
providers: [OAuthService, { providers: [
provide: HTTP_INTERCEPTORS, OAuthService,
useClass: DefaultOAuthInterceptor, {
multi: true, provide: HTTP_INTERCEPTORS,
}], useClass: DefaultOAuthInterceptor,
multi: true,
}],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss' styleUrl: './app.scss'
}) })
@@ -28,6 +31,6 @@ export class App implements OnInit {
}); });
} }
ngOnInit(): void { ngOnInit(): void {
this.as.loadDiscoveryDocumentAndLogin(); this.as.loadDiscoveryDocumentAndLogin().then(() => this.router.navigate(['login']));
} }
} }

View File

@@ -0,0 +1 @@
<span>{{data()|json}}</span>

View File

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Content } from './content';
describe('Content', () => {
let component: Content;
let fixture: ComponentFixture<Content>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Content]
})
.compileComponents();
fixture = TestBed.createComponent(Content);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,23 @@
import { JsonPipe } from '@angular/common';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Component, isDevMode, signal } from '@angular/core';
import { OAuthService } from 'angular-oauth2-oidc';
import { AppConfigService } from '../services/config.service';
@Component({
selector: 'app-content',
imports: [JsonPipe],
templateUrl: './content.html',
styleUrl: './content.scss'
})
export class Content {
data = signal({});
constructor(httpClient: HttpClient, readonly as: OAuthService, readonly cs: AppConfigService) {
httpClient.get((isDevMode() ? cs.setting.apiEndpoint : '') + '/api/product', {
headers: new HttpHeaders({ Authorization: `Bearer ${as.getAccessToken()}` }).append('Content-Type', 'application/json')
}).subscribe(data => {
this.data.set(data);
});
}
}

View File

@@ -1 +1,2 @@
<p>login works!</p>
Logging in...

View File

@@ -1,21 +1,33 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { OAuthService } from 'angular-oauth2-oidc'; import { OAuthService } from 'angular-oauth2-oidc';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
imports: [],
templateUrl: './login.html', templateUrl: './login.html',
styleUrl: './login.scss' styleUrl: './login.scss'
}) })
export class Login implements OnInit { export class Login implements OnInit {
constructor(private readonly as: OAuthService, private readonly httpClient: HttpClient) {
} constructor(readonly as: OAuthService, readonly router: Router) { }
ngOnInit(): void { ngOnInit(): void {
this.httpClient.get('https://localhost:5001/api/product', { if (this.as.hasValidAccessToken() && this.as.hasValidIdToken()) {
headers: new HttpHeaders({ Authorization: `Bearer ${this.as.getAccessToken()}` }).append('Content-Type', 'application/json') this.getUserInfo();
} else {
this.as.events.subscribe(event => {
if (event.type === 'token_received')
if (this.as.hasValidIdToken()) {
this.getUserInfo();
}
})
} }
).subscribe(console.warn); }
getUserInfo(): void {
this.as.loadUserProfile().then(value => {
console.log('User profile loaded:', value);
this.router.navigate(['/content']);
});
} }
} }

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class AppConfigService {
private config: any;
constructor(private readonly http: HttpClient) { }
loadConfig(): Observable<any> {
return this.http.get('/config.json')
.pipe(tap(data => this.config = data))
}
get setting() {
return this.config;
}
}