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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
Web/public/images/svgs/microsoft-icon.svg
Normal file
6
Web/public/images/svgs/microsoft-icon.svg
Normal 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 |
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
|
||||
35
Web/src/app/models/auth.models.ts
Normal file
35
Web/src/app/models/auth.models.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
29
Web/src/app/services/auth.interceptor.ts
Normal file
29
Web/src/app/services/auth.interceptor.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user