feat: PKCE auth + CI/CD deploy
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 1m2s
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:
@@ -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 }) {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user