UNPKG

@analog-tools/auth

Version:

Authentication module for AnalogJS applications

473 lines (456 loc) 17.2 kB
import * as i0 from '@angular/core'; import { inject, PLATFORM_ID, DOCUMENT, effect, Injectable, makeEnvironmentProviders } from '@angular/core'; import { Router } from '@angular/router'; import { isPlatformBrowser } 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; } /** * 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); httpRequest = injectRequest(); checkAuthInterval = null; // Auth state - order matters: isAuthenticatedResource and isAuthenticated must be defined first isAuthenticatedResource = httpResource(() => ({ url: '/api/auth/authenticated', method: 'GET', headers: getRequestHeaders(this.httpRequest, { accept: 'application/json', }), withCredentials: true, }), { defaultValue: false, parse: (value) => { return value.authenticated; }, }); isAuthenticated = this.isAuthenticatedResource.asReadonly().value; userResource = httpResource(() => { if (this.isAuthenticated()) { console.log('userResource called'); return { url: '/api/auth/user', method: 'GET', headers: getRequestHeaders(this.httpRequest, { accept: 'application/json', }), withCredentials: true, }; } return; }, { defaultValue: null, parse: (raw) => { return transformUserFromProvider(raw); }, }); user = this.userResource.asReadonly().value; constructor() { // Check authentication status on startup this.isAuthenticatedResource.reload(); if (isPlatformBrowser(this.platformId)) { // Set up periodic check for authentication status this.checkAuthInterval = setInterval(() => { this.isAuthenticatedResource.reload(); }, 5 * 60 * 1000); // Check every 5 minutes } effect(() => { // Automatically fetch user profile when authenticated if (this.isAuthenticated()) { this.userResource.reload(); } }); } ngOnDestroy() { if (this.checkAuthInterval) { clearInterval(this.checkAuthInterval); } } /** * 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; const url = this.document.location.origin + redirectUri; this.document.location.href = `/api/auth/login?redirect_uri=${encodeURIComponent(url)}`; } } /** * 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 }); } (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(AuthService, [{ type: Injectable }], () => [], null); })(); function provideAuthClient() { return makeEnvironmentProviders([AuthService]); } /** * Auth guard that checks if the user is authenticated */ const authGuard = (route, state) => { const authService = inject(AuthService); if (authService.isAuthenticated()) { // User is authenticated, allow access return true; } else { // User is not authenticated, redirect to login authService.login(state.url); return 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); // Get required roles from route data const requiredRoles = route.data?.['roles']; if (!requiredRoles || requiredRoles.length === 0) { // No specific roles required return true; } if (!authService.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; }; 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) => { // Skip interception for auth endpoints to avoid circular issues if (req.url.includes('/api/auth/callback') || req.url.includes('/api/auth/login')) { return next(req); } // Clone the request and add the fetch=true header const request = injectRequest(); const modifiedReq = mergeRequest(req, request); // 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) { // Get current URL to redirect back after login const currentUrl = window.location.pathname + window.location.search; // Redirect to login page with the current URL as redirect target login(currentUrl); // Return empty observable to prevent the error from propagating 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