569 lines
16 KiB
TypeScript
569 lines
16 KiB
TypeScript
|
|
/**
|
||
|
|
* External Auth Service for Stack.Gallery Registry
|
||
|
|
* Orchestrates OAuth/OIDC and LDAP authentication flows
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { User, Session, AuthProvider, ExternalIdentity, PlatformSettings } from '../models/index.ts';
|
||
|
|
import { AuthService, type IAuthResult } from './auth.service.ts';
|
||
|
|
import { AuditService } from './audit.service.ts';
|
||
|
|
import { cryptoService } from './crypto.service.ts';
|
||
|
|
import { AuthStrategyFactory, type IOAuthCallbackData } from './auth/strategies/index.ts';
|
||
|
|
import type { IExternalUserInfo, IConnectionTestResult } from '../interfaces/auth.interfaces.ts';
|
||
|
|
|
||
|
|
export interface IOAuthState {
|
||
|
|
providerId: string;
|
||
|
|
returnUrl?: string;
|
||
|
|
nonce: string;
|
||
|
|
exp: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export class ExternalAuthService {
|
||
|
|
private strategyFactory: AuthStrategyFactory;
|
||
|
|
private authService: AuthService;
|
||
|
|
private auditService: AuditService;
|
||
|
|
|
||
|
|
constructor() {
|
||
|
|
this.strategyFactory = new AuthStrategyFactory(cryptoService);
|
||
|
|
this.authService = new AuthService();
|
||
|
|
this.auditService = new AuditService({ actorType: 'system' });
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Initiate OAuth flow - returns authorization URL and state
|
||
|
|
*/
|
||
|
|
public async initiateOAuth(
|
||
|
|
providerId: string,
|
||
|
|
returnUrl?: string
|
||
|
|
): Promise<{ authUrl: string; state: string }> {
|
||
|
|
const provider = await AuthProvider.findById(providerId);
|
||
|
|
if (!provider) {
|
||
|
|
throw new Error('Provider not found');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (provider.status !== 'active') {
|
||
|
|
throw new Error('Provider is not active');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (provider.type !== 'oidc') {
|
||
|
|
throw new Error('Provider is not an OAuth/OIDC provider');
|
||
|
|
}
|
||
|
|
|
||
|
|
const strategy = this.strategyFactory.create(provider);
|
||
|
|
if (!strategy.getAuthorizationUrl) {
|
||
|
|
throw new Error('Provider does not support OAuth flow');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate state with encoded data
|
||
|
|
const state = await this.generateState(providerId, returnUrl);
|
||
|
|
const nonce = crypto.randomUUID();
|
||
|
|
|
||
|
|
const authUrl = await strategy.getAuthorizationUrl(state, nonce);
|
||
|
|
|
||
|
|
return { authUrl, state };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle OAuth callback - exchange code for user and create session
|
||
|
|
*/
|
||
|
|
public async handleOAuthCallback(
|
||
|
|
data: IOAuthCallbackData,
|
||
|
|
options: { ipAddress?: string; userAgent?: string } = {}
|
||
|
|
): Promise<IAuthResult> {
|
||
|
|
// Validate state
|
||
|
|
const stateData = await this.validateState(data.state);
|
||
|
|
if (!stateData) {
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
errorCode: 'INVALID_STATE',
|
||
|
|
errorMessage: 'Invalid or expired state',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get provider
|
||
|
|
const provider = await AuthProvider.findById(stateData.providerId);
|
||
|
|
if (!provider || provider.status !== 'active') {
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
errorCode: 'PROVIDER_INACTIVE',
|
||
|
|
errorMessage: 'Provider not found or inactive',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle OAuth callback
|
||
|
|
const strategy = this.strategyFactory.create(provider);
|
||
|
|
if (!strategy.handleCallback) {
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
errorCode: 'INVALID_PROVIDER',
|
||
|
|
errorMessage: 'Provider does not support OAuth callback',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
let externalUser: IExternalUserInfo;
|
||
|
|
try {
|
||
|
|
externalUser = await strategy.handleCallback(data);
|
||
|
|
} catch (error) {
|
||
|
|
await this.auditService.log('USER_LOGIN', 'user', {
|
||
|
|
success: false,
|
||
|
|
metadata: {
|
||
|
|
providerId: provider.id,
|
||
|
|
providerName: provider.name,
|
||
|
|
error: error instanceof Error ? error.message : String(error),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
errorCode: 'PROVIDER_ERROR',
|
||
|
|
errorMessage: error instanceof Error ? error.message : 'Authentication failed',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Find or create user
|
||
|
|
const { user, isNew } = await this.findOrCreateUser(provider, externalUser, options);
|
||
|
|
|
||
|
|
// Create session
|
||
|
|
const session = await Session.createSession({
|
||
|
|
userId: user.id,
|
||
|
|
userAgent: options.userAgent || '',
|
||
|
|
ipAddress: options.ipAddress || '',
|
||
|
|
});
|
||
|
|
|
||
|
|
// Generate tokens using the existing AuthService approach
|
||
|
|
const accessToken = await this.generateAccessToken(user, session.id);
|
||
|
|
const refreshToken = await this.generateRefreshToken(user, session.id);
|
||
|
|
|
||
|
|
// Update user last login
|
||
|
|
user.lastLoginAt = new Date();
|
||
|
|
await user.save();
|
||
|
|
|
||
|
|
// Audit log
|
||
|
|
await AuditService.withContext({
|
||
|
|
actorId: user.id,
|
||
|
|
actorType: 'user',
|
||
|
|
actorIp: options.ipAddress,
|
||
|
|
actorUserAgent: options.userAgent,
|
||
|
|
}).log('USER_LOGIN', 'user', {
|
||
|
|
resourceId: user.id,
|
||
|
|
success: true,
|
||
|
|
metadata: {
|
||
|
|
providerId: provider.id,
|
||
|
|
providerName: provider.name,
|
||
|
|
isNewUser: isNew,
|
||
|
|
authMethod: 'oauth',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
user,
|
||
|
|
accessToken,
|
||
|
|
refreshToken,
|
||
|
|
sessionId: session.id,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Authenticate with LDAP credentials
|
||
|
|
*/
|
||
|
|
public async authenticateLdap(
|
||
|
|
providerId: string,
|
||
|
|
username: string,
|
||
|
|
password: string,
|
||
|
|
options: { ipAddress?: string; userAgent?: string } = {}
|
||
|
|
): Promise<IAuthResult> {
|
||
|
|
const provider = await AuthProvider.findById(providerId);
|
||
|
|
if (!provider || provider.status !== 'active' || provider.type !== 'ldap') {
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
errorCode: 'INVALID_PROVIDER',
|
||
|
|
errorMessage: 'Invalid LDAP provider',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const strategy = this.strategyFactory.create(provider);
|
||
|
|
if (!strategy.authenticateCredentials) {
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
errorCode: 'INVALID_PROVIDER',
|
||
|
|
errorMessage: 'Provider does not support credential authentication',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
let externalUser: IExternalUserInfo;
|
||
|
|
try {
|
||
|
|
externalUser = await strategy.authenticateCredentials(username, password);
|
||
|
|
} catch (error) {
|
||
|
|
await this.auditService.log('USER_LOGIN', 'user', {
|
||
|
|
success: false,
|
||
|
|
metadata: {
|
||
|
|
providerId: provider.id,
|
||
|
|
providerName: provider.name,
|
||
|
|
username,
|
||
|
|
error: error instanceof Error ? error.message : String(error),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
errorCode: 'AUTH_FAILED',
|
||
|
|
errorMessage: 'Invalid credentials',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Find or create user
|
||
|
|
const { user, isNew } = await this.findOrCreateUser(provider, externalUser, options);
|
||
|
|
|
||
|
|
// Create session
|
||
|
|
const session = await Session.createSession({
|
||
|
|
userId: user.id,
|
||
|
|
userAgent: options.userAgent || '',
|
||
|
|
ipAddress: options.ipAddress || '',
|
||
|
|
});
|
||
|
|
|
||
|
|
// Generate tokens
|
||
|
|
const accessToken = await this.generateAccessToken(user, session.id);
|
||
|
|
const refreshToken = await this.generateRefreshToken(user, session.id);
|
||
|
|
|
||
|
|
// Update user last login
|
||
|
|
user.lastLoginAt = new Date();
|
||
|
|
await user.save();
|
||
|
|
|
||
|
|
// Audit log
|
||
|
|
await AuditService.withContext({
|
||
|
|
actorId: user.id,
|
||
|
|
actorType: 'user',
|
||
|
|
actorIp: options.ipAddress,
|
||
|
|
actorUserAgent: options.userAgent,
|
||
|
|
}).log('USER_LOGIN', 'user', {
|
||
|
|
resourceId: user.id,
|
||
|
|
success: true,
|
||
|
|
metadata: {
|
||
|
|
providerId: provider.id,
|
||
|
|
providerName: provider.name,
|
||
|
|
isNewUser: isNew,
|
||
|
|
authMethod: 'ldap',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
user,
|
||
|
|
accessToken,
|
||
|
|
refreshToken,
|
||
|
|
sessionId: session.id,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Link an external provider to an existing user
|
||
|
|
*/
|
||
|
|
public async linkProvider(
|
||
|
|
userId: string,
|
||
|
|
providerId: string,
|
||
|
|
externalUser: IExternalUserInfo
|
||
|
|
): Promise<ExternalIdentity> {
|
||
|
|
// Check if this external ID is already linked to another user
|
||
|
|
const existing = await ExternalIdentity.findByExternalId(providerId, externalUser.externalId);
|
||
|
|
if (existing) {
|
||
|
|
if (existing.userId === userId) {
|
||
|
|
// Already linked to this user, just update
|
||
|
|
await existing.updateAttributes({
|
||
|
|
externalEmail: externalUser.email,
|
||
|
|
externalUsername: externalUser.username,
|
||
|
|
rawAttributes: externalUser.rawAttributes,
|
||
|
|
});
|
||
|
|
return existing;
|
||
|
|
}
|
||
|
|
throw new Error('This external account is already linked to another user');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create new identity link
|
||
|
|
const identity = await ExternalIdentity.createIdentity({
|
||
|
|
userId,
|
||
|
|
providerId,
|
||
|
|
externalId: externalUser.externalId,
|
||
|
|
externalEmail: externalUser.email,
|
||
|
|
externalUsername: externalUser.username,
|
||
|
|
rawAttributes: externalUser.rawAttributes,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Update user's external identity IDs
|
||
|
|
const user = await User.findById(userId);
|
||
|
|
if (user) {
|
||
|
|
user.externalIdentityIds = [...(user.externalIdentityIds || []), identity.id];
|
||
|
|
await user.save();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Audit log
|
||
|
|
await this.auditService.log('USER_UPDATED', 'user', {
|
||
|
|
resourceId: userId,
|
||
|
|
success: true,
|
||
|
|
metadata: {
|
||
|
|
action: 'link_provider',
|
||
|
|
providerId,
|
||
|
|
externalId: externalUser.externalId,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
return identity;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Unlink an external provider from a user
|
||
|
|
*/
|
||
|
|
public async unlinkProvider(userId: string, providerId: string): Promise<boolean> {
|
||
|
|
const identity = await ExternalIdentity.findByUserAndProvider(userId, providerId);
|
||
|
|
if (!identity) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Ensure user still has another auth method
|
||
|
|
const user = await User.findById(userId);
|
||
|
|
if (!user) return false;
|
||
|
|
|
||
|
|
const otherIdentities = await ExternalIdentity.findByUserId(userId);
|
||
|
|
const hasLocalAuth = user.canUseLocalAuth && user.passwordHash;
|
||
|
|
|
||
|
|
if (otherIdentities.length <= 1 && !hasLocalAuth) {
|
||
|
|
throw new Error('Cannot unlink last authentication method');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Remove identity
|
||
|
|
await identity.delete();
|
||
|
|
|
||
|
|
// Update user's external identity IDs
|
||
|
|
user.externalIdentityIds = user.externalIdentityIds.filter((id) => id !== identity.id);
|
||
|
|
await user.save();
|
||
|
|
|
||
|
|
// Audit log
|
||
|
|
await this.auditService.log('USER_UPDATED', 'user', {
|
||
|
|
resourceId: userId,
|
||
|
|
success: true,
|
||
|
|
metadata: {
|
||
|
|
action: 'unlink_provider',
|
||
|
|
providerId,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Test provider connection
|
||
|
|
*/
|
||
|
|
public async testConnection(providerId: string): Promise<IConnectionTestResult> {
|
||
|
|
const provider = await AuthProvider.findById(providerId);
|
||
|
|
if (!provider) {
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
latencyMs: 0,
|
||
|
|
error: 'Provider not found',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const strategy = this.strategyFactory.create(provider);
|
||
|
|
const result = await strategy.testConnection();
|
||
|
|
|
||
|
|
// Update provider test status
|
||
|
|
await provider.updateTestResult(result.success, result.error);
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find or create user from external authentication
|
||
|
|
*/
|
||
|
|
private async findOrCreateUser(
|
||
|
|
provider: AuthProvider,
|
||
|
|
externalUser: IExternalUserInfo,
|
||
|
|
options: { ipAddress?: string } = {}
|
||
|
|
): Promise<{ user: User; isNew: boolean }> {
|
||
|
|
// 1. Check if external identity already exists
|
||
|
|
const existingIdentity = await ExternalIdentity.findByExternalId(
|
||
|
|
provider.id,
|
||
|
|
externalUser.externalId
|
||
|
|
);
|
||
|
|
|
||
|
|
if (existingIdentity) {
|
||
|
|
const user = await User.findById(existingIdentity.userId);
|
||
|
|
if (user) {
|
||
|
|
// Update identity with latest info
|
||
|
|
await existingIdentity.updateAttributes({
|
||
|
|
externalEmail: externalUser.email,
|
||
|
|
externalUsername: externalUser.username,
|
||
|
|
rawAttributes: externalUser.rawAttributes,
|
||
|
|
});
|
||
|
|
return { user, isNew: false };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. Try to link by email if enabled
|
||
|
|
if (provider.provisioning.autoLinkByEmail && externalUser.email) {
|
||
|
|
const existingUser = await User.findByEmail(externalUser.email);
|
||
|
|
if (existingUser) {
|
||
|
|
await this.linkProvider(existingUser.id, provider.id, externalUser);
|
||
|
|
return { user: existingUser, isNew: false };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. Create new user if JIT is enabled
|
||
|
|
if (!provider.provisioning.jitEnabled) {
|
||
|
|
throw new Error('User not found and JIT provisioning is disabled');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check domain restrictions
|
||
|
|
if (provider.provisioning.allowedEmailDomains?.length) {
|
||
|
|
const domain = externalUser.email.split('@')[1];
|
||
|
|
if (!provider.provisioning.allowedEmailDomains.includes(domain)) {
|
||
|
|
throw new Error(`Email domain ${domain} is not allowed`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate unique username
|
||
|
|
let username = externalUser.username || externalUser.email.split('@')[0];
|
||
|
|
username = username.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||
|
|
|
||
|
|
// Ensure username is unique
|
||
|
|
let counter = 0;
|
||
|
|
let finalUsername = username;
|
||
|
|
while (await User.findByUsername(finalUsername)) {
|
||
|
|
counter++;
|
||
|
|
finalUsername = `${username}${counter}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create user
|
||
|
|
const user = new User();
|
||
|
|
user.id = await User.getNewId();
|
||
|
|
user.email = externalUser.email.toLowerCase();
|
||
|
|
user.username = finalUsername;
|
||
|
|
user.displayName = externalUser.displayName || finalUsername;
|
||
|
|
user.avatarUrl = externalUser.avatarUrl;
|
||
|
|
user.status = 'active';
|
||
|
|
user.emailVerified = true; // Trust the provider
|
||
|
|
user.canUseLocalAuth = false; // No password set
|
||
|
|
user.provisionedByProviderId = provider.id;
|
||
|
|
user.passwordHash = ''; // No local password
|
||
|
|
user.createdAt = new Date();
|
||
|
|
user.updatedAt = new Date();
|
||
|
|
await user.save();
|
||
|
|
|
||
|
|
// Link external identity
|
||
|
|
await this.linkProvider(user.id, provider.id, externalUser);
|
||
|
|
|
||
|
|
return { user, isNew: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate OAuth state token
|
||
|
|
*/
|
||
|
|
private async generateState(providerId: string, returnUrl?: string): Promise<string> {
|
||
|
|
const stateData: IOAuthState = {
|
||
|
|
providerId,
|
||
|
|
returnUrl,
|
||
|
|
nonce: crypto.randomUUID(),
|
||
|
|
exp: Date.now() + 10 * 60 * 1000, // 10 minutes
|
||
|
|
};
|
||
|
|
|
||
|
|
// Encode as base64
|
||
|
|
return btoa(JSON.stringify(stateData));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate OAuth state token
|
||
|
|
*/
|
||
|
|
private async validateState(state: string): Promise<IOAuthState | null> {
|
||
|
|
try {
|
||
|
|
const stateData: IOAuthState = JSON.parse(atob(state));
|
||
|
|
|
||
|
|
// Check expiration
|
||
|
|
if (stateData.exp < Date.now()) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return stateData;
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate access token (mirrors AuthService logic)
|
||
|
|
*/
|
||
|
|
private async generateAccessToken(user: User, sessionId: string): Promise<string> {
|
||
|
|
const jwtSecret = Deno.env.get('JWT_SECRET') || 'change-me-in-production';
|
||
|
|
const now = Math.floor(Date.now() / 1000);
|
||
|
|
const expiresIn = 15 * 60; // 15 minutes
|
||
|
|
|
||
|
|
const payload = {
|
||
|
|
sub: user.id,
|
||
|
|
email: user.email,
|
||
|
|
sessionId,
|
||
|
|
type: 'access',
|
||
|
|
iat: now,
|
||
|
|
exp: now + expiresIn,
|
||
|
|
};
|
||
|
|
|
||
|
|
return await this.signJwt(payload, jwtSecret);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate refresh token (mirrors AuthService logic)
|
||
|
|
*/
|
||
|
|
private async generateRefreshToken(user: User, sessionId: string): Promise<string> {
|
||
|
|
const jwtSecret = Deno.env.get('JWT_SECRET') || 'change-me-in-production';
|
||
|
|
const now = Math.floor(Date.now() / 1000);
|
||
|
|
const expiresIn = 7 * 24 * 60 * 60; // 7 days
|
||
|
|
|
||
|
|
const payload = {
|
||
|
|
sub: user.id,
|
||
|
|
email: user.email,
|
||
|
|
sessionId,
|
||
|
|
type: 'refresh',
|
||
|
|
iat: now,
|
||
|
|
exp: now + expiresIn,
|
||
|
|
};
|
||
|
|
|
||
|
|
return await this.signJwt(payload, jwtSecret);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sign JWT token
|
||
|
|
*/
|
||
|
|
private async signJwt(payload: Record<string, unknown>, secret: string): Promise<string> {
|
||
|
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||
|
|
|
||
|
|
const encodedHeader = this.base64UrlEncode(JSON.stringify(header));
|
||
|
|
const encodedPayload = this.base64UrlEncode(JSON.stringify(payload));
|
||
|
|
|
||
|
|
const data = `${encodedHeader}.${encodedPayload}`;
|
||
|
|
|
||
|
|
const encoder = new TextEncoder();
|
||
|
|
const key = await crypto.subtle.importKey(
|
||
|
|
'raw',
|
||
|
|
encoder.encode(secret),
|
||
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||
|
|
false,
|
||
|
|
['sign']
|
||
|
|
);
|
||
|
|
|
||
|
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||
|
|
const encodedSignature = this.base64UrlEncode(
|
||
|
|
String.fromCharCode(...new Uint8Array(signature))
|
||
|
|
);
|
||
|
|
|
||
|
|
return `${data}.${encodedSignature}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Base64 URL encode
|
||
|
|
*/
|
||
|
|
private base64UrlEncode(str: string): string {
|
||
|
|
const base64 = btoa(str);
|
||
|
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Singleton instance
|
||
|
|
export const externalAuthService = new ExternalAuthService();
|