This commit is contained in:
2025-12-04 15:49:57 +01:00
parent c698a0b893
commit 60a3c82cd9
26 changed files with 399 additions and 98 deletions

View File

@@ -0,0 +1,8 @@
import type { AuthProviderDTO } from './AuthProviderDTO';
export interface AuthCallbackCommandDTO {
provider: AuthProviderDTO;
code: string;
state: string;
returnTo?: string;
}

View File

@@ -0,0 +1 @@
export type AuthProviderDTO = 'IRACING_DEMO';

View File

@@ -0,0 +1,8 @@
import type { AuthenticatedUserDTO } from './AuthenticatedUserDTO';
export interface AuthSessionDTO {
user: AuthenticatedUserDTO;
issuedAt: number;
expiresAt: number;
token: string;
}

View File

@@ -0,0 +1,8 @@
export interface AuthenticatedUserDTO {
id: string;
displayName: string;
email?: string;
iracingCustomerId?: string;
primaryDriverId?: string;
avatarUrl?: string;
}

View File

@@ -0,0 +1,4 @@
export interface IracingAuthStateDTO {
state: string;
returnTo?: string;
}

View File

@@ -0,0 +1,6 @@
import type { AuthProviderDTO } from './AuthProviderDTO';
export interface StartAuthCommandDTO {
provider: AuthProviderDTO;
returnTo?: string;
}

View File

@@ -0,0 +1,8 @@
import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO';
import type { AuthCallbackCommandDTO } from '../dto/AuthCallbackCommandDTO';
import type { StartAuthCommandDTO } from '../dto/StartAuthCommandDTO';
export interface IdentityProviderPort {
startAuth(command: StartAuthCommandDTO): Promise<{ redirectUrl: string; state: string }>;
completeAuth(command: AuthCallbackCommandDTO): Promise<AuthenticatedUserDTO>;
}

View File

@@ -0,0 +1,8 @@
import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO';
import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
export interface IdentitySessionPort {
getCurrentSession(): Promise<AuthSessionDTO | null>;
createSession(user: AuthenticatedUserDTO): Promise<AuthSessionDTO>;
clearSession(): Promise<void>;
}

View File

@@ -0,0 +1,14 @@
import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
export class GetCurrentUserSessionUseCase {
private readonly sessionPort: IdentitySessionPort;
constructor(sessionPort: IdentitySessionPort) {
this.sessionPort = sessionPort;
}
async execute(): Promise<AuthSessionDTO | null> {
return this.sessionPort.getCurrentSession();
}
}

View File

@@ -0,0 +1,21 @@
import type { AuthCallbackCommandDTO } from '../dto/AuthCallbackCommandDTO';
import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO';
import type { IdentityProviderPort } from '../ports/IdentityProviderPort';
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
export class HandleAuthCallbackUseCase {
private readonly provider: IdentityProviderPort;
private readonly sessionPort: IdentitySessionPort;
constructor(provider: IdentityProviderPort, sessionPort: IdentitySessionPort) {
this.provider = provider;
this.sessionPort = sessionPort;
}
async execute(command: AuthCallbackCommandDTO): Promise<AuthSessionDTO> {
const user: AuthenticatedUserDTO = await this.provider.completeAuth(command);
const session = await this.sessionPort.createSession(user);
return session;
}
}

View File

@@ -0,0 +1,13 @@
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
export class LogoutUseCase {
private readonly sessionPort: IdentitySessionPort;
constructor(sessionPort: IdentitySessionPort) {
this.sessionPort = sessionPort;
}
async execute(): Promise<void> {
await this.sessionPort.clearSession();
}
}

View File

@@ -0,0 +1,14 @@
import type { StartAuthCommandDTO } from '../dto/StartAuthCommandDTO';
import type { IdentityProviderPort } from '../ports/IdentityProviderPort';
export class StartAuthUseCase {
private readonly provider: IdentityProviderPort;
constructor(provider: IdentityProviderPort) {
this.provider = provider;
}
async execute(command: StartAuthCommandDTO): Promise<{ redirectUrl: string; state: string }> {
return this.provider.startAuth(command);
}
}

View File

@@ -0,0 +1,73 @@
import type { EmailValidationResult } from '../value-objects/EmailAddress';
import { validateEmail } from '../value-objects/EmailAddress';
import { UserId } from '../value-objects/UserId';
export interface UserProps {
id: UserId;
displayName: string;
email?: string;
iracingCustomerId?: string;
primaryDriverId?: string;
avatarUrl?: string;
}
export class User {
private readonly id: UserId;
private displayName: string;
private email?: string;
private iracingCustomerId?: string;
private primaryDriverId?: string;
private avatarUrl?: string;
private constructor(props: UserProps) {
if (!props.displayName || !props.displayName.trim()) {
throw new Error('User displayName cannot be empty');
}
this.id = props.id;
this.displayName = props.displayName.trim();
this.email = props.email;
this.iracingCustomerId = props.iracingCustomerId;
this.primaryDriverId = props.primaryDriverId;
this.avatarUrl = props.avatarUrl;
}
public static create(props: UserProps): User {
if (props.email) {
const result: EmailValidationResult = validateEmail(props.email);
if (!result.success) {
throw new Error(result.error);
}
return new User({
...props,
email: result.email,
});
}
return new User(props);
}
public getId(): UserId {
return this.id;
}
public getDisplayName(): string {
return this.displayName;
}
public getEmail(): string | undefined {
return this.email;
}
public getIracingCustomerId(): string | undefined {
return this.iracingCustomerId;
}
public getPrimaryDriverId(): string | undefined {
return this.primaryDriverId;
}
public getAvatarUrl(): string | undefined {
return this.avatarUrl;
}
}

View File

@@ -0,0 +1,22 @@
export class UserId {
private readonly value: string;
private constructor(value: string) {
if (!value || !value.trim()) {
throw new Error('UserId cannot be empty');
}
this.value = value;
}
public static fromString(value: string): UserId {
return new UserId(value);
}
public toString(): string {
return this.value;
}
public equals(other: UserId): boolean {
return this.value === other.value;
}
}

View File

@@ -1 +1,15 @@
export * from './domain/value-objects/EmailAddress';
export * from './domain/value-objects/EmailAddress';
export * from './domain/value-objects/UserId';
export * from './domain/entities/User';
export * from './application/dto/AuthenticatedUserDTO';
export * from './application/dto/AuthSessionDTO';
export * from './application/dto/AuthCallbackCommandDTO';
export * from './application/dto/StartAuthCommandDTO';
export * from './application/dto/AuthProviderDTO';
export * from './application/dto/IracingAuthStateDTO';
export * from './application/use-cases/StartAuthUseCase';
export * from './application/use-cases/HandleAuthCallbackUseCase';
export * from './application/use-cases/GetCurrentUserSessionUseCase';
export * from './application/use-cases/LogoutUseCase';

View File

@@ -0,0 +1,50 @@
import { randomUUID } from 'crypto';
import { createStaticRacingSeed } from '../../../testing-support';
import type { IdentityProviderPort } from '../../application/ports/IdentityProviderPort';
import type { StartAuthCommandDTO } from '../../application/dto/StartAuthCommandDTO';
import type { AuthCallbackCommandDTO } from '../../application/dto/AuthCallbackCommandDTO';
import type { AuthenticatedUserDTO } from '../../application/dto/AuthenticatedUserDTO';
export class IracingDemoIdentityProviderAdapter implements IdentityProviderPort {
private readonly seedDriverId: string;
constructor() {
const seed = createStaticRacingSeed(42);
this.seedDriverId = seed.drivers[0]?.id ?? 'driver-1';
}
async startAuth(command: StartAuthCommandDTO): Promise<{ redirectUrl: string; state: string }> {
const state = randomUUID();
const params = new URLSearchParams();
params.set('code', 'dummy-code');
params.set('state', state);
if (command.returnTo) {
params.set('returnTo', command.returnTo);
}
return {
redirectUrl: `/auth/iracing/callback?${params.toString()}`,
state,
};
}
async completeAuth(command: AuthCallbackCommandDTO): Promise<AuthenticatedUserDTO> {
if (!command.code) {
throw new Error('Missing auth code');
}
if (!command.state) {
throw new Error('Missing auth state');
}
const user: AuthenticatedUserDTO = {
id: 'demo-user',
displayName: 'GridPilot Demo Driver',
iracingCustomerId: '000000',
primaryDriverId: this.seedDriverId,
avatarUrl: `/api/avatar/${this.seedDriverId}`,
};
return user;
}
}

View File

@@ -0,0 +1,59 @@
import { cookies } from 'next/headers';
import { randomUUID } from 'crypto';
import type { AuthenticatedUserDTO } from '../../application/dto/AuthenticatedUserDTO';
import type { AuthSessionDTO } from '../../application/dto/AuthSessionDTO';
import type { IdentitySessionPort } from '../../application/ports/IdentitySessionPort';
const SESSION_COOKIE = 'gp_demo_session';
function parseCookieValue(raw: string | undefined): AuthSessionDTO | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as AuthSessionDTO;
if (!parsed.expiresAt || Date.now() > parsed.expiresAt) {
return null;
}
return parsed;
} catch {
return null;
}
}
function serializeSession(session: AuthSessionDTO): string {
return JSON.stringify(session);
}
export class CookieIdentitySessionAdapter implements IdentitySessionPort {
async getCurrentSession(): Promise<AuthSessionDTO | null> {
const store = await cookies();
const raw = store.get(SESSION_COOKIE)?.value;
return parseCookieValue(raw);
}
async createSession(user: AuthenticatedUserDTO): Promise<AuthSessionDTO> {
const now = Date.now();
const expiresAt = now + 24 * 60 * 60 * 1000;
const session: AuthSessionDTO = {
user,
issuedAt: now,
expiresAt,
token: randomUUID(),
};
const store = await cookies();
store.set(SESSION_COOKIE, serializeSession(session), {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NODE_ENV === 'production',
});
return session;
}
async clearSession(): Promise<void> {
const store = await cookies();
store.delete(SESSION_COOKIE);
}
}

View File

@@ -5,7 +5,8 @@
"types": "./index.ts",
"type": "module",
"exports": {
"./domain/*": "./domain/*"
"./domain/*": "./domain/*",
"./application/*": "./application/*"
},
"dependencies": {
"zod": "^3.25.76"

View File

@@ -1,7 +1,6 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",
"declaration": true,
"declarationMap": false