From 7b4d2e789f22b383f92d92e7267e4365c27a502a Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Tue, 17 Mar 2026 15:08:08 +0800 Subject: [PATCH] SSO auth (1) --- .env.example | 10 + package.json | 8 +- pnpm-lock.yaml | 90 +++ .../migration.sql | 63 ++ .../migration.sql | 8 + prisma/schema.prisma | 61 +- src/app.module.ts | 23 + src/auth/auth.constants.ts | 4 + src/auth/auth.controller.ts | 173 +++-- src/auth/auth.module.ts | 16 +- src/auth/auth.service.spec.ts | 428 ++++++++++++ src/auth/auth.service.ts | 622 ++++++++++++++---- src/auth/auth.types.ts | 20 + src/auth/auth.utils.ts | 30 + src/auth/decorators/current-user.decorator.ts | 1 + src/auth/dto/exchange-sso-code-request.dto.ts | 11 + src/auth/dto/login-response.dto.ts | 6 + src/auth/dto/logout-request.dto.ts | 9 + src/auth/dto/refresh-token-request.dto.ts | 9 + src/auth/dto/sso-provider.ts | 3 + src/auth/dto/start-sso-request.dto.ts | 15 + src/auth/dto/start-sso-response.dto.ts | 8 + src/auth/guards/discord-auth.guard.ts | 41 ++ src/auth/guards/google-auth.guard.ts | 42 ++ src/auth/guards/sso-provider.guard.ts | 26 + .../services/jwt-verification.service.spec.ts | 45 ++ src/auth/services/jwt-verification.service.ts | 8 +- src/auth/strategies/discord.strategy.ts | 61 ++ src/auth/strategies/google.strategy.ts | 60 ++ src/auth/strategies/jwt.strategy.ts | 8 + src/auth/types/social-auth-profile.ts | 11 + src/main.ts | 6 +- src/users/users.service.spec.ts | 19 + src/users/users.service.ts | 7 +- src/ws/state/connection/handler.ts | 6 + 35 files changed, 1762 insertions(+), 196 deletions(-) create mode 100644 prisma/migrations/20260316120000_add_sso_auth_tables/migration.sql create mode 100644 prisma/migrations/20260317110000_normalize_user_emails/migration.sql create mode 100644 src/auth/auth.constants.ts create mode 100644 src/auth/auth.service.spec.ts create mode 100644 src/auth/auth.types.ts create mode 100644 src/auth/auth.utils.ts create mode 100644 src/auth/dto/exchange-sso-code-request.dto.ts create mode 100644 src/auth/dto/logout-request.dto.ts create mode 100644 src/auth/dto/refresh-token-request.dto.ts create mode 100644 src/auth/dto/sso-provider.ts create mode 100644 src/auth/dto/start-sso-request.dto.ts create mode 100644 src/auth/dto/start-sso-response.dto.ts create mode 100644 src/auth/guards/discord-auth.guard.ts create mode 100644 src/auth/guards/google-auth.guard.ts create mode 100644 src/auth/guards/sso-provider.guard.ts create mode 100644 src/auth/strategies/discord.strategy.ts create mode 100644 src/auth/strategies/google.strategy.ts create mode 100644 src/auth/types/social-auth-profile.ts diff --git a/.env.example b/.env.example index a3a968c..4e2e5bf 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,13 @@ JWT_SECRET=replace-with-strong-random-secret JWT_ISSUER=friendolls JWT_AUDIENCE=friendolls-api JWT_EXPIRES_IN_SECONDS=3600 + +# Google OAuth +GOOGLE_CLIENT_ID="replace-with-google-client-id" +GOOGLE_CLIENT_SECRET="replace-with-google-client-secret" +GOOGLE_CALLBACK_URL="http://localhost:3000/auth/sso/google/callback" + +# Discord OAuth +DISCORD_CLIENT_ID="replace-with-discord-client-id" +DISCORD_CLIENT_SECRET="replace-with-discord-client-secret" +DISCORD_CALLBACK_URL="http://localhost:3000/auth/sso/discord/callback" diff --git a/package.json b/package.json index c909ae4..85066a0 100644 --- a/package.json +++ b/package.json @@ -40,14 +40,16 @@ "@prisma/adapter-pg": "^7.0.0", "@prisma/client": "^7.0.0", "@socket.io/redis-adapter": "^8.3.0", + "axios": "^1.7.9", + "bcryptjs": "^3.0.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "dotenv": "^17.2.3", "ioredis": "^5.8.2", - "axios": "^1.7.9", - "bcryptjs": "^3.0.2", "jsonwebtoken": "^9.0.2", "passport": "^0.7.0", + "passport-discord": "^0.1.4", + "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "pg": "^8.16.3", "reflect-metadata": "^0.2.2", @@ -65,6 +67,8 @@ "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.10.7", + "@types/passport-discord": "^0.1.15", + "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", "@types/pg": "^8.15.6", "@types/supertest": "^6.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2bf04b..7dafdcd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,12 @@ importers: passport: specifier: ^0.7.0 version: 0.7.0 + passport-discord: + specifier: ^0.1.4 + version: 0.1.4 + passport-google-oauth20: + specifier: ^2.0.0 + version: 2.0.0 passport-jwt: specifier: ^4.0.1 version: 4.0.1 @@ -117,6 +123,12 @@ importers: '@types/node': specifier: ^22.10.7 version: 22.19.1 + '@types/passport-discord': + specifier: ^0.1.15 + version: 0.1.15 + '@types/passport-google-oauth20': + specifier: ^2.0.17 + version: 2.0.17 '@types/passport-jwt': specifier: ^4.0.1 version: 4.0.1 @@ -1114,9 +1126,21 @@ packages: '@types/node@22.19.1': resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} + '@types/oauth@0.9.6': + resolution: {integrity: sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==} + + '@types/passport-discord@0.1.15': + resolution: {integrity: sha512-F5ryPS2vaSNoIz9IajuQjWx3gf0W2YhCJjX7eMQapzAZyl64WGPZEAmBlnpYDtJWMHXMPpJ8ixT4xl0Uk3/k1w==} + + '@types/passport-google-oauth20@2.0.17': + resolution: {integrity: sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==} + '@types/passport-jwt@4.0.1': resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + '@types/passport-oauth2@1.8.0': + resolution: {integrity: sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==} + '@types/passport-strategy@0.2.38': resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} @@ -1529,6 +1553,10 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} + base64url@3.0.1: + resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} + engines: {node: '>=6.0.0'} + baseline-browser-mapping@2.8.30: resolution: {integrity: sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==} hasBin: true @@ -2860,6 +2888,9 @@ packages: engines: {node: ^14.16.0 || >=16.10.0} hasBin: true + oauth@0.10.2: + resolution: {integrity: sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2929,9 +2960,21 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + passport-discord@0.1.4: + resolution: {integrity: sha512-VJWPYqSOmh7SaCLw/C+k1ZqCzJnn2frrmQRx1YrcPJ3MQ+Oa31XclbbmqFICSvl8xv3Fqd6YWQ4H4p1MpIN9rA==} + deprecated: 'This package is no longer maintained. Please consider migrating to a maintained alternative, (following packages are author unvetted) such as: discord-strategy, passport-discord-auth, ...' + + passport-google-oauth20@2.0.0: + resolution: {integrity: sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==} + engines: {node: '>= 0.4.0'} + passport-jwt@4.0.1: resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + passport-oauth2@1.8.0: + resolution: {integrity: sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==} + engines: {node: '>= 0.4.0'} + passport-strategy@1.0.0: resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} engines: {node: '>= 0.4.0'} @@ -3580,6 +3623,9 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + uid2@0.0.4: + resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} + uid2@1.0.0: resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==} engines: {node: '>= 4.0.0'} @@ -4919,11 +4965,33 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/oauth@0.9.6': + dependencies: + '@types/node': 22.19.1 + + '@types/passport-discord@0.1.15': + dependencies: + '@types/express': 5.0.5 + '@types/passport': 1.0.17 + '@types/passport-oauth2': 1.8.0 + + '@types/passport-google-oauth20@2.0.17': + dependencies: + '@types/express': 5.0.5 + '@types/passport': 1.0.17 + '@types/passport-oauth2': 1.8.0 + '@types/passport-jwt@4.0.1': dependencies: '@types/jsonwebtoken': 9.0.10 '@types/passport-strategy': 0.2.38 + '@types/passport-oauth2@1.8.0': + dependencies: + '@types/express': 5.0.5 + '@types/oauth': 0.9.6 + '@types/passport': 1.0.17 + '@types/passport-strategy@0.2.38': dependencies: '@types/express': 5.0.5 @@ -5382,6 +5450,8 @@ snapshots: base64id@2.0.0: {} + base64url@3.0.1: {} + baseline-browser-mapping@2.8.30: {} bcryptjs@3.0.3: {} @@ -6881,6 +6951,8 @@ snapshots: pkg-types: 2.3.0 tinyexec: 1.0.2 + oauth@0.10.2: {} + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -6955,11 +7027,27 @@ snapshots: parseurl@1.3.3: {} + passport-discord@0.1.4: + dependencies: + passport-oauth2: 1.8.0 + + passport-google-oauth20@2.0.0: + dependencies: + passport-oauth2: 1.8.0 + passport-jwt@4.0.1: dependencies: jsonwebtoken: 9.0.2 passport-strategy: 1.0.0 + passport-oauth2@1.8.0: + dependencies: + base64url: 3.0.1 + oauth: 0.10.2 + passport-strategy: 1.0.0 + uid2: 0.0.4 + utils-merge: 1.0.1 + passport-strategy@1.0.0: {} passport@0.7.0: @@ -7598,6 +7686,8 @@ snapshots: uglify-js@3.19.3: optional: true + uid2@0.0.4: {} + uid2@1.0.0: {} uid@2.0.2: diff --git a/prisma/migrations/20260316120000_add_sso_auth_tables/migration.sql b/prisma/migrations/20260316120000_add_sso_auth_tables/migration.sql new file mode 100644 index 0000000..5cbfa93 --- /dev/null +++ b/prisma/migrations/20260316120000_add_sso_auth_tables/migration.sql @@ -0,0 +1,63 @@ +CREATE TYPE "AuthProvider" AS ENUM ('GOOGLE', 'DISCORD'); + +CREATE TABLE "auth_identities" ( + "id" TEXT NOT NULL, + "provider" "AuthProvider" NOT NULL, + "provider_subject" TEXT NOT NULL, + "provider_email" TEXT, + "provider_name" TEXT, + "provider_username" TEXT, + "provider_picture" TEXT, + "email_verified" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "user_id" TEXT NOT NULL, + + CONSTRAINT "auth_identities_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "auth_sessions" ( + "id" TEXT NOT NULL, + "provider" "AuthProvider", + "refresh_token_hash" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "revoked_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "user_id" TEXT NOT NULL, + + CONSTRAINT "auth_sessions_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "auth_exchange_codes" ( + "id" TEXT NOT NULL, + "provider" "AuthProvider" NOT NULL, + "code_hash" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "consumed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "user_id" TEXT NOT NULL, + + CONSTRAINT "auth_exchange_codes_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "auth_identities_provider_provider_subject_key" ON "auth_identities"("provider", "provider_subject"); +CREATE INDEX "auth_identities_user_id_idx" ON "auth_identities"("user_id"); + +CREATE UNIQUE INDEX "auth_sessions_refresh_token_hash_key" ON "auth_sessions"("refresh_token_hash"); +CREATE INDEX "auth_sessions_user_id_idx" ON "auth_sessions"("user_id"); + +CREATE UNIQUE INDEX "auth_exchange_codes_code_hash_key" ON "auth_exchange_codes"("code_hash"); +CREATE INDEX "auth_exchange_codes_user_id_idx" ON "auth_exchange_codes"("user_id"); + +ALTER TABLE "auth_identities" +ADD CONSTRAINT "auth_identities_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "auth_sessions" +ADD CONSTRAINT "auth_sessions_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "auth_exchange_codes" +ADD CONSTRAINT "auth_exchange_codes_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260317110000_normalize_user_emails/migration.sql b/prisma/migrations/20260317110000_normalize_user_emails/migration.sql new file mode 100644 index 0000000..346c5e2 --- /dev/null +++ b/prisma/migrations/20260317110000_normalize_user_emails/migration.sql @@ -0,0 +1,8 @@ +UPDATE "users" +SET "email" = LOWER(TRIM("email")) +WHERE "email" <> LOWER(TRIM("email")); + +ALTER TABLE "users" + DROP CONSTRAINT IF EXISTS "users_email_key"; + +CREATE UNIQUE INDEX "users_email_key" ON "users"(LOWER("email")); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5eea465..215cd3d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,7 +9,7 @@ datasource db { provider = "postgresql" } -/// User model representing authenticated users from local auth +/// User model representing authenticated users from Friendolls auth model User { /// Internal unique identifier (UUID) id String @id @default(uuid()) @@ -54,10 +54,64 @@ model User { userFriendships Friendship[] @relation("UserFriendships") friendFriendships Friendship[] @relation("FriendFriendships") dolls Doll[] + authIdentities AuthIdentity[] + authSessions AuthSession[] + authExchangeCodes AuthExchangeCode[] @@map("users") } +model AuthIdentity { + id String @id @default(uuid()) + provider AuthProvider + providerSubject String @map("provider_subject") + providerEmail String? @map("provider_email") + providerName String? @map("provider_name") + providerUsername String? @map("provider_username") + providerPicture String? @map("provider_picture") + emailVerified Boolean @default(false) @map("email_verified") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + userId String @map("user_id") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerSubject]) + @@index([userId]) + @@map("auth_identities") +} + +model AuthSession { + id String @id @default(uuid()) + provider AuthProvider? + refreshTokenHash String @unique @map("refresh_token_hash") + expiresAt DateTime @map("expires_at") + revokedAt DateTime? @map("revoked_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + userId String @map("user_id") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("auth_sessions") +} + +model AuthExchangeCode { + id String @id @default(uuid()) + provider AuthProvider + codeHash String @unique @map("code_hash") + expiresAt DateTime @map("expires_at") + consumedAt DateTime? @map("consumed_at") + createdAt DateTime @default(now()) @map("created_at") + userId String @map("user_id") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("auth_exchange_codes") +} + model FriendRequest { id String @id @default(uuid()) senderId String @map("sender_id") @@ -108,3 +162,8 @@ enum FriendRequestStatus { ACCEPTED DENIED } + +enum AuthProvider { + GOOGLE + DISCORD +} diff --git a/src/app.module.ts b/src/app.module.ts index fdb1df3..b423f70 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -33,9 +33,32 @@ function validateEnvironment(config: Record): Record { throw new Error('PORT must be a valid number'); } + validateOptionalProvider(config, 'GOOGLE'); + validateOptionalProvider(config, 'DISCORD'); + return config; } +function validateOptionalProvider( + config: Record, + provider: 'GOOGLE' | 'DISCORD', +): void { + const vars = [ + `${provider}_CLIENT_ID`, + `${provider}_CLIENT_SECRET`, + `${provider}_CALLBACK_URL`, + ]; + + const presentVars = vars.filter((varName) => Boolean(config[varName])); + + if (presentVars.length > 0 && presentVars.length !== vars.length) { + const missingVars = vars.filter((varName) => !config[varName]); + throw new Error( + `Incomplete ${provider} OAuth configuration: missing ${missingVars.join(', ')}`, + ); + } +} + /** * Root Application Module * diff --git a/src/auth/auth.constants.ts b/src/auth/auth.constants.ts new file mode 100644 index 0000000..2d884d9 --- /dev/null +++ b/src/auth/auth.constants.ts @@ -0,0 +1,4 @@ +export const ACCESS_TOKEN_TYPE = 'access'; +export const REFRESH_TOKEN_TYPE = 'refresh'; +export const AUTH_CODE_TTL_MS = 5 * 60 * 1000; +export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 4beed3c..0724182 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,30 +1,34 @@ import { + BadRequestException, Body, Controller, + Get, HttpCode, - Post, - UseGuards, Logger, + Post, + Query, + Req, + Res, + UseGuards, } from '@nestjs/common'; import { - ApiBearerAuth, + ApiBody, ApiOperation, ApiResponse, ApiTags, ApiUnauthorizedResponse, - ApiBadRequestResponse, } from '@nestjs/swagger'; +import type { Request, Response } from 'express'; import { AuthService } from './auth.service'; -import { LoginRequestDto } from './dto/login-request.dto'; -import { RegisterRequestDto } from './dto/register-request.dto'; import { LoginResponseDto } from './dto/login-response.dto'; -import { ChangePasswordDto } from './dto/change-password.dto'; -import { ResetPasswordDto } from './dto/reset-password.dto'; -import { - CurrentUser, - type AuthenticatedUser, -} from './decorators/current-user.decorator'; -import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { StartSsoRequestDto } from './dto/start-sso-request.dto'; +import { StartSsoResponseDto } from './dto/start-sso-response.dto'; +import { ExchangeSsoCodeRequestDto } from './dto/exchange-sso-code-request.dto'; +import { RefreshTokenRequestDto } from './dto/refresh-token-request.dto'; +import { LogoutRequestDto } from './dto/logout-request.dto'; +import type { SocialAuthProfile } from './types/social-auth-profile'; +import { GoogleAuthGuard } from './guards/google-auth.guard'; +import { DiscordAuthGuard } from './guards/discord-auth.guard'; @ApiTags('auth') @Controller('auth') @@ -33,71 +37,100 @@ export class AuthController { constructor(private readonly authService: AuthService) {} - @Post('register') - @ApiOperation({ summary: 'Register a new user' }) - @ApiResponse({ status: 201, description: 'User registered' }) - @ApiBadRequestResponse({ description: 'Invalid registration data' }) - async register(@Body() body: RegisterRequestDto) { - const user = await this.authService.register(body); - this.logger.log(`Registered user: ${user.id}`); - return { id: user.id }; - } - - @Post('login') + @Post('sso/start') @HttpCode(200) - @ApiOperation({ summary: 'Login with email and password' }) + @ApiOperation({ summary: 'Create an SSO state token for the desktop app' }) + @ApiResponse({ status: 200, type: StartSsoResponseDto }) + startSso(@Body() body: StartSsoRequestDto): StartSsoResponseDto { + return this.authService.startSso(body.provider, body.redirectUri); + } + + @Get('sso/google') + @UseGuards(GoogleAuthGuard) + @ApiOperation({ summary: 'Begin Google sign-in' }) + async startGoogle(): Promise {} + + @Get('sso/google/callback') + @UseGuards(GoogleAuthGuard) + @ApiOperation({ summary: 'Handle Google sign-in callback' }) + async finishGoogle( + @Req() request: Request, + @Res() response: Response, + @Query('state') state?: string, + ): Promise { + await this.finishSso('google', request, response, state); + } + + @Get('sso/discord') + @UseGuards(DiscordAuthGuard) + @ApiOperation({ summary: 'Begin Discord sign-in' }) + async startDiscord(): Promise {} + + @Get('sso/discord/callback') + @UseGuards(DiscordAuthGuard) + @ApiOperation({ summary: 'Handle Discord sign-in callback' }) + async finishDiscord( + @Req() request: Request, + @Res() response: Response, + @Query('state') state?: string, + ): Promise { + await this.finishSso('discord', request, response, state); + } + + @Post('sso/exchange') + @HttpCode(200) + @ApiOperation({ + summary: 'Exchange a one-time desktop auth code for app tokens', + }) @ApiResponse({ status: 200, type: LoginResponseDto }) - @ApiUnauthorizedResponse({ description: 'Invalid credentials' }) - async login(@Body() body: LoginRequestDto): Promise { - return this.authService.login(body.email, body.password); - } - - @Post('change-password') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() - @HttpCode(204) - @ApiOperation({ summary: 'Change current user password' }) - @ApiResponse({ status: 204, description: 'Password updated' }) - @ApiUnauthorizedResponse({ description: 'Invalid credentials' }) - async changePassword( - @CurrentUser() user: AuthenticatedUser, - @Body() body: ChangePasswordDto, - ): Promise { - await this.authService.changePassword( - user.userId, - body.currentPassword, - body.newPassword, - ); - } - - @Post('reset-password') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() - @HttpCode(204) - @ApiOperation({ summary: 'Reset password with old password' }) - @ApiResponse({ status: 204, description: 'Password updated' }) - @ApiUnauthorizedResponse({ description: 'Invalid credentials' }) - async resetPassword( - @CurrentUser() user: AuthenticatedUser, - @Body() body: ResetPasswordDto, - ): Promise { - await this.authService.changePassword( - user.userId, - body.oldPassword, - body.newPassword, - ); + @ApiUnauthorizedResponse({ description: 'Invalid or expired exchange code' }) + async exchangeSsoCode( + @Body() body: ExchangeSsoCodeRequestDto, + ): Promise { + return this.authService.exchangeSsoCode(body.code); } @Post('refresh') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() @HttpCode(200) - @ApiOperation({ summary: 'Refresh access token' }) + @ApiOperation({ summary: 'Rotate app tokens using a refresh token' }) + @ApiBody({ type: RefreshTokenRequestDto }) @ApiResponse({ status: 200, type: LoginResponseDto }) - @ApiUnauthorizedResponse({ description: 'Invalid token' }) + @ApiUnauthorizedResponse({ description: 'Invalid refresh token' }) async refresh( - @CurrentUser() user: AuthenticatedUser, + @Body() body: RefreshTokenRequestDto, ): Promise { - return this.authService.refreshToken(user); + return this.authService.refreshTokens(body.refreshToken); + } + + @Post('logout') + @HttpCode(204) + @ApiOperation({ summary: 'Revoke a refresh token session' }) + @ApiBody({ type: LogoutRequestDto }) + async logout(@Body() body: LogoutRequestDto): Promise { + await this.authService.logout(body.refreshToken); + } + + private async finishSso( + provider: 'google' | 'discord', + request: Request, + response: Response, + state?: string, + ): Promise { + if (!state) { + throw new BadRequestException('Missing SSO state'); + } + + const profile = request.user as SocialAuthProfile | undefined; + if (!profile) { + throw new BadRequestException('Missing SSO profile'); + } + + const redirectUri = await this.authService.completeSso( + provider, + state, + profile, + ); + this.logger.log(`Completed ${provider} SSO callback`); + response.redirect(302, redirectUri); } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 5f77dfb..4bf688a 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -3,18 +3,30 @@ import { ConfigModule } from '@nestjs/config'; import { PassportModule } from '@nestjs/passport'; import { AuthService } from './auth.service'; import { JwtStrategy } from './strategies/jwt.strategy'; +import { GoogleStrategy } from './strategies/google.strategy'; +import { DiscordStrategy } from './strategies/discord.strategy'; import { JwtVerificationService } from './services/jwt-verification.service'; import { UsersModule } from '../users/users.module'; import { AuthController } from './auth.controller'; +import { GoogleAuthGuard } from './guards/google-auth.guard'; +import { DiscordAuthGuard } from './guards/discord-auth.guard'; @Module({ imports: [ ConfigModule, - PassportModule.register({ defaultStrategy: 'jwt' }), + PassportModule.register({ defaultStrategy: 'jwt', session: false }), forwardRef(() => UsersModule), ], controllers: [AuthController], - providers: [JwtStrategy, AuthService, JwtVerificationService], + providers: [ + JwtStrategy, + GoogleStrategy, + DiscordStrategy, + GoogleAuthGuard, + DiscordAuthGuard, + AuthService, + JwtVerificationService, + ], exports: [AuthService, PassportModule, JwtVerificationService], }) export class AuthModule {} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..7efa921 --- /dev/null +++ b/src/auth/auth.service.spec.ts @@ -0,0 +1,428 @@ +import { + BadRequestException, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { decode, sign } from 'jsonwebtoken'; +import { PrismaService } from '../database/prisma.service'; +import { AuthService } from './auth.service'; +import { sha256 } from './auth.utils'; +import type { SocialAuthProfile } from './types/social-auth-profile'; + +describe('AuthService', () => { + let service: AuthService; + + const applyDefaultConfig = () => { + mockConfigService.get.mockImplementation((key: string) => { + const config: Record = { + JWT_SECRET: 'test-secret', + JWT_ISSUER: 'friendolls', + JWT_AUDIENCE: 'friendolls-api', + JWT_EXPIRES_IN_SECONDS: '3600', + GOOGLE_CLIENT_ID: 'google-client-id', + GOOGLE_CLIENT_SECRET: 'google-client-secret', + GOOGLE_CALLBACK_URL: 'http://localhost:3000/auth/sso/google/callback', + DISCORD_CLIENT_ID: 'discord-client-id', + DISCORD_CLIENT_SECRET: 'discord-client-secret', + DISCORD_CALLBACK_URL: 'http://localhost:3000/auth/sso/discord/callback', + }; + + return config[key]; + }); + }; + + const mockConfigService = { + get: jest.fn(), + }; + + const mockPrismaService = { + authExchangeCode: { + create: jest.fn(), + }, + authIdentity: { + findUnique: jest.fn(), + update: jest.fn(), + }, + authSession: { + create: jest.fn(), + }, + user: { + update: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + }, + $queryRaw: jest.fn(), + $transaction: jest.fn(), + }; + + const socialProfile: SocialAuthProfile = { + provider: 'google', + providerSubject: 'google-user-123', + email: 'jane@example.com', + emailVerified: true, + displayName: 'Jane Example', + username: 'jane', + picture: 'https://example.com/jane.png', + }; + + const createRefreshToken = (overrides: Record = {}) => + sign( + { + sub: 'user-1', + sid: 'session-1', + jti: 'refresh-jti', + typ: 'refresh', + ...overrides, + }, + 'test-secret', + { + issuer: 'friendolls', + audience: 'friendolls-api', + expiresIn: 60, + algorithm: 'HS256', + }, + ); + + beforeEach(async () => { + applyDefaultConfig(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: PrismaService, useValue: mockPrismaService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(AuthService); + jest.clearAllMocks(); + applyDefaultConfig(); + }); + + describe('startSso', () => { + it('returns a signed state token for configured providers', () => { + const result = service.startSso( + 'google', + 'http://127.0.0.1:43123/callback', + ); + + expect(result.state).toEqual(expect.any(String)); + }); + + it('rejects unconfigured providers', () => { + mockConfigService.get.mockImplementation((key: string) => { + if (key.startsWith('GOOGLE_')) { + return undefined; + } + + const config: Record = { + JWT_SECRET: 'test-secret', + JWT_ISSUER: 'friendolls', + JWT_AUDIENCE: 'friendolls-api', + JWT_EXPIRES_IN_SECONDS: '3600', + DISCORD_CLIENT_ID: 'discord-client-id', + DISCORD_CLIENT_SECRET: 'discord-client-secret', + DISCORD_CALLBACK_URL: + 'http://localhost:3000/auth/sso/discord/callback', + }; + + return config[key]; + }); + + const localService = new AuthService( + mockPrismaService as unknown as PrismaService, + mockConfigService as unknown as ConfigService, + ); + + expect(() => + localService.startSso('google', 'http://127.0.0.1:43123/callback'), + ).toThrow(ServiceUnavailableException); + }); + }); + + describe('exchangeSsoCode', () => { + it('throws when the exchange code was already consumed', async () => { + mockPrismaService.$queryRaw.mockResolvedValueOnce([]); + + await expect(service.exchangeSsoCode('used-code')).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + describe('refreshTokens', () => { + it('throws unauthorized on malformed refresh token', async () => { + await expect(service.refreshTokens('not-a-jwt')).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('revokes the session when a stale refresh token is replayed', async () => { + const refreshToken = createRefreshToken(); + + mockPrismaService.$queryRaw + .mockResolvedValueOnce([ + { + id: 'session-1', + refresh_token_hash: 'different-hash', + expires_at: new Date(Date.now() + 60_000), + revoked_at: null, + provider: 'GOOGLE', + user_id: 'user-1', + email: 'jane@example.com', + roles: ['user'], + }, + ]) + .mockResolvedValueOnce([{ id: 'session-1' }]); + + await expect(service.refreshTokens(refreshToken)).rejects.toThrow( + UnauthorizedException, + ); + expect(mockPrismaService.$queryRaw).toHaveBeenCalledTimes(2); + }); + + it('issues a distinct refresh token on rotation', async () => { + const refreshToken = createRefreshToken(); + + mockPrismaService.$queryRaw + .mockResolvedValueOnce([ + { + id: 'session-1', + refresh_token_hash: sha256(refreshToken), + expires_at: new Date(Date.now() + 60_000), + revoked_at: null, + provider: 'GOOGLE', + user_id: 'user-1', + email: 'jane@example.com', + roles: ['user'], + }, + ]) + .mockResolvedValueOnce([{ id: 'session-1' }]); + + const result = await service.refreshTokens(refreshToken); + + expect(result.refreshToken).not.toBe(refreshToken); + const payload = decode(result.refreshToken) as { jti?: string } | null; + expect(payload?.jti).toEqual(expect.any(String)); + }); + + it('throws when refresh rotation loses the race', async () => { + const refreshToken = createRefreshToken(); + + mockPrismaService.$queryRaw + .mockResolvedValueOnce([ + { + id: 'session-1', + refresh_token_hash: sha256(refreshToken), + expires_at: new Date(Date.now() + 60_000), + revoked_at: null, + provider: 'GOOGLE', + user_id: 'user-1', + email: 'jane@example.com', + roles: ['user'], + }, + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'session-1' }]); + + await expect(service.refreshTokens(refreshToken)).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + describe('completeSso', () => { + it('creates a new account when the provider email is verified', async () => { + const state = service.startSso( + 'google', + 'http://127.0.0.1:43123/callback', + ).state; + const createdUser = { + id: 'user-1', + email: 'jane@example.com', + name: 'Jane Example', + username: 'jane', + picture: 'https://example.com/jane.png', + roles: [], + keycloakSub: null, + passwordHash: null, + lastLoginAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + activeDollId: null, + }; + const txUserCreate = jest.fn().mockResolvedValue(createdUser); + + mockPrismaService.authIdentity.findUnique.mockResolvedValue(null); + mockPrismaService.$transaction.mockImplementation((callback) => + Promise.resolve( + callback({ + user: { + findUnique: jest.fn().mockResolvedValue(null), + create: txUserCreate, + }, + authIdentity: { + create: jest.fn().mockResolvedValue(undefined), + }, + }), + ), + ); + mockPrismaService.authExchangeCode.create.mockResolvedValue({ + id: 'code-1', + }); + + const redirectUri = await service.completeSso( + 'google', + state, + socialProfile, + ); + + expect(mockPrismaService.$transaction).toHaveBeenCalled(); + expect(txUserCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + email: 'jane@example.com', + }), + }); + expect(mockPrismaService.authExchangeCode.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + provider: 'GOOGLE', + userId: 'user-1', + }), + }); + expect(redirectUri).toContain('http://127.0.0.1:43123/callback'); + expect(redirectUri).toContain('code='); + }); + + it('rejects creating an account when provider email is unverified', async () => { + const state = service.startSso( + 'google', + 'http://127.0.0.1:43123/callback', + ).state; + const unverifiedProfile: SocialAuthProfile = { + ...socialProfile, + emailVerified: false, + }; + + mockPrismaService.authIdentity.findUnique.mockResolvedValue(null); + + await expect( + service.completeSso('google', state, unverifiedProfile), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects auto-linking to an existing local account', async () => { + const state = service.startSso( + 'google', + 'http://127.0.0.1:43123/callback', + ).state; + + mockPrismaService.authIdentity.findUnique.mockResolvedValue(null); + mockPrismaService.$transaction.mockImplementation((callback) => + Promise.resolve( + callback({ + user: { + findUnique: jest.fn().mockResolvedValue({ id: 'user-1' }), + create: jest.fn(), + }, + authIdentity: { + create: jest.fn(), + }, + }), + ), + ); + + await expect( + service.completeSso('google', state, socialProfile), + ).rejects.toThrow(BadRequestException); + }); + + it('allows an existing linked identity to sign in without an email', async () => { + const state = service.startSso( + 'discord', + 'http://127.0.0.1:43123/callback', + ).state; + const linkedUser = { + id: 'user-1', + email: 'jane@example.com', + name: 'Jane Example', + username: 'jane', + picture: 'https://example.com/jane.png', + roles: ['user'], + keycloakSub: null, + passwordHash: null, + lastLoginAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + activeDollId: null, + }; + + mockPrismaService.authIdentity.findUnique.mockResolvedValue({ + id: 'identity-1', + user: linkedUser, + }); + mockPrismaService.authIdentity.update.mockResolvedValue(undefined); + mockPrismaService.user.update.mockResolvedValue(linkedUser); + mockPrismaService.authExchangeCode.create.mockResolvedValue({ + id: 'code-1', + }); + + const redirectUri = await service.completeSso('discord', state, { + provider: 'discord', + providerSubject: 'google-user-123', + email: null, + emailVerified: false, + displayName: 'Jane Example', + username: 'jane', + }); + + expect(redirectUri).toContain('code='); + expect(mockPrismaService.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: expect.not.objectContaining({ email: expect.anything() }), + }); + }); + + it('normalizes provider emails before creating users and identities', async () => { + const state = service.startSso( + 'google', + 'http://127.0.0.1:43123/callback', + ).state; + const mixedCaseProfile: SocialAuthProfile = { + ...socialProfile, + email: ' Jane@Example.COM ', + }; + + const txUserCreate = jest.fn().mockResolvedValue({ id: 'user-1' }); + const txIdentityCreate = jest.fn().mockResolvedValue(undefined); + + mockPrismaService.authIdentity.findUnique.mockResolvedValue(null); + mockPrismaService.$transaction.mockImplementation((callback) => + Promise.resolve( + callback({ + user: { + findUnique: jest.fn().mockResolvedValue(null), + create: txUserCreate, + }, + authIdentity: { + create: txIdentityCreate, + }, + }), + ), + ); + mockPrismaService.authExchangeCode.create.mockResolvedValue({ + id: 'code-1', + }); + + await service.completeSso('google', state, mixedCaseProfile); + + expect(txUserCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ email: 'jane@example.com' }), + }); + expect(txIdentityCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ providerEmail: 'jane@example.com' }), + }); + }); + }); +}); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index b8e5fee..98775bd 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,25 +1,46 @@ import { + BadRequestException, Injectable, Logger, + ServiceUnavailableException, UnauthorizedException, - BadRequestException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { sign } from 'jsonwebtoken'; -import { compare, hash } from 'bcryptjs'; -import { UsersService } from '../users/users.service'; -import { User } from '../users/users.entity'; -import type { AuthenticatedUser } from './decorators/current-user.decorator'; +import { + JsonWebTokenError, + TokenExpiredError, + sign, + verify, +} from 'jsonwebtoken'; +import { PrismaService } from '../database/prisma.service'; +import type { SocialAuthProfile } from './types/social-auth-profile'; +import type { + AuthTokens, + AccessTokenClaims, + RefreshTokenClaims, +} from './auth.types'; +import { + AUTH_CODE_TTL_MS, + REFRESH_TOKEN_TTL_MS, + ACCESS_TOKEN_TYPE, + REFRESH_TOKEN_TYPE, +} from './auth.constants'; +import { + asProviderName, + isLoopbackRedirect, + normalizeEmail, + randomOpaqueToken, + sha256, +} from './auth.utils'; +import type { SsoProvider } from './dto/sso-provider'; + +interface SsoStateClaims { + provider: SsoProvider; + redirectUri: string; + nonce: string; + typ: 'sso_state'; +} -/** - * Authentication Service - * - * Handles native authentication: - * - User registration - * - Login with email/password - * - JWT issuance - * - Password changes - */ @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); @@ -27,114 +48,299 @@ export class AuthService { private readonly jwtIssuer: string; private readonly jwtAudience?: string; private readonly jwtExpiresInSeconds: number; + private readonly googleSsoEnabled: boolean; + private readonly discordSsoEnabled: boolean; constructor( - private readonly usersService: UsersService, + private readonly prisma: PrismaService, private readonly configService: ConfigService, ) { this.jwtSecret = this.configService.get('JWT_SECRET') || ''; - if (!this.jwtSecret) { - throw new Error('JWT_SECRET must be configured'); - } this.jwtIssuer = this.configService.get('JWT_ISSUER') || 'friendolls'; this.jwtAudience = this.configService.get('JWT_AUDIENCE'); this.jwtExpiresInSeconds = Number( this.configService.get('JWT_EXPIRES_IN_SECONDS') || '3600', ); + this.googleSsoEnabled = this.isProviderConfigured('GOOGLE'); + this.discordSsoEnabled = this.isProviderConfigured('DISCORD'); + + if (!this.jwtSecret) { + throw new Error('JWT_SECRET must be configured'); + } } - async register(data: { - email: string; - password: string; - name?: string; - username?: string; - }): Promise { - const { email, password, name, username } = data; + startSso(provider: SsoProvider, redirectUri: string): { state: string } { + this.assertProviderEnabled(provider); - const existing = await this.usersService.findByEmail(email); - if (existing) { - throw new BadRequestException('Email already registered'); + if (!isLoopbackRedirect(redirectUri)) { + throw new BadRequestException( + 'Desktop redirect URI must target localhost or 127.0.0.1', + ); } - const passwordHash = await hash(password, 12); - return this.usersService.createLocalUser({ - email, - passwordHash, - name: name || username || 'Unknown User', - username, + const state = sign( + { + provider, + redirectUri, + nonce: randomOpaqueToken(16), + typ: 'sso_state', + } satisfies SsoStateClaims, + this.jwtSecret, + { + issuer: this.jwtIssuer, + audience: this.jwtAudience, + expiresIn: Math.floor(AUTH_CODE_TTL_MS / 1000), + algorithm: 'HS256', + }, + ); + + return { state }; + } + + async completeSso( + provider: SsoProvider, + state: string, + profile: SocialAuthProfile, + ): Promise { + this.assertProviderEnabled(provider); + + const stateClaims = this.verifyStateToken(state, provider); + const user = await this.findOrCreateUserFromProfile(profile); + const authCode = randomOpaqueToken(32); + + await this.prisma.authExchangeCode.create({ + data: { + provider: asProviderName(provider), + codeHash: sha256(authCode), + expiresAt: new Date(Date.now() + AUTH_CODE_TTL_MS), + userId: user.id, + }, + }); + + const callbackUrl = new URL(stateClaims.redirectUri); + callbackUrl.searchParams.set('code', authCode); + callbackUrl.searchParams.set('state', state); + return callbackUrl.toString(); + } + + async exchangeSsoCode(code: string): Promise { + const codeHash = sha256(code); + + const matchedExchange = await this.consumeExchangeCode(codeHash); + + if (!matchedExchange) { + throw new UnauthorizedException('Invalid or expired exchange code'); + } + + return this.issueTokens( + matchedExchange.user_id, + matchedExchange.email, + matchedExchange.roles, + matchedExchange.provider, + ); + } + + async refreshTokens(refreshToken: string): Promise { + const payload = this.verifyRefreshToken(refreshToken); + const refreshTokenHash = sha256(refreshToken); + const now = new Date(); + + const session = await this.getSessionWithUser(payload.sid); + + if (!session) { + throw new UnauthorizedException('Invalid refresh token'); + } + + if (session.revoked_at || session.expires_at <= now) { + throw new UnauthorizedException('Invalid refresh token'); + } + + if (session.refresh_token_hash !== refreshTokenHash) { + await this.revokeSessionOnReplay(session.id); + throw new UnauthorizedException('Invalid refresh token'); + } + + const nextRefreshToken = this.signRefreshToken(session.user_id, session.id); + const updated = await this.rotateRefreshSession( + session.id, + refreshTokenHash, + nextRefreshToken, + ); + + if (!updated) { + await this.revokeSessionOnReplay(session.id); + throw new UnauthorizedException('Invalid refresh token'); + } + + return { + accessToken: this.signAccessToken( + session.user_id, + session.email, + session.roles, + ), + expiresIn: this.jwtExpiresInSeconds, + refreshToken: nextRefreshToken, + refreshExpiresIn: Math.floor(REFRESH_TOKEN_TTL_MS / 1000), + }; + } + + async logout(refreshToken: string): Promise { + try { + const payload = this.verifyRefreshToken(refreshToken); + const updated = await this.revokeRefreshSession( + payload.sid, + sha256(refreshToken), + ); + + if (!updated) { + return; + } + } catch { + return; + } + } + + private async findOrCreateUserFromProfile(profile: SocialAuthProfile) { + const provider = asProviderName(profile.provider); + const now = new Date(); + const existingIdentity = await this.prisma.authIdentity.findUnique({ + where: { + provider_providerSubject: { + provider, + providerSubject: profile.providerSubject, + }, + }, + include: { + user: true, + }, + }); + + if (existingIdentity) { + const normalizedProviderEmail = profile.email + ? normalizeEmail(profile.email) + : null; + + await this.prisma.authIdentity.update({ + where: { id: existingIdentity.id }, + data: { + ...(normalizedProviderEmail + ? { providerEmail: normalizedProviderEmail } + : {}), + providerName: profile.displayName, + providerUsername: profile.username, + providerPicture: profile.picture, + emailVerified: profile.emailVerified, + }, + }); + + const user = await this.prisma.user.update({ + where: { id: existingIdentity.user.id }, + data: { + ...(normalizedProviderEmail + ? { email: normalizedProviderEmail } + : {}), + name: profile.displayName, + username: profile.username, + picture: profile.picture, + lastLoginAt: now, + }, + }); + + return user; + } + + if (!profile.email) { + throw new BadRequestException('Provider did not supply an email address'); + } + + const email = normalizeEmail(profile.email); + + if (!profile.emailVerified) { + throw new BadRequestException( + 'Provider email must be verified before creating an account', + ); + } + + return this.prisma.$transaction(async (tx) => { + let user = await tx.user.findUnique({ + where: { email }, + }); + + if (user) { + throw new BadRequestException( + 'An account with this email already exists. Sign in with the existing account before linking this provider.', + ); + } + + user = await tx.user.create({ + data: { + email, + name: profile.displayName, + username: profile.username, + picture: profile.picture, + roles: [], + lastLoginAt: now, + keycloakSub: null, + }, + }); + + await tx.authIdentity.create({ + data: { + provider, + providerSubject: profile.providerSubject, + providerEmail: email, + providerName: profile.displayName, + providerUsername: profile.username, + providerPicture: profile.picture, + emailVerified: profile.emailVerified, + userId: user.id, + }, + }); + + return user; }); } - async login( - email: string, - password: string, - ): Promise<{ - accessToken: string; - expiresIn: number; - }> { - const user = await this.usersService.findByEmail(email); - if (!user) { - throw new UnauthorizedException('Invalid credentials'); - } - - const passwordOk = await this.verifyPassword(user, password); - if (!passwordOk) { - throw new UnauthorizedException('Invalid credentials'); - } - - await this.usersService.updateLastLogin(user.id); - - const accessToken = this.issueToken({ - userId: user.id, - email: user.email, - roles: user.roles, - }); - - return { accessToken, expiresIn: this.jwtExpiresInSeconds }; - } - - async changePassword( + private async issueTokens( userId: string, - currentPassword: string, - newPassword: string, - ): Promise { - const user = await this.usersService.findOne(userId); + email: string, + roles: string[], + provider?: 'GOOGLE' | 'DISCORD', + ): Promise { + const sessionId = randomOpaqueToken(16); + const refreshToken = this.signRefreshToken(userId, sessionId); - const passwordOk = await this.verifyPassword(user, currentPassword); - if (!passwordOk) { - throw new UnauthorizedException('Invalid credentials'); - } - - const passwordHash = await hash(newPassword, 12); - await this.usersService.updatePasswordHash(userId, passwordHash); - } - - async refreshToken(user: AuthenticatedUser): Promise<{ - accessToken: string; - expiresIn: number; - }> { - const existingUser = await this.usersService.findOne(user.userId); - const accessToken = this.issueToken({ - userId: existingUser.id, - email: existingUser.email, - roles: existingUser.roles, + await this.prisma.authSession.create({ + data: { + id: sessionId, + provider: provider ?? null, + refreshTokenHash: sha256(refreshToken), + expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS), + userId, + }, }); - return { accessToken, expiresIn: this.jwtExpiresInSeconds }; + return { + accessToken: this.signAccessToken(userId, email, roles), + expiresIn: this.jwtExpiresInSeconds, + refreshToken, + refreshExpiresIn: Math.floor(REFRESH_TOKEN_TTL_MS / 1000), + }; } - private issueToken(payload: { - userId: string; - email: string; - roles: string[]; - }): string { + private signAccessToken( + userId: string, + email: string, + roles: string[], + ): string { return sign( { - sub: payload.userId, - email: payload.email, - roles: payload.roles, - }, + sub: userId, + email, + roles, + typ: ACCESS_TOKEN_TYPE, + } satisfies AccessTokenClaims, this.jwtSecret, { issuer: this.jwtIssuer, @@ -145,32 +351,218 @@ export class AuthService { ); } - private async verifyPassword(user: User, password: string): Promise { - const userWithPassword = user as unknown as { - passwordHash?: string | null; - }; - if (userWithPassword.passwordHash) { - return compare(password, userWithPassword.passwordHash); - } - - return false; + private signRefreshToken(userId: string, sessionId: string): string { + return sign( + { + sub: userId, + sid: sessionId, + jti: randomOpaqueToken(16), + typ: REFRESH_TOKEN_TYPE, + } satisfies RefreshTokenClaims, + this.jwtSecret, + { + issuer: this.jwtIssuer, + audience: this.jwtAudience, + expiresIn: Math.floor(REFRESH_TOKEN_TTL_MS / 1000), + algorithm: 'HS256', + }, + ); } - hasRole(user: { roles?: string[] }, requiredRole: string): boolean { - return user.roles?.includes(requiredRole) ?? false; + private verifyStateToken( + state: string, + provider: SsoProvider, + ): SsoStateClaims { + let payload: SsoStateClaims; + + try { + payload = verify(state, this.jwtSecret, { + issuer: this.jwtIssuer, + audience: this.jwtAudience, + algorithms: ['HS256'], + }) as SsoStateClaims; + } catch (error) { + if ( + error instanceof TokenExpiredError || + error instanceof JsonWebTokenError + ) { + throw new BadRequestException('Invalid SSO state'); + } + + throw error; + } + + if (payload.typ !== 'sso_state' || payload.provider !== provider) { + throw new BadRequestException('Invalid SSO state'); + } + + if (!isLoopbackRedirect(payload.redirectUri)) { + throw new BadRequestException('Invalid SSO redirect URI'); + } + + return payload; } - hasAnyRole(user: { roles?: string[] }, requiredRoles: string[]): boolean { - if (!user.roles || user.roles.length === 0) { - return false; + private verifyRefreshToken(refreshToken: string): RefreshTokenClaims { + let payload: RefreshTokenClaims; + + try { + payload = verify(refreshToken, this.jwtSecret, { + issuer: this.jwtIssuer, + audience: this.jwtAudience, + algorithms: ['HS256'], + }) as RefreshTokenClaims; + } catch (error) { + if ( + error instanceof TokenExpiredError || + error instanceof JsonWebTokenError + ) { + throw new UnauthorizedException('Invalid refresh token'); + } + + throw error; } - return requiredRoles.some((role) => user.roles!.includes(role)); + + if ( + payload.typ !== 'refresh' || + !payload.sid || + !payload.sub || + !payload.jti + ) { + throw new UnauthorizedException('Invalid refresh token'); + } + + return payload; } - hasAllRoles(user: { roles?: string[] }, requiredRoles: string[]): boolean { - if (!user.roles || user.roles.length === 0) { - return false; + private isProviderConfigured(provider: 'GOOGLE' | 'DISCORD'): boolean { + return Boolean( + this.configService.get(`${provider}_CLIENT_ID`) && + this.configService.get(`${provider}_CLIENT_SECRET`) && + this.configService.get(`${provider}_CALLBACK_URL`), + ); + } + + private assertProviderEnabled(provider: SsoProvider): void { + const enabled = + provider === 'google' ? this.googleSsoEnabled : this.discordSsoEnabled; + + if (!enabled) { + this.logger.warn(`SSO provider is not configured: ${provider}`); + throw new ServiceUnavailableException( + `${provider} SSO is not configured`, + ); } - return requiredRoles.every((role) => user.roles!.includes(role)); + } + + private async consumeExchangeCode(codeHash: string): Promise<{ + id: string; + provider: 'GOOGLE' | 'DISCORD'; + user_id: string; + email: string; + roles: string[]; + } | null> { + const rows = await this.prisma.$queryRaw< + Array<{ + id: string; + provider: 'GOOGLE' | 'DISCORD'; + user_id: string; + email: string; + roles: string[]; + }> + >` + UPDATE auth_exchange_codes AS aec + SET consumed_at = NOW() + FROM users AS u + WHERE aec.user_id = u.id + AND aec.code_hash = ${codeHash} + AND aec.consumed_at IS NULL + AND aec.expires_at > NOW() + RETURNING aec.id, aec.provider, aec.user_id, u.email, u.roles + `; + + return rows[0] ?? null; + } + + private async getSessionWithUser(sessionId: string): Promise<{ + id: string; + refresh_token_hash: string; + expires_at: Date; + revoked_at: Date | null; + provider: 'GOOGLE' | 'DISCORD' | null; + user_id: string; + email: string; + roles: string[]; + } | null> { + const rows = await this.prisma.$queryRaw< + Array<{ + id: string; + refresh_token_hash: string; + expires_at: Date; + revoked_at: Date | null; + provider: 'GOOGLE' | 'DISCORD' | null; + user_id: string; + email: string; + roles: string[]; + }> + >` + SELECT s.id, s.refresh_token_hash, s.expires_at, s.revoked_at, s.provider, s.user_id, u.email, u.roles + FROM auth_sessions AS s + INNER JOIN users AS u ON u.id = s.user_id + WHERE s.id = ${sessionId} + LIMIT 1 + `; + + return rows[0] ?? null; + } + + private async rotateRefreshSession( + sessionId: string, + refreshTokenHash: string, + nextRefreshToken: string, + ): Promise { + const rows = await this.prisma.$queryRaw>` + UPDATE auth_sessions + SET refresh_token_hash = ${sha256(nextRefreshToken)}, + expires_at = ${new Date(Date.now() + REFRESH_TOKEN_TTL_MS)}, + revoked_at = NULL, + updated_at = NOW() + WHERE id = ${sessionId} + AND refresh_token_hash = ${refreshTokenHash} + AND revoked_at IS NULL + AND expires_at > NOW() + RETURNING id + `; + + return rows.length === 1; + } + + private async revokeRefreshSession( + sessionId: string, + refreshTokenHash: string, + ): Promise { + const rows = await this.prisma.$queryRaw>` + UPDATE auth_sessions + SET revoked_at = NOW(), + updated_at = NOW() + WHERE id = ${sessionId} + AND refresh_token_hash = ${refreshTokenHash} + AND revoked_at IS NULL + AND expires_at > NOW() + RETURNING id + `; + + return rows.length === 1; + } + + private async revokeSessionOnReplay(sessionId: string): Promise { + await this.prisma.$queryRaw>` + UPDATE auth_sessions + SET revoked_at = NOW(), + updated_at = NOW() + WHERE id = ${sessionId} + AND revoked_at IS NULL + RETURNING id + `; } } diff --git a/src/auth/auth.types.ts b/src/auth/auth.types.ts new file mode 100644 index 0000000..8c80f13 --- /dev/null +++ b/src/auth/auth.types.ts @@ -0,0 +1,20 @@ +export interface AuthTokens { + accessToken: string; + expiresIn: number; + refreshToken: string; + refreshExpiresIn: number; +} + +export interface AccessTokenClaims { + sub: string; + email: string; + roles: string[]; + typ: 'access'; +} + +export interface RefreshTokenClaims { + sub: string; + sid: string; + jti: string; + typ: 'refresh'; +} diff --git a/src/auth/auth.utils.ts b/src/auth/auth.utils.ts new file mode 100644 index 0000000..7c0550b --- /dev/null +++ b/src/auth/auth.utils.ts @@ -0,0 +1,30 @@ +import { createHash, randomBytes } from 'crypto'; +import type { SsoProvider } from './dto/sso-provider'; + +export function randomOpaqueToken(size = 32): string { + return randomBytes(size).toString('base64url'); +} + +export function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +export function isLoopbackRedirect(value: string): boolean { + try { + const parsed = new URL(value); + return ( + (parsed.protocol === 'http:' || parsed.protocol === 'https:') && + ['127.0.0.1', 'localhost'].includes(parsed.hostname) + ); + } catch { + return false; + } +} + +export function asProviderName(value: SsoProvider): 'GOOGLE' | 'DISCORD' { + return value === 'google' ? 'GOOGLE' : 'DISCORD'; +} + +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} diff --git a/src/auth/decorators/current-user.decorator.ts b/src/auth/decorators/current-user.decorator.ts index b44c681..b9602e6 100644 --- a/src/auth/decorators/current-user.decorator.ts +++ b/src/auth/decorators/current-user.decorator.ts @@ -8,6 +8,7 @@ export interface AuthenticatedUser { userId: string; email: string; roles?: string[]; + tokenType: 'access'; } /** diff --git a/src/auth/dto/exchange-sso-code-request.dto.ts b/src/auth/dto/exchange-sso-code-request.dto.ts new file mode 100644 index 0000000..55753d2 --- /dev/null +++ b/src/auth/dto/exchange-sso-code-request.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class ExchangeSsoCodeRequestDto { + @ApiProperty({ + description: 'One-time auth code returned to the desktop callback', + }) + @IsString() + @IsNotEmpty() + code!: string; +} diff --git a/src/auth/dto/login-response.dto.ts b/src/auth/dto/login-response.dto.ts index 341ebc0..1457625 100644 --- a/src/auth/dto/login-response.dto.ts +++ b/src/auth/dto/login-response.dto.ts @@ -6,4 +6,10 @@ export class LoginResponseDto { @ApiProperty({ description: 'Access token expiration in seconds' }) expiresIn: number; + + @ApiProperty({ description: 'Opaque refresh token' }) + refreshToken: string; + + @ApiProperty({ description: 'Refresh token expiration in seconds' }) + refreshExpiresIn: number; } diff --git a/src/auth/dto/logout-request.dto.ts b/src/auth/dto/logout-request.dto.ts new file mode 100644 index 0000000..9bbfa87 --- /dev/null +++ b/src/auth/dto/logout-request.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class LogoutRequestDto { + @ApiProperty({ description: 'Refresh token to revoke' }) + @IsString() + @IsNotEmpty() + refreshToken!: string; +} diff --git a/src/auth/dto/refresh-token-request.dto.ts b/src/auth/dto/refresh-token-request.dto.ts new file mode 100644 index 0000000..a9e8416 --- /dev/null +++ b/src/auth/dto/refresh-token-request.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class RefreshTokenRequestDto { + @ApiProperty({ description: 'Refresh token issued by Friendolls' }) + @IsString() + @IsNotEmpty() + refreshToken!: string; +} diff --git a/src/auth/dto/sso-provider.ts b/src/auth/dto/sso-provider.ts new file mode 100644 index 0000000..cb39e63 --- /dev/null +++ b/src/auth/dto/sso-provider.ts @@ -0,0 +1,3 @@ +export const SSO_PROVIDERS = ['google', 'discord'] as const; + +export type SsoProvider = (typeof SSO_PROVIDERS)[number]; diff --git a/src/auth/dto/start-sso-request.dto.ts b/src/auth/dto/start-sso-request.dto.ts new file mode 100644 index 0000000..b636d43 --- /dev/null +++ b/src/auth/dto/start-sso-request.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsNotEmpty, IsString, IsUrl } from 'class-validator'; +import { SSO_PROVIDERS, type SsoProvider } from './sso-provider'; + +export class StartSsoRequestDto { + @ApiProperty({ enum: SSO_PROVIDERS, example: 'google' }) + @IsIn(SSO_PROVIDERS) + provider!: SsoProvider; + + @ApiProperty({ example: 'http://127.0.0.1:43123/callback' }) + @IsString() + @IsNotEmpty() + @IsUrl({ require_protocol: true, require_host: true, require_tld: false }) + redirectUri!: string; +} diff --git a/src/auth/dto/start-sso-response.dto.ts b/src/auth/dto/start-sso-response.dto.ts new file mode 100644 index 0000000..8707297 --- /dev/null +++ b/src/auth/dto/start-sso-response.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class StartSsoResponseDto { + @ApiProperty({ + description: 'Opaque state value echoed back to the desktop callback', + }) + state!: string; +} diff --git a/src/auth/guards/discord-auth.guard.ts b/src/auth/guards/discord-auth.guard.ts new file mode 100644 index 0000000..eca6d7e --- /dev/null +++ b/src/auth/guards/discord-auth.guard.ts @@ -0,0 +1,41 @@ +import { + ExecutionContext, + Injectable, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class DiscordAuthGuard extends AuthGuard('discord') { + constructor(private readonly configService: ConfigService) { + super(); + } + + canActivate(context: ExecutionContext) { + if (!this.isConfigured()) { + throw new ServiceUnavailableException('discord SSO is not configured'); + } + + return super.canActivate(context); + } + + getAuthenticateOptions(context: ExecutionContext) { + const request = context + .switchToHttp() + .getRequest<{ query: { state?: string } }>(); + + return { + state: request.query.state, + prompt: 'consent', + }; + } + + private isConfigured(): boolean { + return Boolean( + this.configService.get('DISCORD_CLIENT_ID') && + this.configService.get('DISCORD_CLIENT_SECRET') && + this.configService.get('DISCORD_CALLBACK_URL'), + ); + } +} diff --git a/src/auth/guards/google-auth.guard.ts b/src/auth/guards/google-auth.guard.ts new file mode 100644 index 0000000..7124de6 --- /dev/null +++ b/src/auth/guards/google-auth.guard.ts @@ -0,0 +1,42 @@ +import { + ExecutionContext, + Injectable, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class GoogleAuthGuard extends AuthGuard('google') { + constructor(private readonly configService: ConfigService) { + super(); + } + + canActivate(context: ExecutionContext) { + if (!this.isConfigured()) { + throw new ServiceUnavailableException('google SSO is not configured'); + } + + return super.canActivate(context); + } + + getAuthenticateOptions(context: ExecutionContext) { + const request = context + .switchToHttp() + .getRequest<{ query: { state?: string } }>(); + + return { + state: request.query.state, + accessType: 'offline', + prompt: 'consent', + }; + } + + private isConfigured(): boolean { + return Boolean( + this.configService.get('GOOGLE_CLIENT_ID') && + this.configService.get('GOOGLE_CLIENT_SECRET') && + this.configService.get('GOOGLE_CALLBACK_URL'), + ); + } +} diff --git a/src/auth/guards/sso-provider.guard.ts b/src/auth/guards/sso-provider.guard.ts new file mode 100644 index 0000000..112bfe8 --- /dev/null +++ b/src/auth/guards/sso-provider.guard.ts @@ -0,0 +1,26 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + BadRequestException, +} from '@nestjs/common'; +import { SSO_PROVIDERS } from '../dto/sso-provider'; + +@Injectable() +export class SsoProviderGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context + .switchToHttp() + .getRequest<{ params: { provider?: string } }>(); + const provider = request.params.provider; + + if ( + !provider || + !SSO_PROVIDERS.includes(provider as (typeof SSO_PROVIDERS)[number]) + ) { + throw new BadRequestException('Unsupported SSO provider'); + } + + return true; + } +} diff --git a/src/auth/services/jwt-verification.service.spec.ts b/src/auth/services/jwt-verification.service.spec.ts index bda4358..00accd6 100644 --- a/src/auth/services/jwt-verification.service.spec.ts +++ b/src/auth/services/jwt-verification.service.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; +import { sign } from 'jsonwebtoken'; import { JwtVerificationService } from './jwt-verification.service'; describe('JwtVerificationService', () => { @@ -76,4 +77,48 @@ describe('JwtVerificationService', () => { expect(token).toBeUndefined(); }); }); + + describe('verifyToken', () => { + it('accepts access tokens', () => { + const token = sign( + { + sub: 'user-1', + email: 'user@example.com', + roles: ['user'], + typ: 'access', + }, + 'test-secret', + { + issuer: 'https://test.com', + audience: 'test-audience', + expiresIn: 60, + algorithm: 'HS256', + }, + ); + + const payload = service.verifyToken(token); + + expect(payload.sub).toBe('user-1'); + expect(payload.typ).toBe('access'); + }); + + it('rejects refresh tokens', () => { + const token = sign( + { + sub: 'user-1', + sid: 'session-1', + typ: 'refresh', + }, + 'test-secret', + { + issuer: 'https://test.com', + audience: 'test-audience', + expiresIn: 60, + algorithm: 'HS256', + }, + ); + + expect(() => service.verifyToken(token)).toThrow('Invalid token type'); + }); + }); }); diff --git a/src/auth/services/jwt-verification.service.ts b/src/auth/services/jwt-verification.service.ts index 1adb374..d37271d 100644 --- a/src/auth/services/jwt-verification.service.ts +++ b/src/auth/services/jwt-verification.service.ts @@ -26,11 +26,17 @@ export class JwtVerificationService { } verifyToken(token: string): JwtPayload { - return verify(token, this.jwtSecret, { + const payload = verify(token, this.jwtSecret, { issuer: this.issuer, audience: this.audience, algorithms: [JWT_ALGORITHM], }) as JwtPayload; + + if (payload.typ !== 'access') { + throw new Error('Invalid token type'); + } + + return payload; } extractToken(handshake: { diff --git a/src/auth/strategies/discord.strategy.ts b/src/auth/strategies/discord.strategy.ts new file mode 100644 index 0000000..9da7f24 --- /dev/null +++ b/src/auth/strategies/discord.strategy.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, type Profile } from 'passport-discord'; +import type { SocialAuthProfile } from '../types/social-auth-profile'; + +@Injectable() +export class DiscordStrategy extends PassportStrategy(Strategy, 'discord') { + private readonly logger = new Logger(DiscordStrategy.name); + + constructor(configService: ConfigService) { + const clientID = configService.get('DISCORD_CLIENT_ID') || ''; + const clientSecret = + configService.get('DISCORD_CLIENT_SECRET') || ''; + const callbackURL = configService.get('DISCORD_CALLBACK_URL') || ''; + + if (!clientID || !clientSecret || !callbackURL) { + super({ + clientID: 'disabled', + clientSecret: 'disabled', + callbackURL: 'http://localhost/disabled', + scope: ['identify'], + }); + this.logger.warn( + 'Discord OAuth strategy disabled: configuration incomplete', + ); + return; + } + + super({ + clientID, + clientSecret, + callbackURL, + scope: ['identify', 'email'], + }); + } + + validate( + _accessToken: string, + _refreshToken: string, + profile: Profile, + done: (error: Error | null, user?: SocialAuthProfile) => void, + ): void { + const primaryEmail = profile.email ?? null; + const user: SocialAuthProfile = { + provider: 'discord', + providerSubject: profile.id, + email: primaryEmail, + emailVerified: Boolean( + (profile as Profile & { verified?: boolean }).verified, + ), + displayName: profile.username || profile.global_name || 'Discord User', + username: profile.username, + picture: profile.avatar + ? `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png` + : undefined, + }; + + done(null, user); + } +} diff --git a/src/auth/strategies/google.strategy.ts b/src/auth/strategies/google.strategy.ts new file mode 100644 index 0000000..3509c62 --- /dev/null +++ b/src/auth/strategies/google.strategy.ts @@ -0,0 +1,60 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, type Profile } from 'passport-google-oauth20'; +import type { SocialAuthProfile } from '../types/social-auth-profile'; + +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { + private readonly logger = new Logger(GoogleStrategy.name); + + constructor(configService: ConfigService) { + const clientID = configService.get('GOOGLE_CLIENT_ID') || ''; + const clientSecret = + configService.get('GOOGLE_CLIENT_SECRET') || ''; + const callbackURL = configService.get('GOOGLE_CALLBACK_URL') || ''; + + if (!clientID || !clientSecret || !callbackURL) { + super({ + clientID: 'disabled', + clientSecret: 'disabled', + callbackURL: 'http://localhost/disabled', + }); + this.logger.warn( + 'Google OAuth strategy disabled: configuration incomplete', + ); + return; + } + + super({ + clientID, + clientSecret, + callbackURL, + scope: ['openid', 'email', 'profile'], + passReqToCallback: false, + }); + } + + validate( + _accessToken: string, + _refreshToken: string, + profile: Profile, + done: (error: Error | null, user?: SocialAuthProfile) => void, + ): void { + const primaryEmail = + profile.emails?.find((item) => item.value)?.value ?? null; + const emailVerified = + profile.emails?.find((item) => item.verified)?.verified ?? false; + const user: SocialAuthProfile = { + provider: 'google', + providerSubject: profile.id, + email: primaryEmail, + emailVerified, + displayName: profile.displayName || profile.username || 'Google User', + username: profile.username, + picture: profile.photos?.[0]?.value, + }; + + done(null, user); + } +} diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts index 575eea1..21ad6dc 100644 --- a/src/auth/strategies/jwt.strategy.ts +++ b/src/auth/strategies/jwt.strategy.ts @@ -10,6 +10,7 @@ export interface JwtPayload { sub: string; // User ID email: string; roles?: string[]; + typ: 'access'; iss: string; aud?: string; exp: number; @@ -59,6 +60,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { userId: string; email: string; roles?: string[]; + tokenType: 'access'; }> { this.logger.debug(`Validating JWT token payload`); this.logger.debug(` Issuer: ${payload.iss}`); @@ -75,9 +77,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) { throw new UnauthorizedException('Invalid token: missing subject'); } + if (payload.typ !== 'access') { + this.logger.warn('JWT token has invalid type'); + throw new UnauthorizedException('Invalid token type'); + } + const user = { userId: payload.sub, email: payload.email, + tokenType: payload.typ, roles: payload.roles && payload.roles.length > 0 ? payload.roles : undefined, }; diff --git a/src/auth/types/social-auth-profile.ts b/src/auth/types/social-auth-profile.ts new file mode 100644 index 0000000..7fd64db --- /dev/null +++ b/src/auth/types/social-auth-profile.ts @@ -0,0 +1,11 @@ +import type { SsoProvider } from '../dto/sso-provider'; + +export interface SocialAuthProfile { + provider: SsoProvider; + providerSubject: string; + email: string | null; + emailVerified: boolean; + displayName: string; + username?: string; + picture?: string; +} diff --git a/src/main.ts b/src/main.ts index f45c7c4..3494653 100644 --- a/src/main.ts +++ b/src/main.ts @@ -38,8 +38,8 @@ async function bootstrap() { .setTitle('Friendolls API') .setDescription( 'API for managing users in Friendolls application.\n\n' + - 'Authentication is handled via Keycloak OpenID Connect.\n' + - 'Users must authenticate via Keycloak to obtain a JWT token.\n\n' + + 'Authentication is handled via Passport.js social sign-in for desktop clients.\n' + + 'Desktop clients exchange one-time SSO codes for Friendolls JWT tokens.\n\n' + 'Include the JWT token in the Authorization header as: `Bearer `', ) .setVersion('1.0') @@ -49,7 +49,7 @@ async function bootstrap() { scheme: 'bearer', bearerFormat: 'JWT', name: 'Authorization', - description: 'Enter JWT token obtained from Keycloak', + description: 'Enter Friendolls JWT access token', in: 'header', }, 'bearer', diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index 286e365..cde782e 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -86,6 +86,25 @@ describe('UsersService', () => { }), }); }); + + it('normalizes email before creating a local user', async () => { + const dto = { + email: ' John@Example.COM ', + name: 'John Doe', + username: 'johndoe', + passwordHash: 'hashed', + }; + + mockPrismaService.user.create.mockResolvedValue(mockUser); + + await service.createLocalUser(dto); + + expect(mockPrismaService.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + email: 'john@example.com', + }), + }); + }); }); describe('findOne', () => { diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 8563973..df1d310 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -9,6 +9,7 @@ import { PrismaService } from '../database/prisma.service'; import { User, Prisma } from '@prisma/client'; import type { UpdateUserDto } from './dto/update-user.dto'; import { UserEvents } from './events/user.events'; +import { normalizeEmail } from '../auth/auth.utils'; export interface CreateLocalUserDto { email: string; @@ -241,7 +242,9 @@ export class UsersService { } async findByEmail(email: string): Promise { - return this.prisma.user.findFirst({ where: { email } }); + return this.prisma.user.findFirst({ + where: { email: normalizeEmail(email) }, + }); } async createLocalUser(createDto: CreateLocalUserDto): Promise { @@ -250,7 +253,7 @@ export class UsersService { return this.prisma.user.create({ data: { - email: createDto.email, + email: normalizeEmail(createDto.email), name: createDto.name, username: createDto.username, passwordHash: createDto.passwordHash, diff --git a/src/ws/state/connection/handler.ts b/src/ws/state/connection/handler.ts index 43c3620..450eabf 100644 --- a/src/ws/state/connection/handler.ts +++ b/src/ws/state/connection/handler.ts @@ -36,6 +36,7 @@ export class ConnectionHandler { client.data.user = { userId: payload.sub, email: payload.email, + tokenType: 'access', roles: payload.roles, }; @@ -81,6 +82,7 @@ export class ConnectionHandler { userTokenData = { userId: payload.sub, email: payload.email, + tokenType: 'access', roles: payload.roles, }; client.data.user = userTokenData; @@ -98,6 +100,10 @@ export class ConnectionHandler { ); } + if (!userTokenData) { + throw new WsException('Unauthorized: No user data found'); + } + const user = await this.usersService.findOne(userTokenData.userId); // 2. Register socket mapping (Redis Write)