This commit is contained in:
2026-03-17 02:57:36 +08:00
parent a62fae913a
commit 88e458dc43
30 changed files with 1047 additions and 198 deletions

View File

@@ -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"

View File

@@ -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
View File

@@ -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:

View File

@@ -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;

View File

@@ -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
}

View File

@@ -18,7 +18,16 @@ import { DollsModule } from './dolls/dolls.module';
* Returns the validated config.
*/
function validateEnvironment(config: Record<string, any>): Record<string, any> {
const requiredVars = ['JWT_SECRET', 'DATABASE_URL'];
const requiredVars = [
'JWT_SECRET',
'DATABASE_URL',
'GOOGLE_CLIENT_ID',
'GOOGLE_CLIENT_SECRET',
'GOOGLE_CALLBACK_URL',
'DISCORD_CLIENT_ID',
'DISCORD_CLIENT_SECRET',
'DISCORD_CALLBACK_URL',
];
const missingVars = requiredVars.filter((varName) => !config[varName]);

View File

@@ -0,0 +1,7 @@
export const ACCESS_TOKEN_AUDIENCE = 'desktop';
export const ACCESS_TOKEN_TYPE = 'access';
export const REFRESH_TOKEN_TYPE = 'refresh';
export const SSO_STATE_COOKIE = 'fd_sso_state';
export const SSO_REDIRECT_COOKIE = 'fd_sso_redirect';
export const AUTH_CODE_TTL_MS = 5 * 60 * 1000;
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;

View File

@@ -1,30 +1,35 @@
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 +38,101 @@ 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 authorize URL 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 })
@ApiBearerAuth()
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);
}
}

View File

@@ -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 {}

View File

@@ -1,25 +1,40 @@
import {
BadRequestException,
Injectable,
Logger,
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 { Prisma } from '@prisma/client';
import { 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,
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);
@@ -29,112 +44,340 @@ export class AuthService {
private readonly jwtExpiresInSeconds: number;
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',
);
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;
const existing = await this.usersService.findByEmail(email);
if (existing) {
throw new BadRequestException('Email already registered');
startSso(provider: SsoProvider, redirectUri: string): { state: string } {
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> {
const stateClaims = this.verifyStateToken(state, provider);
const user = await this.findOrCreateUserFromProfile(profile);
const authCode = randomOpaqueToken(32);
await this.prisma.$executeRaw`
INSERT INTO auth_exchange_codes (id, provider, code_hash, expires_at, created_at, user_id)
VALUES (${randomOpaqueToken(16)}, ${asProviderName(provider)}::"AuthProvider", ${sha256(authCode)}, ${new Date(Date.now() + AUTH_CODE_TTL_MS)}, ${new Date()}, ${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 exchange = await this.prisma.$queryRaw<
Array<{
id: string;
provider: string;
consumed_at: Date | null;
expires_at: Date;
user_id: string;
email: string;
roles: string[];
}>
>`
SELECT aec.id, aec.provider::text AS provider, aec.consumed_at, aec.expires_at, aec.user_id, u.email, u.roles
FROM auth_exchange_codes aec
INNER JOIN users u ON u.id = aec.user_id
WHERE aec.code_hash = ${codeHash}
LIMIT 1
`;
const matchedExchange = exchange[0];
if (
!matchedExchange ||
matchedExchange.consumed_at ||
matchedExchange.expires_at <= new Date()
) {
throw new UnauthorizedException('Invalid or expired exchange code');
}
await this.prisma.$executeRaw`
UPDATE auth_exchange_codes
SET consumed_at = ${new Date()}
WHERE id = ${matchedExchange.id}
`;
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 sessionRows = await this.prisma.$queryRaw<
Array<{
id: string;
refresh_token_hash: string;
expires_at: Date;
revoked_at: Date | null;
provider: string | null;
user_id: string;
email: string;
roles: string[];
}>
>`
SELECT s.id, s.refresh_token_hash, s.expires_at, s.revoked_at, s.provider::text AS provider, s.user_id, u.email, u.roles
FROM auth_sessions s
INNER JOIN users u ON u.id = s.user_id
WHERE s.id = ${payload.sid}
LIMIT 1
`;
const session = sessionRows[0];
if (
!session ||
session.revoked_at ||
session.expires_at <= new Date() ||
session.refresh_token_hash !== refreshTokenHash
) {
throw new UnauthorizedException('Invalid refresh token');
}
const nextRefreshToken = this.signRefreshToken(session.user_id, session.id);
await this.prisma.$executeRaw`
UPDATE auth_sessions
SET refresh_token_hash = ${sha256(nextRefreshToken)},
expires_at = ${new Date(Date.now() + REFRESH_TOKEN_TTL_MS)},
revoked_at = NULL,
updated_at = ${new Date()}
WHERE id = ${session.id}
`;
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 sessionRows = await this.prisma.$queryRaw<
Array<{ id: string; refresh_token_hash: string }>
>`
SELECT id, refresh_token_hash
FROM auth_sessions
WHERE id = ${payload.sid}
LIMIT 1
`;
const session = sessionRows[0];
if (!session || session.refresh_token_hash !== sha256(refreshToken)) {
return;
}
await this.prisma.$executeRaw`
UPDATE auth_sessions
SET revoked_at = ${new Date()}, updated_at = ${new Date()}
WHERE id = ${session.id}
`;
} catch {
return;
}
}
private async findOrCreateUserFromProfile(profile: SocialAuthProfile) {
if (!profile.email) {
throw new BadRequestException('Provider did not supply an email address');
}
const provider = asProviderName(profile.provider);
const existingIdentityRows = await this.prisma.$queryRaw<
Array<{
identity_id: string;
user_id: string;
email: string;
roles: string[];
}>
>`
SELECT ai.id AS identity_id, u.id AS user_id, u.email, u.roles
FROM auth_identities ai
INNER JOIN users u ON u.id = ai.user_id
WHERE ai.provider = ${provider}::"AuthProvider"
AND ai.provider_subject = ${profile.providerSubject}
LIMIT 1
`;
const existingIdentity = existingIdentityRows[0];
if (existingIdentity) {
await this.prisma.$executeRaw`
UPDATE auth_identities
SET provider_email = ${profile.email},
provider_name = ${profile.displayName},
provider_username = ${profile.username},
provider_picture = ${profile.picture},
email_verified = ${profile.emailVerified},
updated_at = ${new Date()}
WHERE id = ${existingIdentity.identity_id}
`;
const user = await this.prisma.user.update({
where: { id: existingIdentity.user_id },
data: {
email: profile.email,
name: profile.displayName,
username: profile.username,
picture: profile.picture,
lastLoginAt: new Date(),
},
});
return user;
}
return this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
email: profile.email!,
name: profile.displayName,
username: profile.username,
picture: profile.picture,
roles: [],
lastLoginAt: new Date(),
keycloakSub: null,
} satisfies Prisma.UserUncheckedCreateInput,
});
await tx.$executeRaw`
INSERT INTO auth_identities (
id,
provider,
provider_subject,
provider_email,
provider_name,
provider_username,
provider_picture,
email_verified,
created_at,
updated_at,
user_id
) VALUES (
${randomOpaqueToken(16)},
${provider}::"AuthProvider",
${profile.providerSubject},
${profile.email},
${profile.displayName},
${profile.username},
${profile.picture},
${profile.emailVerified},
${new Date()},
${new Date()},
${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?: string,
): 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');
}
await this.prisma.$executeRaw`
INSERT INTO auth_sessions (
id,
provider,
refresh_token_hash,
expires_at,
created_at,
updated_at,
user_id
) VALUES (
${sessionId},
${provider ? Prisma.sql`${provider}::"AuthProvider"` : Prisma.sql`NULL`},
${sha256(refreshToken)},
${new Date(Date.now() + REFRESH_TOKEN_TTL_MS)},
${new Date()},
${new Date()},
${userId}
)
`;
const passwordHash = await hash(newPassword, 12);
await this.usersService.updatePasswordHash(userId, passwordHash);
return {
accessToken: this.signAccessToken(userId, email, roles),
expiresIn: this.jwtExpiresInSeconds,
refreshToken,
refreshExpiresIn: Math.floor(REFRESH_TOKEN_TTL_MS / 1000),
};
}
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,
});
return { accessToken, expiresIn: this.jwtExpiresInSeconds };
}
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 +388,55 @@ 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,
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 {
const payload = verify(state, this.jwtSecret, {
issuer: this.jwtIssuer,
audience: this.jwtAudience,
algorithms: ['HS256'],
}) as SsoStateClaims;
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;
}
return requiredRoles.some((role) => user.roles!.includes(role));
}
private verifyRefreshToken(refreshToken: string): RefreshTokenClaims {
const payload = verify(refreshToken, this.jwtSecret, {
issuer: this.jwtIssuer,
audience: this.jwtAudience,
algorithms: ['HS256'],
}) as RefreshTokenClaims;
hasAllRoles(user: { roles?: string[] }, requiredRoles: string[]): boolean {
if (!user.roles || user.roles.length === 0) {
return false;
if (payload.typ !== 'refresh' || !payload.sid || !payload.sub) {
throw new UnauthorizedException('Invalid refresh token');
}
return requiredRoles.every((role) => user.roles!.includes(role));
return payload;
}
}

19
src/auth/auth.types.ts Normal file
View File

@@ -0,0 +1,19 @@
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;
typ: 'refresh';
}

25
src/auth/auth.utils.ts Normal file
View File

@@ -0,0 +1,25 @@
import { createHash, randomBytes } from 'crypto';
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: string): 'GOOGLE' | 'DISCORD' {
return value.toUpperCase() as 'GOOGLE' | 'DISCORD';
}

View File

@@ -8,6 +8,7 @@ export interface AuthenticatedUser {
userId: string;
email: string;
roles?: string[];
tokenType: 'access';
}
/**

View 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;
}

View File

@@ -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;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,3 @@
export const SSO_PROVIDERS = ['google', 'discord'] as const;
export type SsoProvider = (typeof SSO_PROVIDERS)[number];

View 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;
}

View 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;
}

View File

@@ -0,0 +1,16 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class DiscordAuthGuard extends AuthGuard('discord') {
getAuthenticateOptions(context: ExecutionContext) {
const request = context
.switchToHttp()
.getRequest<{ query: { state?: string } }>();
return {
state: request.query.state,
prompt: 'consent',
};
}
}

View File

@@ -0,0 +1,17 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class GoogleAuthGuard extends AuthGuard('google') {
getAuthenticateOptions(context: ExecutionContext) {
const request = context
.switchToHttp()
.getRequest<{ query: { state?: string } }>();
return {
state: request.query.state,
accessType: 'offline',
prompt: 'consent',
};
}
}

View 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;
}
}

View File

@@ -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: {

View File

@@ -0,0 +1,50 @@
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Strategy, type Profile } from 'passport-discord';
import type { SocialAuthProfile } from '../types/social-auth-profile';
@Injectable()
export class DiscordStrategy extends PassportStrategy(Strategy, 'discord') {
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) {
throw new Error('Discord OAuth configuration is incomplete');
}
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);
}
}

View File

@@ -0,0 +1,48 @@
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Strategy, type Profile } from 'passport-google-oauth20';
import type { SocialAuthProfile } from '../types/social-auth-profile';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
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) {
throw new Error('Google OAuth configuration is incomplete');
}
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 user: SocialAuthProfile = {
provider: 'google',
providerSubject: profile.id,
email: primaryEmail,
emailVerified: primaryEmail !== null,
displayName: profile.displayName || profile.username || 'Google User',
username: profile.username,
picture: profile.photos?.[0]?.value,
};
done(null, user);
}
}

View File

@@ -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,
};

View 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;
}

View File

@@ -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',

View File

@@ -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)