@analog-tools/auth
Version:
Authentication module for AnalogJS applications
473 lines (456 loc) • 17.2 kB
JavaScript
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