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,11 @@
import { NextResponse } from 'next/server';
import { getAuthService } from '@/lib/auth';
export async function POST(request: Request) {
const authService = getAuthService();
await authService.logout();
const url = new URL(request.url);
const redirectUrl = new URL('/', url.origin);
return NextResponse.redirect(redirectUrl);
}

View File

@@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
import { getAuthService } from '@/lib/auth';
export async function GET() {
const authService = getAuthService();
const session = await authService.getCurrentSession();
return NextResponse.json({
session,
});
}

View File

@@ -3,7 +3,6 @@ import { NextResponse } from 'next/server';
import { getAuthService } from '../../../../lib/auth';
const SESSION_COOKIE = 'gp_demo_session';
const STATE_COOKIE = 'gp_demo_auth_state';
export async function GET(request: Request) {
@@ -24,14 +23,7 @@ export async function GET(request: Request) {
}
const authService = getAuthService();
const session = await authService.loginWithIracingCallback({ code, state, returnTo });
cookieStore.set(SESSION_COOKIE, JSON.stringify(session), {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NODE_ENV === 'production',
});
await authService.loginWithIracingCallback({ code, state, returnTo });
cookieStore.delete(STATE_COOKIE);

View File

@@ -1,17 +1,8 @@
export interface AuthUser {
id: string;
displayName: string;
iracingCustomerId?: string;
primaryDriverId?: string;
avatarUrl?: string;
}
import type { AuthenticatedUserDTO } from '@gridpilot/identity/application/dto/AuthenticatedUserDTO';
import type { AuthSessionDTO } from '@gridpilot/identity/application/dto/AuthSessionDTO';
export interface AuthSession {
user: AuthUser;
issuedAt: number;
expiresAt: number;
token: string;
}
export type AuthUser = AuthenticatedUserDTO;
export type AuthSession = AuthSessionDTO;
export interface AuthService {
getCurrentSession(): Promise<AuthSession | null>;

View File

@@ -1,59 +1,32 @@
import { cookies } from 'next/headers';
import { randomUUID } from 'crypto';
import type { AuthService, AuthSession, AuthUser } from './AuthService';
import { createStaticRacingSeed } from '@gridpilot/testing-support';
const SESSION_COOKIE = 'gp_demo_session';
const STATE_COOKIE = 'gp_demo_auth_state';
function parseCookieValue(raw: string | undefined): AuthSession | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as AuthSession;
if (!parsed.expiresAt || Date.now() > parsed.expiresAt) {
return null;
}
return parsed;
} catch {
return null;
}
}
function serializeSession(session: AuthSession): string {
return JSON.stringify(session);
}
import type { AuthService, AuthSession } from './AuthService';
import type { AuthCallbackCommandDTO } from '@gridpilot/identity/application/dto/AuthCallbackCommandDTO';
import type { StartAuthCommandDTO } from '@gridpilot/identity/application/dto/StartAuthCommandDTO';
import { StartAuthUseCase } from '@gridpilot/identity/application/use-cases/StartAuthUseCase';
import { GetCurrentUserSessionUseCase } from '@gridpilot/identity/application/use-cases/GetCurrentUserSessionUseCase';
import { HandleAuthCallbackUseCase } from '@gridpilot/identity/application/use-cases/HandleAuthCallbackUseCase';
import { LogoutUseCase } from '@gridpilot/identity/application/use-cases/LogoutUseCase';
import { CookieIdentitySessionAdapter } from '@gridpilot/identity/infrastructure/session/CookieIdentitySessionAdapter';
import { IracingDemoIdentityProviderAdapter } from '@gridpilot/identity/infrastructure/providers/IracingDemoIdentityProviderAdapter';
export class InMemoryAuthService implements AuthService {
private readonly seedDriverId: string;
constructor() {
const seed = createStaticRacingSeed(42);
this.seedDriverId = seed.drivers[0]?.id ?? 'driver-1';
}
async getCurrentSession(): Promise<AuthSession | null> {
const store = await cookies();
const raw = store.get(SESSION_COOKIE)?.value;
return parseCookieValue(raw);
const sessionPort = new CookieIdentitySessionAdapter();
const useCase = new GetCurrentUserSessionUseCase(sessionPort);
return useCase.execute();
}
async startIracingAuthRedirect(
returnTo?: string,
): Promise<{ redirectUrl: string; state: string }> {
const state = randomUUID();
const provider = new IracingDemoIdentityProviderAdapter();
const useCase = new StartAuthUseCase(provider);
const params = new URLSearchParams();
params.set('code', 'dummy-code');
params.set('state', state);
if (returnTo) {
params.set('returnTo', returnTo);
}
return {
redirectUrl: `/auth/iracing/callback?${params.toString()}`,
state,
const command: StartAuthCommandDTO = {
provider: 'IRACING_DEMO',
returnTo,
};
return useCase.execute(command);
}
async loginWithIracingCallback(params: {
@@ -61,36 +34,23 @@ export class InMemoryAuthService implements AuthService {
state: string;
returnTo?: string;
}): Promise<AuthSession> {
if (!params.code) {
throw new Error('Missing auth code');
}
if (!params.state) {
throw new Error('Missing auth state');
}
const provider = new IracingDemoIdentityProviderAdapter();
const sessionPort = new CookieIdentitySessionAdapter();
const useCase = new HandleAuthCallbackUseCase(provider, sessionPort);
const user: AuthUser = {
id: 'demo-user',
displayName: 'GridPilot Demo Driver',
iracingCustomerId: '000000',
primaryDriverId: this.seedDriverId,
avatarUrl: `/api/avatar/${this.seedDriverId}`,
const command: AuthCallbackCommandDTO = {
provider: 'IRACING_DEMO',
code: params.code,
state: params.state,
returnTo: params.returnTo,
};
const now = Date.now();
const expiresAt = now + 24 * 60 * 60 * 1000;
const session: AuthSession = {
user,
issuedAt: now,
expiresAt,
token: randomUUID(),
};
return session;
return useCase.execute(command);
}
async logout(): Promise<void> {
// Intentionally does nothing; cookie deletion is handled by route handlers.
return;
const sessionPort = new CookieIdentitySessionAdapter();
const useCase = new LogoutUseCase(sessionPort);
await useCase.execute();
}
}

View File

@@ -19,7 +19,11 @@
}
],
"paths": {
"@/*": ["./*"]
"@/*": ["./*"],
"@gridpilot/identity/*": ["../../packages/identity/*"],
"@gridpilot/racing/*": ["../../packages/racing/*"],
"@gridpilot/social/*": ["../../packages/social/*"],
"@gridpilot/testing-support": ["../../packages/testing-support"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],

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

View File

@@ -17,7 +17,8 @@
"packages/*": ["packages/*"],
"apps/*": ["apps/*"],
"@gridpilot/shared-result": ["packages/shared/result/Result.ts"],
"@gridpilot/automation/*": ["packages/automation/*"]
"@gridpilot/automation/*": ["packages/automation/*"],
"@gridpilot/testing-support": ["packages/testing-support/index.ts"]
},
"types": ["vitest/globals", "node"],
"jsx": "react-jsx"