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
169 lines
4.7 KiB
TypeScript
169 lines
4.7 KiB
TypeScript
import {
|
|
Injectable,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { Response } from 'express';
|
|
import { User } from '../user/user.entity';
|
|
import { SetSessionDto } from './dto/set-session.dto';
|
|
|
|
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 configService: ConfigService,
|
|
) {
|
|
this.superOauthUrl = this.configService.getOrThrow<string>('SUPER_OAUTH_URL');
|
|
}
|
|
|
|
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: { superOauthId: oauthUser.id },
|
|
});
|
|
|
|
if (!user) {
|
|
user = this.userRepository.create({
|
|
superOauthId: oauthUser.id,
|
|
username: oauthUser.nickname,
|
|
email: oauthUser.email,
|
|
});
|
|
} else {
|
|
user.username = oauthUser.nickname;
|
|
user.email = oauthUser.email;
|
|
}
|
|
|
|
await this.userRepository.save(user);
|
|
|
|
// Cookie httpOnly — session = UUID interne
|
|
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, // 7 jours
|
|
});
|
|
|
|
// 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 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(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;
|
|
}
|
|
}
|