SSO auth (1)
This commit is contained in:
10
.env.example
10
.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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
90
pnpm-lock.yaml
generated
90
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -33,9 +33,32 @@ function validateEnvironment(config: Record<string, any>): Record<string, any> {
|
||||
throw new Error('PORT must be a valid number');
|
||||
}
|
||||
|
||||
validateOptionalProvider(config, 'GOOGLE');
|
||||
validateOptionalProvider(config, 'DISCORD');
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function validateOptionalProvider(
|
||||
config: Record<string, any>,
|
||||
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
|
||||
*
|
||||
|
||||
4
src/auth/auth.constants.ts
Normal file
4
src/auth/auth.constants.ts
Normal file
@@ -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;
|
||||
@@ -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<void> {}
|
||||
|
||||
@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<void> {
|
||||
await this.finishSso('google', request, response, state);
|
||||
}
|
||||
|
||||
@Get('sso/discord')
|
||||
@UseGuards(DiscordAuthGuard)
|
||||
@ApiOperation({ summary: 'Begin Discord sign-in' })
|
||||
async startDiscord(): Promise<void> {}
|
||||
|
||||
@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<void> {
|
||||
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<LoginResponseDto> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.authService.changePassword(
|
||||
user.userId,
|
||||
body.oldPassword,
|
||||
body.newPassword,
|
||||
);
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid or expired exchange code' })
|
||||
async exchangeSsoCode(
|
||||
@Body() body: ExchangeSsoCodeRequestDto,
|
||||
): Promise<LoginResponseDto> {
|
||||
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<LoginResponseDto> {
|
||||
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<void> {
|
||||
await this.authService.logout(body.refreshToken);
|
||||
}
|
||||
|
||||
private async finishSso(
|
||||
provider: 'google' | 'discord',
|
||||
request: Request,
|
||||
response: Response,
|
||||
state?: string,
|
||||
): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
428
src/auth/auth.service.spec.ts
Normal file
428
src/auth/auth.service.spec.ts
Normal file
@@ -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<string, string | undefined> = {
|
||||
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<string, unknown> = {}) =>
|
||||
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>(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<string, string | undefined> = {
|
||||
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' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string>('JWT_SECRET') || '';
|
||||
if (!this.jwtSecret) {
|
||||
throw new Error('JWT_SECRET must be configured');
|
||||
}
|
||||
this.jwtIssuer =
|
||||
this.configService.get<string>('JWT_ISSUER') || 'friendolls';
|
||||
this.jwtAudience = this.configService.get<string>('JWT_AUDIENCE');
|
||||
this.jwtExpiresInSeconds = Number(
|
||||
this.configService.get<string>('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<User> {
|
||||
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<string> {
|
||||
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<AuthTokens> {
|
||||
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<AuthTokens> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const user = await this.usersService.findOne(userId);
|
||||
email: string,
|
||||
roles: string[],
|
||||
provider?: 'GOOGLE' | 'DISCORD',
|
||||
): Promise<AuthTokens> {
|
||||
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<boolean> {
|
||||
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<string>(`${provider}_CLIENT_ID`) &&
|
||||
this.configService.get<string>(`${provider}_CLIENT_SECRET`) &&
|
||||
this.configService.get<string>(`${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<boolean> {
|
||||
const rows = await this.prisma.$queryRaw<Array<{ id: string }>>`
|
||||
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<boolean> {
|
||||
const rows = await this.prisma.$queryRaw<Array<{ id: string }>>`
|
||||
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<void> {
|
||||
await this.prisma.$queryRaw<Array<{ id: string }>>`
|
||||
UPDATE auth_sessions
|
||||
SET revoked_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = ${sessionId}
|
||||
AND revoked_at IS NULL
|
||||
RETURNING id
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
20
src/auth/auth.types.ts
Normal file
20
src/auth/auth.types.ts
Normal file
@@ -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';
|
||||
}
|
||||
30
src/auth/auth.utils.ts
Normal file
30
src/auth/auth.utils.ts
Normal file
@@ -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();
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface AuthenticatedUser {
|
||||
userId: string;
|
||||
email: string;
|
||||
roles?: string[];
|
||||
tokenType: 'access';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
11
src/auth/dto/exchange-sso-code-request.dto.ts
Normal file
11
src/auth/dto/exchange-sso-code-request.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
9
src/auth/dto/logout-request.dto.ts
Normal file
9
src/auth/dto/logout-request.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
9
src/auth/dto/refresh-token-request.dto.ts
Normal file
9
src/auth/dto/refresh-token-request.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
3
src/auth/dto/sso-provider.ts
Normal file
3
src/auth/dto/sso-provider.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const SSO_PROVIDERS = ['google', 'discord'] as const;
|
||||
|
||||
export type SsoProvider = (typeof SSO_PROVIDERS)[number];
|
||||
15
src/auth/dto/start-sso-request.dto.ts
Normal file
15
src/auth/dto/start-sso-request.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
8
src/auth/dto/start-sso-response.dto.ts
Normal file
8
src/auth/dto/start-sso-response.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
41
src/auth/guards/discord-auth.guard.ts
Normal file
41
src/auth/guards/discord-auth.guard.ts
Normal file
@@ -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<string>('DISCORD_CLIENT_ID') &&
|
||||
this.configService.get<string>('DISCORD_CLIENT_SECRET') &&
|
||||
this.configService.get<string>('DISCORD_CALLBACK_URL'),
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/auth/guards/google-auth.guard.ts
Normal file
42
src/auth/guards/google-auth.guard.ts
Normal file
@@ -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<string>('GOOGLE_CLIENT_ID') &&
|
||||
this.configService.get<string>('GOOGLE_CLIENT_SECRET') &&
|
||||
this.configService.get<string>('GOOGLE_CALLBACK_URL'),
|
||||
);
|
||||
}
|
||||
}
|
||||
26
src/auth/guards/sso-provider.guard.ts
Normal file
26
src/auth/guards/sso-provider.guard.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
61
src/auth/strategies/discord.strategy.ts
Normal file
61
src/auth/strategies/discord.strategy.ts
Normal file
@@ -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<string>('DISCORD_CLIENT_ID') || '';
|
||||
const clientSecret =
|
||||
configService.get<string>('DISCORD_CLIENT_SECRET') || '';
|
||||
const callbackURL = configService.get<string>('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);
|
||||
}
|
||||
}
|
||||
60
src/auth/strategies/google.strategy.ts
Normal file
60
src/auth/strategies/google.strategy.ts
Normal file
@@ -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<string>('GOOGLE_CLIENT_ID') || '';
|
||||
const clientSecret =
|
||||
configService.get<string>('GOOGLE_CLIENT_SECRET') || '';
|
||||
const callbackURL = configService.get<string>('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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
11
src/auth/types/social-auth-profile.ts
Normal file
11
src/auth/types/social-auth-profile.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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 <token>`',
|
||||
)
|
||||
.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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<User | null> {
|
||||
return this.prisma.user.findFirst({ where: { email } });
|
||||
return this.prisma.user.findFirst({
|
||||
where: { email: normalizeEmail(email) },
|
||||
});
|
||||
}
|
||||
|
||||
async createLocalUser(createDto: CreateLocalUserDto): Promise<User> {
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user