Files
gridpilot.gg/apps/api/src/domain/auth/AuthService.ts
2026-01-16 15:20:25 +01:00

336 lines
12 KiB
TypeScript

import { Inject } from '@nestjs/common';
import type { Logger } from '@core/shared/domain/Logger';
import {
ForgotPasswordUseCase,
type ForgotPasswordApplicationError,
type ForgotPasswordInput,
} from '@core/identity/application/use-cases/ForgotPasswordUseCase';
import {
LoginUseCase,
type LoginApplicationError,
type LoginInput,
} from '@core/identity/application/use-cases/LoginUseCase';
import { LogoutUseCase, type LogoutApplicationError } from '@core/identity/application/use-cases/LogoutUseCase';
import {
ResetPasswordUseCase,
type ResetPasswordApplicationError,
type ResetPasswordInput,
} from '@core/identity/application/use-cases/ResetPasswordUseCase';
import {
SignupSponsorUseCase,
type SignupSponsorApplicationError,
type SignupSponsorInput,
} from '@core/identity/application/use-cases/SignupSponsorUseCase';
import {
SignupUseCase,
type SignupApplicationError,
type SignupInput,
} from '@core/identity/application/use-cases/SignupUseCase';
import type { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
import {
AUTH_SESSION_OUTPUT_PORT_TOKEN,
COMMAND_RESULT_OUTPUT_PORT_TOKEN,
FORGOT_PASSWORD_OUTPUT_PORT_TOKEN,
FORGOT_PASSWORD_USE_CASE_TOKEN,
IDENTITY_SESSION_PORT_TOKEN,
LOGGER_TOKEN,
LOGIN_USE_CASE_TOKEN,
LOGOUT_USE_CASE_TOKEN,
RESET_PASSWORD_OUTPUT_PORT_TOKEN,
RESET_PASSWORD_USE_CASE_TOKEN,
SIGNUP_SPONSOR_USE_CASE_TOKEN,
SIGNUP_USE_CASE_TOKEN,
} from './AuthProviders';
import type { AuthSessionDTO } from './dtos/AuthDto';
import { LoginParamsDTO, SignupParamsDTO, SignupSponsorParamsDTO } from './dtos/AuthDto';
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
import { ForgotPasswordPresenter } from './presenters/ForgotPasswordPresenter';
import { ResetPasswordPresenter } from './presenters/ResetPasswordPresenter';
function mapApplicationErrorToMessage(error: { details?: { message?: string } } | undefined, fallback: string): string {
return error?.details?.message ?? fallback;
}
function inferDemoRoleFromEmail(email: string): AuthSessionDTO['user']['role'] | undefined {
const normalized = email.trim().toLowerCase();
if (normalized === 'demo.driver@example.com') return 'driver';
if (normalized === 'demo.sponsor@example.com') return 'sponsor';
if (normalized === 'demo.owner@example.com') return 'league-owner';
if (normalized === 'demo.steward@example.com') return 'league-steward';
if (normalized === 'demo.admin@example.com') return 'league-admin';
if (normalized === 'demo.systemowner@example.com') return 'system-owner';
if (normalized === 'demo.superadmin@example.com') return 'super-admin';
return undefined;
}
export class AuthService {
constructor(
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(IDENTITY_SESSION_PORT_TOKEN)
private readonly identitySessionPort: IdentitySessionPort,
@Inject(LOGIN_USE_CASE_TOKEN) private readonly loginUseCase: LoginUseCase,
@Inject(SIGNUP_USE_CASE_TOKEN) private readonly signupUseCase: SignupUseCase,
@Inject(SIGNUP_SPONSOR_USE_CASE_TOKEN) private readonly signupSponsorUseCase: SignupSponsorUseCase,
@Inject(LOGOUT_USE_CASE_TOKEN) private readonly logoutUseCase: LogoutUseCase,
@Inject(FORGOT_PASSWORD_USE_CASE_TOKEN) private readonly forgotPasswordUseCase: ForgotPasswordUseCase,
@Inject(RESET_PASSWORD_USE_CASE_TOKEN) private readonly resetPasswordUseCase: ResetPasswordUseCase,
// TODO presenters must not be injected
@Inject(AUTH_SESSION_OUTPUT_PORT_TOKEN)
private readonly authSessionPresenter: AuthSessionPresenter,
@Inject(COMMAND_RESULT_OUTPUT_PORT_TOKEN)
private readonly commandResultPresenter: CommandResultPresenter,
@Inject(FORGOT_PASSWORD_OUTPUT_PORT_TOKEN)
private readonly forgotPasswordPresenter: ForgotPasswordPresenter,
@Inject(RESET_PASSWORD_OUTPUT_PORT_TOKEN)
private readonly resetPasswordPresenter: ResetPasswordPresenter,
) {}
async getCurrentSession(): Promise<AuthSessionDTO | null> {
// TODO must call a use case
this.logger.debug('[AuthService] Attempting to get current session.');
const coreSession = await this.identitySessionPort.getCurrentSession();
if (!coreSession) return null;
const userRole = coreSession.user.role;
const role = userRole ? (userRole as AuthSessionDTO['user']['role']) : undefined;
return {
token: coreSession.token,
user: {
userId: coreSession.user.id,
email: coreSession.user.email ?? '',
displayName: coreSession.user.displayName,
...(role !== undefined ? { role } : {}),
},
};
}
async signupWithEmail(params: SignupParamsDTO): Promise<AuthSessionDTO> {
this.logger.debug(`[AuthService] Attempting signup for email: ${params.email}`);
const input: SignupInput = {
email: params.email,
password: params.password,
displayName: params.displayName,
};
const result = await this.signupUseCase.execute(input);
if (result.isErr()) {
const error = result.unwrapErr() as SignupApplicationError;
throw new Error(mapApplicationErrorToMessage(error, 'Signup failed'));
}
const signupResult = result.unwrap();
this.authSessionPresenter.present(signupResult);
const userDTO = this.authSessionPresenter.responseModel;
const inferredRole = inferDemoRoleFromEmail(userDTO.email);
const session = await this.identitySessionPort.createSession({
id: userDTO.userId,
displayName: userDTO.displayName,
email: userDTO.email,
...(inferredRole ? { role: inferredRole } : {}),
});
return {
token: session.token,
user: userDTO,
};
}
async signupSponsor(params: SignupSponsorParamsDTO): Promise<AuthSessionDTO> {
this.logger.debug(`[AuthService] Attempting sponsor signup for email: ${params.email}`);
const input: SignupSponsorInput = {
email: params.email,
password: params.password,
displayName: params.displayName,
companyName: params.companyName,
};
const result = await this.signupSponsorUseCase.execute(input);
if (result.isErr()) {
const error = result.unwrapErr() as SignupSponsorApplicationError;
throw new Error(mapApplicationErrorToMessage(error, 'Sponsor signup failed'));
}
const signupResult = result.unwrap();
this.authSessionPresenter.present(signupResult);
const userDTO = this.authSessionPresenter.responseModel;
const inferredRole = inferDemoRoleFromEmail(userDTO.email);
const session = await this.identitySessionPort.createSession({
id: userDTO.userId,
displayName: userDTO.displayName,
email: userDTO.email,
...(inferredRole ? { role: inferredRole } : {}),
});
return {
token: session.token,
user: userDTO,
};
}
async loginWithEmail(params: LoginParamsDTO): Promise<AuthSessionDTO> {
this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`);
const input: LoginInput = {
email: params.email,
password: params.password,
};
const result = await this.loginUseCase.execute(input);
if (result.isErr()) {
const error = result.unwrapErr() as LoginApplicationError;
throw new Error(mapApplicationErrorToMessage(error, 'Login failed'));
}
const loginResult = result.unwrap();
this.authSessionPresenter.present(loginResult);
const userDTO = this.authSessionPresenter.responseModel;
const sessionOptions = params.rememberMe !== undefined
? { rememberMe: params.rememberMe }
: undefined;
const inferredRole = inferDemoRoleFromEmail(userDTO.email);
const session = await this.identitySessionPort.createSession(
{
id: userDTO.userId,
displayName: userDTO.displayName,
email: userDTO.email,
...(inferredRole ? { role: inferredRole } : {}),
},
sessionOptions
);
return {
token: session.token,
user: userDTO,
};
}
async logout(): Promise<CommandResultDTO> {
this.logger.debug('[AuthService] Attempting logout.');
const result = await this.logoutUseCase.execute();
if (result.isErr()) {
const error = result.unwrapErr() as LogoutApplicationError;
throw new Error(mapApplicationErrorToMessage(error, 'Logout failed'));
}
const logoutResult = result.unwrap();
this.commandResultPresenter.present(logoutResult);
return this.commandResultPresenter.responseModel;
}
/**
* Start iRacing OAuth flow.
*
* NOTE: This is a placeholder implementation for the current alpha build.
* A production implementation should delegate to a dedicated iRacing OAuth port
* and persist/validate state server-side.
*/
async startIracingAuth(returnTo?: string): Promise<string> {
this.logger.debug('[AuthService] Starting iRacing auth flow', { returnTo });
const state = Math.random().toString(36).slice(2);
const base = 'https://example.com/iracing/auth';
const query = new URLSearchParams();
query.set('state', state);
if (returnTo) {
query.set('returnTo', returnTo);
}
return `${base}?${query.toString()}`;
}
/**
* Handle iRacing OAuth callback.
*
* NOTE: Placeholder implementation that creates a demo session.
*/
async iracingCallback(code: string, state: string, returnTo?: string): Promise<AuthSessionDTO> {
this.logger.debug('[AuthService] iRacing callback received', { hasCode: !!code, state, returnTo });
const userId = `iracing-${state || code}`.slice(0, 64);
const session = await this.identitySessionPort.createSession({
id: userId,
displayName: 'iRacing User',
email: '',
});
return {
token: session.token,
user: {
userId,
email: '',
displayName: 'iRacing User',
},
};
}
async forgotPassword(params: { email: string }): Promise<{ message: string; magicLink?: string }> {
this.logger.debug(`[AuthService] Attempting forgot password for email: ${params.email}`);
const input: ForgotPasswordInput = {
email: params.email,
};
const executeResult = await this.forgotPasswordUseCase.execute(input);
if (executeResult.isErr()) {
const error = executeResult.unwrapErr() as ForgotPasswordApplicationError;
throw new Error(mapApplicationErrorToMessage(error, 'Forgot password failed'));
}
const forgotPasswordResult = executeResult.unwrap();
this.forgotPasswordPresenter.present(forgotPasswordResult);
const response = this.forgotPasswordPresenter.responseModel;
const result: { message: string; magicLink?: string } = {
message: response.message,
};
if (response.magicLink) {
result.magicLink = response.magicLink;
}
return result;
}
async resetPassword(params: { token: string; newPassword: string }): Promise<{ message: string }> {
this.logger.debug('[AuthService] Attempting reset password');
const input: ResetPasswordInput = {
token: params.token,
newPassword: params.newPassword,
};
const result = await this.resetPasswordUseCase.execute(input);
if (result.isErr()) {
const error = result.unwrapErr() as ResetPasswordApplicationError;
throw new Error(mapApplicationErrorToMessage(error, 'Reset password failed'));
}
const resetResult = result.unwrap();
this.resetPasswordPresenter.present(resetResult);
return this.resetPasswordPresenter.responseModel;
}
}