feat: PKCE auth + CI/CD deploy
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 1m2s

- Frontend: PKCE flow (oauth.ts, AuthCallback code exchange, 401 interceptor)
- Backend: token introspection via SuperOAuth (no more JWT secret)
- User model: superOauthId (unified) replaces oauthId+provider
- Cookies httpOnly session + refresh token
- POST /auth/refresh endpoint
- Gitea CI workflow (vps-runner pattern)
- DB_SYNC env var for initial schema creation
This commit is contained in:
2026-03-24 13:01:14 +01:00
parent c1bf793234
commit 8c6777c980
61 changed files with 5850 additions and 66 deletions

View File

@@ -4,10 +4,11 @@ import {
Get,
Body,
Res,
Req,
UseGuards,
HttpCode,
HttpStatus,
Req,
UnauthorizedException,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { Response, Request } from 'express';
@@ -30,6 +31,20 @@ export class AuthController {
return this.authService.setSession(dto, res);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { ttl: 60_000, limit: 10 } })
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const refreshToken = (req.signedCookies as Record<string, string>)?.refresh_token;
if (!refreshToken) {
throw new UnauthorizedException('Pas de refresh token');
}
return this.authService.refreshSession(res, refreshToken);
}
@Get('me')
@UseGuards(AuthGuard)
async getMe(@Req() req: Request & { user: User }) {

View File

@@ -1,23 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { User } from '../user/user.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AuthGuard } from './guards/auth.guard';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('SUPER_OAUTH_JWT_SECRET'),
}),
}),
],
imports: [TypeOrmModule.forFeature([User])],
controllers: [AuthController],
providers: [AuthService, AuthGuard],
exports: [AuthGuard, TypeOrmModule],

View File

@@ -1,9 +1,7 @@
import {
Injectable,
UnauthorizedException,
InternalServerErrorException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@@ -11,62 +9,54 @@ import { Response } from 'express';
import { User } from '../user/user.entity';
import { SetSessionDto } from './dto/set-session.dto';
// Payload émis par SuperOAuth
interface SuperOAuthPayload {
sub: string; // ID provider (Twitch ID, Discord ID…)
provider: string; // 'twitch' | 'discord' | 'google' | 'github'
username: string;
avatar_url?: string;
iat: number;
exp: number;
interface SuperOAuthUser {
id: string;
tenantId: string;
email: string | null;
nickname: string;
isActive: boolean;
linkedProviders: string[];
}
const COOKIE_NAME = 'session';
const REFRESH_COOKIE_NAME = 'refresh_token';
@Injectable()
export class AuthService {
private readonly superOauthUrl: string;
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
) {
this.superOauthUrl = this.configService.getOrThrow<string>('SUPER_OAUTH_URL');
}
async setSession(dto: SetSessionDto, res: Response): Promise<Omit<User, 'oauthId'>> {
let payload: SuperOAuthPayload;
try {
payload = await this.jwtService.verifyAsync<SuperOAuthPayload>(dto.jwt, {
secret: this.configService.get<string>('SUPER_OAUTH_JWT_SECRET'),
});
} catch {
throw new UnauthorizedException('JWT SuperOAuth invalide ou expiré');
}
if (!payload.sub || !payload.provider || !payload.username) {
throw new UnauthorizedException('Payload JWT incomplet');
}
async setSession(dto: SetSessionDto, res: Response): Promise<Omit<User, 'superOauthId'>> {
const oauthUser = await this.introspectToken(dto.token);
// Upsert user
let user = await this.userRepository.findOne({
where: { oauthId: payload.sub, provider: payload.provider },
where: { superOauthId: oauthUser.id },
});
if (!user) {
user = this.userRepository.create({
oauthId: payload.sub,
provider: payload.provider,
username: payload.username,
avatarUrl: payload.avatar_url ?? null,
superOauthId: oauthUser.id,
username: oauthUser.nickname,
email: oauthUser.email,
});
} else {
user.username = payload.username;
user.avatarUrl = payload.avatar_url ?? null;
user.username = oauthUser.nickname;
user.email = oauthUser.email;
}
await this.userRepository.save(user);
// Cookie httpOnly signé — valeur = UUID interne
// Cookie httpOnly — session = UUID interne
const isProduction = this.configService.get('NODE_ENV') === 'production';
res.cookie('session', user.id, {
res.cookie(COOKIE_NAME, user.id, {
httpOnly: true,
signed: true,
secure: isProduction,
@@ -74,16 +64,105 @@ export class AuthService {
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours
});
const { oauthId: _, ...safeUser } = user;
// Refresh token cookie si fourni
if (dto.refreshToken) {
res.cookie(REFRESH_COOKIE_NAME, dto.refreshToken, {
httpOnly: true,
signed: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 jours
});
}
const { superOauthId: _, ...safeUser } = user;
return safeUser;
}
async getMe(user: User): Promise<Omit<User, 'oauthId'>> {
const { oauthId: _, ...safeUser } = user;
async refreshSession(res: Response, refreshToken: string): Promise<{ success: boolean }> {
// Exchange refresh token for new access token via SuperOAuth
const response = await fetch(`${this.superOauthUrl}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
}).toString(),
});
if (!response.ok) {
throw new UnauthorizedException('Refresh token invalide ou expiré');
}
const data = await response.json();
if (!data.access_token) {
throw new UnauthorizedException('Refresh échoué — pas de token');
}
// Validate the new access token to get user data
const oauthUser = await this.introspectToken(data.access_token);
const user = await this.userRepository.findOne({
where: { superOauthId: oauthUser.id },
});
if (!user) {
throw new UnauthorizedException('Utilisateur introuvable après refresh');
}
// Set new cookies
const isProduction = this.configService.get('NODE_ENV') === 'production';
res.cookie(COOKIE_NAME, user.id, {
httpOnly: true,
signed: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
if (data.refresh_token) {
res.cookie(REFRESH_COOKIE_NAME, data.refresh_token, {
httpOnly: true,
signed: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
}
return { success: true };
}
async getMe(user: User): Promise<Omit<User, 'superOauthId'>> {
const { superOauthId: _, ...safeUser } = user;
return safeUser;
}
logout(res: Response): void {
res.clearCookie('session');
res.clearCookie(COOKIE_NAME);
res.clearCookie(REFRESH_COOKIE_NAME);
}
private async introspectToken(token: string): Promise<SuperOAuthUser> {
const response = await fetch(`${this.superOauthUrl}/api/v1/auth/token/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
throw new UnauthorizedException('Token SuperOAuth invalide');
}
const data = await response.json();
if (!data.data?.valid || !data.data.user) {
throw new UnauthorizedException('Token SuperOAuth invalide ou expiré');
}
if (!data.data.user.isActive) {
throw new UnauthorizedException('Compte SuperOAuth désactivé');
}
return data.data.user as SuperOAuthUser;
}
}

View File

@@ -1,7 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class SetSessionDto {
@IsString()
@IsNotEmpty()
jwt: string;
token: string;
@IsString()
@IsOptional()
refreshToken?: string;
}