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