feat: Implement OAuth2 authentication with Microsoft, Google, and PocketId

- Added JWT configuration to appsettings.json for secure token handling.
- Updated config.json to include OAuth provider details for Microsoft, Google, and PocketId.
- Added Microsoft icon SVG for UI representation.
- Refactored app.config.ts to use a custom AuthInterceptor for managing access tokens.
- Enhanced auth route guard to handle asynchronous authentication checks.
- Created new auth models for structured request and response handling.
- Developed a callback component to manage user login states and transitions.
- Updated side-login component to support multiple OAuth providers with loading states.
- Implemented authentication service methods for handling OAuth login flows and token management.
- Added error handling and user feedback for authentication processes.
This commit is contained in:
Marek Lesko
2025-11-07 19:23:21 +00:00
parent c14f62849f
commit f34d523413
23 changed files with 2090 additions and 83 deletions

View File

@@ -1,3 +1,18 @@
{
"apiEndpoint": "http://localhost:5000"
"apiEndpoint": "http://localhost:5000",
"oauthProviders": {
"microsoft": {
"clientId": "eb03f08b-280a-46c7-9700-b012caa46000",
"issuer": "https://login.microsoftonline.com/4a0d328f-1f94-4920-b67e-4275737d02a5/v2.0"
},
"google": {
"clientId": "1000025801082-09qojecdodogc3j8g32d6case1chtb25.apps.googleusercontent.com",
"issuer": "https://accounts.google.com",
"dummyClientSecret": "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"
},
"pocketid": {
"clientId": "21131567-fea1-42a2-8907-21abd874eff8",
"issuer": "https://identity.lesko.me"
}
}
}

View File

@@ -0,0 +1,6 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="11" height="11" fill="#F25022"/>
<rect x="15" y="2" width="11" height="11" fill="#7FBA00"/>
<rect x="2" y="15" width="11" height="11" fill="#00A4EF"/>
<rect x="15" y="15" width="11" height="11" fill="#FFB900"/>
</svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@@ -41,9 +41,10 @@ import { adapterFactory } from 'angular-calendar/date-adapters/date-fns';
// code view
import { provideHighlightOptions } from 'ngx-highlightjs';
import 'highlight.js/styles/atom-one-dark.min.css';
import { DefaultOAuthInterceptor, OAuthModule, provideOAuthClient } from 'angular-oauth2-oidc';
import { OAuthModule, provideOAuthClient } from 'angular-oauth2-oidc';
import { AppConfigService } from './services/config.service';
import { ApiEndpointInterceptor } from './services/http.interceptor';
import { AuthInterceptor } from './services/auth.interceptor';
export function HttpLoaderFactory(http: HttpClient): any {
return new TranslateHttpLoader(http, './assets/i18n/', '.json');
@@ -74,7 +75,7 @@ export const appConfig: ApplicationConfig = {
provideAppInitializer(() => inject(AppConfigService).loadConfig()),
{
provide: HTTP_INTERCEPTORS,
useClass: DefaultOAuthInterceptor,
useClass: AuthInterceptor,
multi: true,
},
{
@@ -84,12 +85,7 @@ export const appConfig: ApplicationConfig = {
},
provideHttpClient(withInterceptorsFromDi()),
provideOAuthClient({
resourceServer: {
allowedUrls: ['http://localhost:5000', 'https://localhost:4200', 'https://centrum.lesko.me', 'https://beta.e-dias.sk/'],
sendAccessToken: true,
},
}),
provideOAuthClient(),
// provideClientHydration(),
provideAnimationsAsync(),

View File

@@ -3,15 +3,23 @@ import { inject } from '@angular/core';
import { AuthenticationService } from './services/authentication.service';
export const authGuard: CanActivateFn = (route, state) => {
export const authGuard: CanActivateFn = async (route, state) => {
const auth = inject(AuthenticationService);
const router = inject(Router);
if (auth.hasValidAccessToken())
if (auth.profile)
if (auth.hasValidAccessToken()) {
if (auth.profile) {
return true;
else
return auth.handleCallback();
} else {
try {
const user = await auth.handleCallback();
return user ? true : router.parseUrl('/authentication/login');
} catch (error) {
console.error('Auth guard callback error:', error);
return router.parseUrl('/authentication/login');
}
}
}
// redirect to the login page (UrlTree) so navigation does not fail silently
return router.parseUrl('/authentication/login');

View File

@@ -0,0 +1,35 @@
export interface AuthenticateRequest {
idToken: string;
provider: string;
accessToken?: string; // Optional access token for API calls
}
export interface AuthenticateResponse {
accessToken: string;
expiresAt: string;
user: UserProfile;
isNewUser: boolean;
}
export interface UserProfile {
id: number;
email: string;
firstName?: string;
lastName?: string;
profilePictureUrl?: string;
createdAt: string;
lastLoginAt?: string;
providers: string[];
// Computed properties for template compatibility
name?: string; // Will be computed from firstName + lastName
picture?: string; // Alias for profilePictureUrl
role?: string; // Default user role
}
export interface OAuthConfig {
provider: 'Microsoft' | 'Google' | 'PocketId';
issuer: string;
clientId: string;
scope?: string;
}

View File

@@ -0,0 +1,30 @@
<div class="blank-layout-container justify-content-center">
<div class="position-relative row w-100 h-100">
<div class="col-12 d-flex align-items-center justify-content-center h-100">
<div class="text-center">
<!-- Loading State -->
<div *ngIf="loading && !error" class="d-flex flex-column align-items-center">
<mat-spinner diameter="48" class="m-b-16"></mat-spinner>
<h4 class="f-w-500 f-s-20 m-b-8">Spracúvame prihlásenie...</h4>
<p class="f-s-14 text-muted">Prosím počkajte, overujeme vaše údaje.</p>
</div>
<!-- Success State -->
<div *ngIf="!loading && !error && profile" class="d-flex flex-column align-items-center">
<mat-icon class="text-success f-s-48 m-b-16">check_circle</mat-icon>
<h4 class="f-w-500 f-s-20 m-b-8">Prihlásenie úspešné!</h4>
<p class="f-s-14 text-muted m-b-16">Vitajte, {{ profile.firstName || profile.email }}!</p>
<p class="f-s-12 text-muted">Presmerovávame vás na hlavnú stránku...</p>
</div>
<!-- Error State -->
<div *ngIf="error" class="d-flex flex-column align-items-center">
<mat-icon class="text-danger f-s-48 m-b-16">error</mat-icon>
<h4 class="f-w-500 f-s-20 m-b-8">Prihlásenie zlyhalo</h4>
<p class="f-s-14 text-muted m-b-16">{{ error }}</p>
<p class="f-s-12 text-muted">Presmerovávame vás späť na prihlásenie...</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,31 +1,52 @@
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { Router, RouterModule } from '@angular/router';
import { CoreService } from '../../../services/core.service';
import { MaterialModule } from '../../../material.module';
import { AuthenticationService } from '../../../services/authentication.service';
import { NgScrollbarModule } from "ngx-scrollbar";
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-callback',
imports: [RouterModule, MaterialModule, NgScrollbarModule],
imports: [RouterModule, MaterialModule, NgScrollbarModule, CommonModule],
templateUrl: './callback.component.html',
})
export class CallbackComponent {
export class CallbackComponent implements OnInit {
options: any;
profile: any;
loading = true;
error: string | null = null;
constructor(private settings: CoreService, private as: AuthenticationService, private router: Router) {
this.options = this.settings.getOptions();
}
// Handle the OAuth2 callback and load user profile
this.as
.handleCallback()
.then(_ => {
console.log('Login successful');
this.profile = this.as.profile;
this.router.navigate(['/dashboard/main']);
}).catch(err => {
console.error('Error handling callback', err);
});
async ngOnInit() {
try {
// Handle the OAuth2 callback and authenticate with our API
const user = await this.as.handleCallback();
if (user) {
console.log('Login successful', user);
this.profile = user;
this.loading = false;
// Redirect to dashboard after a short delay
setTimeout(() => {
this.router.navigate(['/dashboard/main']);
}, 1500);
} else {
throw new Error('Authentication failed - no user returned');
}
} catch (err: any) {
console.error('Error handling callback', err);
this.error = err.message || 'Authentication failed. Please try again.';
this.loading = false;
// Redirect to login after error delay
setTimeout(() => {
this.router.navigate(['/authentication/side-login']);
}, 3000);
}
}
}

View File

@@ -17,18 +17,28 @@
<span class="f-s-14 d-block f-s-14 m-t-8">Váš uživateľský prístup</span>
<div class="row m-t-24 align-items-center">
<a mat-stroked-button class="w-100" (click)="googleLogin()">
<a mat-stroked-button class="w-100" (click)="loginWithMicrosoft()" [disabled]="loading">
<div class="d-flex align-items-center">
<img src="/assets/images/svgs/google-icon.svg" alt="google" width="16" class="m-r-8" />
Prihlásiť sa pomocou Google
<img src="/assets/images/svgs/microsoft-icon.svg" alt="microsoft" width="16" class="m-r-8" />
<span *ngIf="!loading">Prihlásiť sa pomocou Microsoft</span>
<span *ngIf="loading">Prihlasovanie...</span>
</div>
</a>
<a mat-stroked-button class="w-100" style="margin-top:12px" (click)="pocketLogin()">
<a mat-stroked-button class="w-100" style="margin-top:12px" (click)="loginWithGoogle()" [disabled]="loading">
<div class="d-flex align-items-center">
<img src="/assets/images/svgs/google-icon.svg" alt="google" width="16" class="m-r-8" />
<span *ngIf="!loading">Prihlásiť sa pomocou Google</span>
<span *ngIf="loading">Prihlasovanie...</span>
</div>
</a>
<a mat-stroked-button class="w-100" style="margin-top:12px" (click)="loginWithPocketId()" [disabled]="loading">
<div class="d-flex align-items-center">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pocket-id-light.svg"
alt="google" width="16" class="m-r-8" />
Prihlásiť sa pomocou PocketId
alt="pocketid" width="16" class="m-r-8" />
<span *ngIf="!loading">Prihlásiť sa pomocou PocketId</span>
<span *ngIf="loading">Prihlasovanie...</span>
</div>
</a>
</div>

View File

@@ -5,31 +5,54 @@ import { Router, RouterModule } from '@angular/router';
import { MaterialModule } from '../../../material.module';
import { BrandingComponent } from '../../../layouts/full/vertical/sidebar/branding.component';
import { AuthenticationService } from '../../../services/authentication.service';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-side-login',
imports: [RouterModule, MaterialModule, FormsModule, ReactiveFormsModule, BrandingComponent],
imports: [RouterModule, MaterialModule, FormsModule, ReactiveFormsModule, BrandingComponent, CommonModule],
templateUrl: './side-login.component.html'
})
export class AppSideLoginComponent {
options: any;
loading = false;
constructor(private settings: CoreService, private router: Router, private readonly as: AuthenticationService) {
this.options = this.settings.getOptions();
}
googleLogin() {
this.as.configureAndLogin({
issuer: 'https://accounts.google.com',
clientId: '1000025801082-09ikmt61a9c9vbdjhpdab9b0ui3vdnij.apps.googleusercontent.com',
dummyClientSecret: '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',
});
async loginWithMicrosoft() {
if (this.loading) return;
this.loading = true;
try {
await this.as.loginWithMicrosoft();
} catch (error) {
console.error('Microsoft login failed:', error);
this.loading = false;
}
}
pocketLogin() {
this.as.configureAndLogin({
issuer: 'https://identity.lesko.me',
clientId: '21131567-fea1-42a2-8907-21abd874eff8',
});
async loginWithGoogle() {
if (this.loading) return;
this.loading = true;
try {
await this.as.loginWithGoogle();
} catch (error) {
console.error('Google login failed:', error);
this.loading = false;
}
}
async loginWithPocketId() {
if (this.loading) return;
this.loading = true;
try {
await this.as.loginWithPocketId();
} catch (error) {
console.error('PocketId login failed:', error);
this.loading = false;
}
}
}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@angular/core';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';
import { AuthenticationService } from './authentication.service';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private authService: AuthenticationService) { }
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// Skip adding auth header for authentication endpoints
if (req.url.includes('/api/auth/authenticate') || req.url.includes('/assets/')) {
return next.handle(req);
}
// Get custom access token
const token = this.authService.getCustomAccessToken();
if (token) {
// Clone request and add Authorization header
const authReq = req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`)
});
return next.handle(authReq);
}
return next.handle(req);
}
}

View File

@@ -1,14 +1,21 @@
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { AuthConfig, OAuthService } from 'angular-oauth2-oidc';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, firstValueFrom } from 'rxjs';
import { ToastrService } from 'ngx-toastr';
import { AuthenticateRequest, AuthenticateResponse, UserProfile, OAuthConfig } from '../models/auth.models';
import { AppConfigService } from './config.service';
const CONFIG_KEY = 'oauth_config_v1';
const TOKEN_KEY = 'custom_access_token';
const USER_KEY = 'user_profile';
@Injectable({ providedIn: 'root' })
export class AuthenticationService {
private config: Partial<AuthConfig> = {
redirectUri: window.location.origin + '/authentication/callback',
redirectUri: this.getRedirectUri(),
scope: 'openid profile email',
responseType: 'code',
requireHttps: false,
@@ -16,15 +23,43 @@ export class AuthenticationService {
timeoutFactor: 0.01,
};
public profile: any = null;
private getRedirectUri(): string {
// Use the current origin + callback path
const origin = window.location.origin;
// For development/testing environments, ensure we use the right callback
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
return `${origin}/authentication/callback`;
}
// For Gitpod/Codespaces or other cloud IDEs
if (origin.includes('gitpod.io') || origin.includes('github.dev') || origin.includes('codespaces')) {
return `${origin}/authentication/callback`;
}
// Default fallback
return `${origin}/authentication/callback`;
}
constructor(private oauthService: OAuthService, private router: Router) { }
public profile: UserProfile | null = null;
private userSubject = new BehaviorSubject<UserProfile | null>(null);
public user$ = this.userSubject.asObservable();
constructor(
private oauthService: OAuthService,
private router: Router,
private http: HttpClient,
private toastr: ToastrService,
private configService: AppConfigService
) {
this.loadStoredUser();
}
saveConfig(cfg: Partial<AuthConfig>) {
try {
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
} catch {
// ignore}
// ignore
}
}
@@ -37,6 +72,57 @@ export class AuthenticationService {
return null;
}
}
private loadStoredUser(): void {
try {
const userJson = localStorage.getItem(USER_KEY);
if (userJson) {
const user = JSON.parse(userJson) as UserProfile;
// Ensure computed properties are present (in case they were stored without them)
const enhancedUser: UserProfile = {
...user,
name: user.name || (user.firstName && user.lastName
? `${user.firstName} ${user.lastName}`
: user.firstName || user.lastName || user.email),
picture: user.picture || user.profilePictureUrl || 'https://via.placeholder.com/150?text=User',
role: user.role || 'Používateľ'
};
this.profile = enhancedUser;
this.userSubject.next(this.profile);
}
} catch {
// ignore
}
}
private saveUser(user: UserProfile): void {
try {
// Populate computed properties for template compatibility
const enhancedUser: UserProfile = {
...user,
name: user.firstName && user.lastName
? `${user.firstName} ${user.lastName}`
: user.firstName || user.lastName || user.email,
picture: user.profilePictureUrl || 'https://via.placeholder.com/150?text=User', // Default avatar
role: 'Používateľ' // Default role in Slovak
};
localStorage.setItem(USER_KEY, JSON.stringify(enhancedUser));
this.profile = enhancedUser;
this.userSubject.next(enhancedUser);
} catch {
// ignore
}
}
private saveCustomToken(token: string): void {
try {
localStorage.setItem(TOKEN_KEY, token);
} catch {
// ignore
}
}
// Configure the library and persist configuration
configure(cfg: Partial<AuthConfig>) {
this.config = { ...this.config, ...cfg };
@@ -61,6 +147,10 @@ export class AuthenticationService {
// Start login flow using discovery document + Authorization Code (PKCE)
startLogin(cfg?: Partial<AuthConfig>): Promise<void> {
if (cfg) this.configure(cfg);
console.log('OAuth Config:', this.config);
console.log('Redirect URI:', this.config.redirectUri);
return this.oauthService
.loadDiscoveryDocument()
.then(() => {
@@ -70,40 +160,201 @@ export class AuthenticationService {
}
// Call this on the callback route to process the redirect and obtain tokens + profile
handleCallback(): Promise<any> {
if (this.restoreConfiguration())
// Ensure discovery document loaded, then process code flow, then load profile
return this.oauthService
.loadDiscoveryDocumentAndTryLogin()
.then((isLoggedIn: boolean) => {
if (!isLoggedIn && !this.oauthService.hasValidAccessToken()) {
return Promise.reject('No valid token after callback');
}
return this.loadUserProfile();
});
else
return this.router.navigate(['/authentication/login']);
async handleCallback(): Promise<UserProfile | null> {
if (!this.restoreConfiguration()) {
this.router.navigate(['/authentication/login']);
return null;
}
try {
// Process OAuth callback to get ID token
const isLoggedIn = await this.oauthService.loadDiscoveryDocumentAndTryLogin();
if (!isLoggedIn && !this.oauthService.hasValidAccessToken()) {
throw new Error('No valid token after callback');
}
// Get the ID token from the OAuth service
const idToken = this.oauthService.getIdToken();
if (!idToken) {
throw new Error('No ID token received from OAuth provider');
}
// Get the access token for API calls (if available)
const accessToken = this.oauthService.getAccessToken();
// Determine the provider based on the current OAuth configuration
const provider = this.determineProvider();
// Call our API to authenticate and get custom access token
const authResponse = await this.authenticateWithApi(idToken, provider, accessToken);
// Save the custom access token and user profile
this.saveCustomToken(authResponse.accessToken);
this.saveUser(authResponse.user);
// Show success message
this.toastr.success(
authResponse.isNewUser ? 'Account created successfully!' : 'Logged in successfully!',
'Authentication'
);
return authResponse.user;
} catch (error) {
console.error('Authentication callback failed:', error);
this.toastr.error('Authentication failed. Please try again.', 'Error');
this.router.navigate(['/authentication/login']);
return null;
}
}
private determineProvider(): string {
const config = this.oauthService.issuer;
if (config.includes('login.microsoftonline.com')) {
return 'Microsoft';
} else if (config.includes('accounts.google.com')) {
return 'Google';
} else if (config.includes('identity.lesko.me')) {
return 'PocketId';
}
throw new Error(`Unknown OAuth provider: ${config}`);
}
private async authenticateWithApi(idToken: string, provider: string, accessToken?: string): Promise<AuthenticateResponse> {
const request: AuthenticateRequest = {
idToken,
provider,
...(accessToken && { accessToken }) // Only include accessToken if it exists
};
console.log('Authenticating with API:', {
provider,
hasIdToken: !!idToken,
hasAccessToken: !!accessToken
});
try {
const response = await firstValueFrom(
this.http.post<AuthenticateResponse>('/api/auth/authenticate', request)
);
return response;
} catch (error: any) {
console.error('API authentication failed:', error);
if (error.error?.message) {
throw new Error(error.error.message);
}
throw new Error('Authentication with API failed');
}
}
async getCurrentUser(): Promise<UserProfile | null> {
if (!this.hasValidCustomToken()) {
return null;
}
try {
const response = await firstValueFrom(
this.http.get<UserProfile>('/api/auth/me')
);
this.saveUser(response);
return response;
} catch (error) {
console.error('Failed to get current user:', error);
this.clearAuth();
return null;
}
}
loadUserProfile(): Promise<any> {
// This method is kept for backward compatibility
return this.oauthService.loadUserProfile()
.then(profile => {
this.profile = profile["info"];
return new Promise((resolve) => resolve(profile["info"]));
// Don't override our custom profile with OAuth profile
return profile;
});
}
// Convenience helpers
// OAuth2 provider-specific login methods
loginWithMicrosoft(): Promise<void> {
const providerConfig = this.configService.setting?.oauthProviders?.microsoft;
if (!providerConfig) {
throw new Error('Microsoft OAuth configuration not found');
}
const microsoftConfig: Partial<AuthConfig> = {
issuer: providerConfig.issuer,
clientId: providerConfig.clientId,
scope: 'openid profile email https://graph.microsoft.com/User.Read',
};
return this.startLogin(microsoftConfig);
}
loginWithGoogle(): Promise<void> {
const providerConfig = this.configService.setting?.oauthProviders?.google;
if (!providerConfig) {
throw new Error('Google OAuth configuration not found');
}
const googleConfig: Partial<AuthConfig> = {
issuer: providerConfig.issuer,
clientId: providerConfig.clientId,
scope: 'openid profile email',
// Override redirect URI for Google to match what might be registered
redirectUri: `${window.location.origin}/authentication/callback`
};
console.log('Google OAuth Config:', googleConfig);
return this.startLogin(googleConfig);
}
loginWithPocketId(): Promise<void> {
const providerConfig = this.configService.setting?.oauthProviders?.pocketid;
if (!providerConfig) {
throw new Error('PocketId OAuth configuration not found');
}
const pocketIdConfig: Partial<AuthConfig> = {
issuer: providerConfig.issuer,
clientId: providerConfig.clientId,
scope: 'openid profile email',
};
return this.startLogin(pocketIdConfig);
}
// Token management
hasValidCustomToken(): boolean {
const token = this.getCustomAccessToken();
if (!token) return false;
try {
// Basic JWT expiration check
const payload = JSON.parse(atob(token.split('.')[1]));
const now = Math.floor(Date.now() / 1000);
return payload.exp > now;
} catch {
return false;
}
}
getCustomAccessToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
// Keep OAuth methods for backward compatibility
get events() {
return this.oauthService.events;
}
hasValidAccessToken(): boolean {
return this.oauthService.hasValidAccessToken();
return this.hasValidCustomToken();
}
getAccessToken(): string {
return this.oauthService.getAccessToken();
return this.getCustomAccessToken() || '';
}
getIdToken(): string {
@@ -114,12 +365,44 @@ export class AuthenticationService {
return this.oauthService.getIdentityClaims() as object | null;
}
logout(destroyLocalSession = false) {
if (destroyLocalSession) {
try {
localStorage.removeItem(CONFIG_KEY);
} catch { }
async logout(destroyLocalSession = true): Promise<void> {
try {
// Call API logout endpoint if we have a valid token
if (this.hasValidCustomToken()) {
await firstValueFrom(this.http.post('/api/auth/logout', {}));
}
} catch (error) {
console.warn('Logout API call failed:', error);
}
this.oauthService.logOut();
this.clearAuth();
if (destroyLocalSession) {
this.oauthService.logOut();
}
this.toastr.success('Logged out successfully', 'Authentication');
this.router.navigate(['/authentication/login']);
}
private clearAuth(): void {
try {
localStorage.removeItem(CONFIG_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
} catch { }
this.profile = null;
this.userSubject.next(null);
}
// Check if user is authenticated
isAuthenticated(): boolean {
return this.hasValidCustomToken() && this.profile !== null;
}
// Get current user synchronously
getCurrentUserSync(): UserProfile | null {
return this.profile;
}
}