336 lines
12 KiB
TypeScript
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;
|
|
}
|
|
} |