UNPKG

@analog-tools/auth

Version:
601 lines (584 loc) 23 kB
import * as i0 from '@angular/core'; import { makeStateKey, inject, PLATFORM_ID, DOCUMENT, Injector, TransferState, signal, runInInjectionContext, computed, effect, Injectable, makeEnvironmentProviders } from '@angular/core'; import { Router } from '@angular/router'; import { isPlatformBrowser, isPlatformServer } from '@angular/common'; import { HttpHeaders, httpResource, HttpErrorResponse } from '@angular/common/http'; import { injectRequest } from '@analogjs/router/tokens'; import { catchError, EMPTY, Observable, throwError } from 'rxjs'; import { TRPCClientError } from '@trpc/client'; function fromKeycloak(keycloakUser) { return { username: keycloakUser.preferred_username, fullName: keycloakUser.name, givenName: keycloakUser.given_name, familyName: keycloakUser.family_name, picture: undefined, email: keycloakUser.email, emailVerified: keycloakUser.email_verified, locale: undefined, // Locale is not provided in Keycloak user info lastLogin: new Date().toISOString(), // Assuming last login is now updatedAt: undefined, // Assuming updated at is now createdAt: undefined, // Assuming created at is now auth_id: keycloakUser.sub, // Using sub as auth_id }; } /** * Transforms an Auth0 user object into the application's AuthUser format * @param auth0User - The user object from Auth0 * @returns A standardized AuthUser object */ function fromAuth0(auth0User) { return { username: auth0User.nickname || auth0User.email || '', fullName: auth0User.name || '', givenName: auth0User.given_name || '', familyName: auth0User.family_name || '', picture: auth0User.picture, email: auth0User.email, emailVerified: auth0User.email_verified, locale: auth0User.locale, lastLogin: auth0User.last_login, updatedAt: auth0User.updated_at, createdAt: auth0User.created_at, auth_id: auth0User.sub, // If roles are stored directly or in app_metadata roles: auth0User.roles || auth0User.app_metadata?.["roles"], }; } /** * Detects the identity provider based on user data structure * @param userInfo - User information object from the provider * @returns The detected identity provider type */ function detectProvider(userInfo) { if (!userInfo) { return 'unknown'; } // Check for Keycloak-specific properties if (userInfo['realm_access'] || userInfo['resource_access']) { return 'keycloak'; } // Check for Auth0-specific properties if (userInfo['nickname'] !== undefined || userInfo['user_metadata'] !== undefined || userInfo['app_metadata'] !== undefined) { return 'auth0'; } // If we can't determine the provider, check for common patterns // Auth0 typically includes an issuer URL with "auth0.com" if (userInfo['iss'] && typeof userInfo['iss'] === 'string' && userInfo['iss'].includes('auth0.com')) { return 'auth0'; } // Keycloak typically includes an issuer URL with "auth/realms" if (userInfo['iss'] && typeof userInfo['iss'] === 'string' && userInfo['iss'].includes('/auth/realms')) { return 'keycloak'; } // If we still can't determine, return unknown return 'unknown'; } /** * Transforms user data from any supported identity provider into the application's AuthUser format * @param userInfo - User information object from the provider * @returns A standardized AuthUser object */ function transformUserFromProvider(userInfo) { const provider = detectProvider(userInfo); switch (provider) { case 'keycloak': return fromKeycloak(userInfo); case 'auth0': return fromAuth0(userInfo); case 'unknown': default: // Fallback transformation for unknown providers // This provides a basic mapping that should work with most standard OIDC providers return { username: userInfo['preferred_username'] || userInfo['nickname'] || userInfo['email'] || userInfo['sub'] || '', fullName: userInfo['name'] || '', givenName: userInfo['given_name'] || '', familyName: userInfo['family_name'] || '', picture: userInfo['picture'], email: userInfo['email'], emailVerified: userInfo['email_verified'], locale: userInfo['locale'], lastLogin: userInfo['last_login'] || new Date().toISOString(), updatedAt: userInfo['updated_at'], createdAt: userInfo['created_at'], auth_id: userInfo['sub'], roles: userInfo['roles'] || [], }; } } function getRequestHeaders(serverRequest, originalHeaderValues) { let headers = new HttpHeaders(); headers = headers.set('fetch', 'true'); if (originalHeaderValues) { Object.entries(originalHeaderValues).forEach(([key, value]) => { if (value !== null && value !== undefined) { headers = headers.set(key, value); } }); } if (serverRequest) { Object.entries(serverRequest.headers).forEach(([key, value]) => { if (value !== null && value !== undefined && typeof value === 'string') { headers = headers.set(key, value); } }); } return headers; } const MAX_USER_RELOAD_ATTEMPTS = 3; const AUTH_TRANSFER_STATE_KEY = makeStateKey('analog-tools.auth.snapshot'); /** * Auth service for BFF (Backend for Frontend) authentication pattern * Uses server-side sessions with Auth0 instead of client-side tokens */ class AuthService { router = inject(Router); platformId = inject(PLATFORM_ID); document = inject(DOCUMENT); injector = inject(Injector); transferState = inject(TransferState); checkAuthInterval = null; userReloadEffect = null; userReloadTimeout = null; userReloadAttempts = 0; hasRevalidatedBrowserAuth = false; providedServerRequest = signal(null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "providedServerRequest" }] : /* istanbul ignore next */ [])); transferredSnapshot = this.consumeTransferredSnapshot(); isSettledResourceStatus(status) { return status === 'resolved' || status === 'local' || status === 'error'; } consumeTransferredSnapshot() { if (!isPlatformBrowser(this.platformId)) { return null; } if (!this.transferState.hasKey(AUTH_TRANSFER_STATE_KEY)) { return null; } const snapshot = this.transferState.get(AUTH_TRANSFER_STATE_KEY, null); this.transferState.remove(AUTH_TRANSFER_STATE_KEY); return snapshot; } setServerRequest(serverRequest) { if (serverRequest) { this.providedServerRequest.set(serverRequest); } } resolveRequestHeaders(originalHeaderValues) { const providedServerRequest = this.providedServerRequest(); if (providedServerRequest) { return getRequestHeaders(providedServerRequest, originalHeaderValues); } return runInInjectionContext(this.injector, () => { return getRequestHeaders(injectRequest(), originalHeaderValues); }); } // Auth state - order matters: isAuthenticatedResource and isAuthenticated must be defined first isAuthenticatedResource = httpResource(() => { return { url: '/api/auth/authenticated', method: 'GET', headers: this.resolveRequestHeaders({ accept: 'application/json', }), withCredentials: true, }; }, { ...(ngDevMode ? { debugName: "isAuthenticatedResource" } : /* istanbul ignore next */ {}), defaultValue: this.transferredSnapshot?.authenticated ?? false, parse: (value) => { return value.authenticated; } }); isAuthenticated = this.isAuthenticatedResource.asReadonly().value; isAuthenticationResolved = computed(() => { const status = this.isAuthenticatedResource.status(); return this.isSettledResourceStatus(status); }, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "isAuthenticationResolved" }] : /* istanbul ignore next */ [])); isAuthenticationLoading = computed(() => { const status = this.isAuthenticatedResource.status(); return !this.isSettledResourceStatus(status); }, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "isAuthenticationLoading" }] : /* istanbul ignore next */ [])); userResource = httpResource(() => { if (!this.isAuthenticated()) { return undefined; } return { url: '/api/auth/user', method: 'GET', headers: this.resolveRequestHeaders({ accept: 'application/json', }), withCredentials: true, }; }, { ...(ngDevMode ? { debugName: "userResource" } : /* istanbul ignore next */ {}), defaultValue: this.transferredSnapshot?.user ?? null, parse: (raw) => { return transformUserFromProvider(raw); } }); user = this.userResource.asReadonly().value; constructor() { if (isPlatformServer(this.platformId)) { effect(() => { if (!this.isAuthenticationResolved()) { return; } if (this.isAuthenticated() && !this.isSettledResourceStatus(this.userResource.status())) { return; } this.transferState.set(AUTH_TRANSFER_STATE_KEY, { authenticated: this.isAuthenticated(), user: this.userResource.value(), }); }, { injector: this.injector }); } if (isPlatformBrowser(this.platformId)) { queueMicrotask(() => { if (!this.hasRevalidatedBrowserAuth && this.isAuthenticationResolved() && !this.isAuthenticated()) { this.hasRevalidatedBrowserAuth = true; this.isAuthenticatedResource.reload(); } }); this.userReloadEffect = effect(() => { const isAuthenticated = this.isAuthenticated(); const user = this.userResource.value(); const status = this.userResource.status(); if (!isAuthenticated || user !== null) { this.clearUserReloadRetry(); return; } if (this.userReloadAttempts < MAX_USER_RELOAD_ATTEMPTS && this.isSettledResourceStatus(status)) { this.userReloadAttempts += 1; this.userResource.reload(); this.scheduleUserReloadRetry(); } }, { ...(ngDevMode ? { debugName: "userReloadEffect" } : /* istanbul ignore next */ {}), injector: this.injector }); // Set up periodic check for authentication status this.checkAuthInterval = setInterval(() => { this.isAuthenticatedResource.reload(); }, 5 * 60 * 1000); // Check every 5 minutes } } ngOnDestroy() { if (this.checkAuthInterval) { clearInterval(this.checkAuthInterval); } this.clearUserReloadRetry(); this.userReloadEffect?.destroy(); } clearUserReloadRetry() { this.userReloadAttempts = 0; if (this.userReloadTimeout) { clearTimeout(this.userReloadTimeout); this.userReloadTimeout = null; } } scheduleUserReloadRetry() { if (this.userReloadTimeout || this.userReloadAttempts >= MAX_USER_RELOAD_ATTEMPTS) { return; } this.userReloadTimeout = setTimeout(() => { this.userReloadTimeout = null; if (this.isAuthenticated() && this.userResource.value() === null) { this.userResource.reload(); this.scheduleUserReloadRetry(); } }, 1000); } waitForAuthentication() { if (!isPlatformBrowser(this.platformId) || this.isAuthenticationResolved()) { return Promise.resolve(this.isAuthenticated()); } return new Promise((resolve) => { const watcher = effect(() => { if (this.isAuthenticationResolved()) { resolve(this.isAuthenticated()); queueMicrotask(() => watcher.destroy()); } }, { ...(ngDevMode ? { debugName: "watcher" } : /* istanbul ignore next */ {}), injector: this.injector }); }); } /** * Login the user by redirecting to the login endpoint * @param targetUrl Optional URL to redirect to after login */ login(targetUrl) { if (isPlatformBrowser(this.platformId)) { const redirectUri = targetUrl || this.router.url; this.document.location.href = `/api/auth/login?redirect_uri=${encodeURIComponent(redirectUri)}`; } } /** * Logout the user by redirecting to the logout endpoint */ logout() { if (isPlatformBrowser(this.platformId)) { try { const logoutUrl = `/api/auth/logout?redirect_uri=${encodeURIComponent('/')}`; // Clear local state before redirect this.userResource.set(null); if (this.checkAuthInterval) { clearInterval(this.checkAuthInterval); } this.document.location.href = logoutUrl; } catch (error) { console.error('Logout failed:', error); // Implement fallback logout mechanism } } } /** * Check if user has the required roles * @param roles Array of roles to check */ hasRoles(roles) { const user = this.userResource.value(); if (!user || !user.roles) return false; return roles.some((role) => user.roles?.lastIndexOf(role) !== -1); } static ɵfac = function AuthService_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || AuthService)(); }; static ɵprov = /*@__PURE__*/ i0.ɵɵdefineInjectable({ token: AuthService, factory: AuthService.ɵfac, providedIn: 'root' }); } (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(AuthService, [{ type: Injectable, args: [{ providedIn: 'root' }] }], () => [], null); })(); function provideAuthClient() { return makeEnvironmentProviders([AuthService]); } /** * Auth guard that checks if the user is authenticated */ const authGuard = (route, state) => { const authService = inject(AuthService); const platformId = inject(PLATFORM_ID); if (isPlatformServer(platformId)) { return true; } return authService .waitForAuthentication() .then((isAuthenticated) => { if (isAuthenticated) { // User is authenticated, allow access return true; } // User is not authenticated, redirect to login authService.login(state.url); return false; }) .catch(() => false); }; /** * Role-based guard that checks if the user has the required roles */ const roleGuard = (route, state) => { const authService = inject(AuthService); const router = inject(Router); const platformId = inject(PLATFORM_ID); if (isPlatformServer(platformId)) { return true; } // Get required roles from route data const requiredRoles = route.data?.['roles']; if (!requiredRoles || requiredRoles.length === 0) { // No specific roles required return true; } return authService .waitForAuthentication() .then((isAuthenticated) => { if (!isAuthenticated) { authService.login(state.url); return false; } // Check if user has any of the required roles if (authService.hasRoles(requiredRoles)) { return true; } // User doesn't have required roles, redirect to access denied router.navigate(['/access-denied']); return false; }) .catch(() => false); }; function redirect(uri) { document.location.href = uri; } function login(redirectUri) { const url = document.location.origin + (redirectUri || ''); redirect(`/api/auth/login?redirect_uri=${encodeURIComponent(url)}`); } function mergeRequest(originalRequest, serverRequest) { let modifiedReq; if (serverRequest) { let headers = new HttpHeaders(); Object.entries(serverRequest.headers).forEach(([key, value]) => { if (value !== null && value !== undefined && typeof value === 'string') { headers = headers.set(key, value); } }); headers = headers.set('fetch', 'true'); modifiedReq = originalRequest.clone({ headers: headers, withCredentials: true, }); } else { modifiedReq = originalRequest.clone({ headers: originalRequest.headers.set('fetch', 'true'), withCredentials: true, }); } return modifiedReq; } /** * HTTP interceptor that: * 1. Adds a fetch=true header to indicate fresh data requests * 2. Redirects to login page when an API returns a 401 Unauthorized response * * This handles cases where a session has expired on the server-side. */ const authInterceptor = (req, next) => { const isAuthEndpoint = req.url.includes('/api/auth/'); const platformId = inject(PLATFORM_ID); // Clone the request and add the fetch=true header const request = injectRequest(); const modifiedReq = mergeRequest(req, request); // Auth endpoints still need the current SSR request context (cookies/headers), // but should not trigger the interceptor's 401 redirect handling. if (isAuthEndpoint) { return next(modifiedReq); } // Use the modified request with the added header return next(modifiedReq).pipe(catchError((error) => { // Only handle HttpErrorResponse with 401 status if (error instanceof HttpErrorResponse && error.status === 401) { if (isPlatformBrowser(platformId)) { const currentUrl = window.location.pathname + window.location.search; login(currentUrl); } // Return EMPTY to suppress the error — httpResource will use defaultValue return EMPTY; } // For other errors, rethrow throw error; })); }; /** * Provider for the auth interceptor */ const provideAuthInterceptor = () => ({ provide: 'HTTP_INTERCEPTORS', useValue: authInterceptor, multi: true, }); function proxyClient(client, errorHandler) { return new Proxy(client, { get(target, prop) { return new Proxy(target[prop], { get(target, prop) { return proxyProcedure(target[prop], errorHandler); }, }); }, }); } function proxyProcedure(procedure, errorHandler) { return new Proxy(procedure, { get(procedureTarget, procedureProp) { const procedureMethod = procedureTarget[procedureProp]; // Only intercept query and mutate methods if (procedureProp !== 'query' && procedureProp !== 'mutate') { return procedureMethod; } // Return a wrapped version of the method that catches errors return function (...args) { const method = procedureMethod; const result = method(...args); // If the result is an Observable (for Angular), add error handling if (result instanceof Observable) { return result.pipe(catchError((error) => { // Check if it's a TRPC client error with UNAUTHORIZED code if (error instanceof TRPCClientError) { const trpcError = error; const errorData = trpcError.data; if (errorHandler(errorData)) { // Handle the error and prevent it from propagating return new Observable((subscriber) => { subscriber.complete(); }); } } // Always rethrow the error for other error handlers return throwError(() => error); })); } return result; }; }, }); } function confirmDialog(msg) { return new Promise(function (resolve, reject) { try { if (!window?.confirm) { console.error("confirm is not available"); return reject(false); } const confirmed = confirm(msg); return confirmed ? resolve(true) : reject(false); } catch { return reject("Error showing confirmation dialog"); } }); } // eslint-disable-next-line @typescript-eslint/no-unused-vars function createDefaultConfirmation(_) { confirmDialog("Session expired. Do you want to refresh the page?").then(() => { window.location.href = '/'; // eslint-disable-next-line @typescript-eslint/no-empty-function }).catch(() => { }); return true; } /** * Wraps a TRPC client with error handling for auth errors * @param client The original TRPC client * @param errorHandler A function to handle errors. if returns true, the error is handled and catched * @returns A wrapped TRPC client with error handling */ function wrapTrpcClientWithErrorHandling(client, errorHandler) { // Create a proxy that intercepts all client calls return proxyClient(client, errorHandler ?? createDefaultConfirmation); } function createTrpcClientWithAuth(trpcClient, request, TrpcHeaders) { // Add request headers including cookies for auth TrpcHeaders.update((headers) => ({ ...headers, fetch: 'true', cookie: request?.headers.cookie, })); // Wrap the client to add error handling return wrapTrpcClientWithErrorHandling(trpcClient); } /** * Generated bundle index. Do not edit. */ export { AuthService, authGuard, authInterceptor, createTrpcClientWithAuth, provideAuthClient, provideAuthInterceptor, roleGuard, wrapTrpcClientWithErrorHandling }; //# sourceMappingURL=analog-tools-auth-angular.mjs.map