@accounter/server
Version:
Accounter GraphQL server
221 lines (197 loc) • 7.21 kB
text/typescript
import { ManagementClient } from 'auth0';
import { Inject, Injectable, Scope } from 'graphql-modules';
import { ENVIRONMENT } from '../../../shared/tokens.js';
import type { Environment } from '../../../shared/types/index.js';
({
scope: Scope.Singleton,
global: true,
})
export class Auth0ManagementProvider {
private client: ManagementClient | undefined;
// Short-lived cache of Auth0 profile lookups. The provider is a Singleton, so
// this is shared across requests to avoid hammering the Auth0 Management API
// (and its rate limits) when listing users. The TTL bounds staleness so
// profile changes in Auth0 still propagate within a few minutes.
private profileCache = new Map<
string,
{ profile: { email: string | null; name: string | null }; expiresAt: number }
>();
private readonly PROFILE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
constructor((ENVIRONMENT) private env: Environment) {
this.initializeClient();
}
private initializeClient() {
if (this.env.auth0) {
this.client = new ManagementClient({
domain: this.env.auth0.domain,
clientId: this.env.auth0.clientId,
clientSecret: this.env.auth0.clientSecret,
audience: this.env.auth0.managementAudience,
});
} else {
console.warn('Auth0 not configured, Auth0ManagementProvider disabled');
}
}
private getClient(): ManagementClient {
if (!this.client) {
throw new Error('Auth0 client not initialized');
}
return this.client;
}
async getUserByEmail(email: string): Promise<string | null> {
const client = this.getClient();
try {
const users = await client.users.listUsersByEmail({ email });
if (users?.length > 0) {
return users[0].user_id ?? null;
}
return null;
} catch (error) {
console.error(`Failed to get Auth0 user by email ${email}:`, error);
throw new Error(`Failed to get Auth0 user by email: ${(error as Error).message}`, {
cause: error,
});
}
}
async getUserEmailById(
auth0UserId: string,
): Promise<{ email: string | null; emailVerified: boolean } | null> {
const client = this.getClient();
try {
const user = await client.users.get(auth0UserId);
return {
email: typeof user?.email === 'string' ? user.email : null,
emailVerified: user?.email_verified === true,
};
} catch (error) {
console.error(`Failed to get Auth0 user by id ${auth0UserId}:`, error);
return null;
}
}
/**
* Fetch the display identity (email + name) for an Auth0 user.
* Returns null when the lookup fails so callers can degrade gracefully
* instead of failing the whole request.
*/
async getUserProfileById(
auth0UserId: string,
): Promise<{ email: string | null; name: string | null } | null> {
const cached = this.profileCache.get(auth0UserId);
if (cached && cached.expiresAt > Date.now()) {
return cached.profile;
}
const client = this.getClient();
try {
const user = await client.users.get(auth0UserId);
const profile = {
email: typeof user?.email === 'string' ? user.email : null,
name: typeof user?.name === 'string' ? user.name : null,
};
// Only successful lookups are cached, so transient failures are retried.
this.profileCache.set(auth0UserId, {
profile,
expiresAt: Date.now() + this.PROFILE_CACHE_TTL_MS,
});
return profile;
} catch (error) {
console.error(`Failed to get Auth0 user profile by id ${auth0UserId}:`, error);
return null;
}
}
async createBlockedUser(email: string, password?: string): Promise<string> {
const client = this.getClient();
const existingUserId = await this.getUserByEmail(email);
if (existingUserId) {
return existingUserId;
}
const temporaryPassword = password || this.generateTemporaryPassword();
try {
const user = await client.users.create({
connection: 'Username-Password-Authentication',
email,
password: temporaryPassword,
email_verified: false,
blocked: true,
app_metadata: {
registered_by: 'accounter',
},
});
if (!user.user_id) {
throw new Error('Auth0 did not return a user ID');
}
return user.user_id;
} catch (error) {
console.error('Failed to create Auth0 user:', error);
throw new Error(`Failed to create Auth0 user: ${(error as Error).message}`, { cause: error });
}
}
async unblockUser(auth0UserId: string): Promise<void> {
const client = this.getClient();
try {
await client.users.update(auth0UserId, { blocked: false });
} catch (error) {
console.error(`Failed to unblock Auth0 user ${auth0UserId}:`, error);
throw new Error(`Failed to unblock Auth0 user: ${(error as Error).message}`, {
cause: error,
});
}
}
async blockUser(auth0UserId: string): Promise<void> {
const client = this.getClient();
try {
await client.users.update(auth0UserId, { blocked: true });
} catch (error) {
console.error(`Failed to block Auth0 user ${auth0UserId}:`, error);
throw new Error(`Failed to block Auth0 user: ${(error as Error).message}`, { cause: error });
}
}
async deleteUser(auth0UserId: string): Promise<void> {
const client = this.getClient();
try {
// Cleanup for expired invitations
await client.users.delete(auth0UserId);
} catch (error) {
console.error(`Failed to delete Auth0 user ${auth0UserId}:`, error);
throw new Error(`Failed to delete Auth0 user: ${(error as Error).message}`, { cause: error });
}
}
async sendPasswordResetEmail(auth0UserId: string): Promise<string> {
const client = this.getClient();
try {
const { ticket } = await client.tickets.changePassword({
user_id: auth0UserId,
mark_email_as_verified: true, // Mark verified only after password change
result_url: this.env.general.frontendUrl
? `${this.env.general.frontendUrl}/login`
: undefined,
});
return ticket;
} catch (error) {
console.error('Failed to trigger password change', error);
throw new Error(`Failed to trigger password change: ${(error as Error).message}`, {
cause: error,
});
}
}
private generateTemporaryPassword(): string {
// Generate a random password that meets strict Auth0 requirements:
// 8+ chars, lower, upper, number, special char
const lower = 'abcdefghijklmnopqrstuvwxyz';
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const numbers = '0123456789';
const special = '!@#$%^&*()_+~`|}{[]\\:;?><,./-=';
const all = lower + upper + numbers + special;
const pick = (str: string) => {
const randomValues = new Uint32Array(1);
globalThis.crypto.getRandomValues(randomValues);
return str[randomValues[0] % str.length];
};
const password =
pick(lower) +
pick(upper) +
pick(numbers) +
pick(special) +
Array.from({ length: 12 }, () => pick(all)).join('');
return password;
}
}