- ET-SAAS-015-oauth.md: Changed Estado from Propuesto to Implementado - ET-SAAS-016-analytics.md: Changed Estado from Propuesto to Implementado Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
759 lines
20 KiB
Markdown
759 lines
20 KiB
Markdown
---
|
|
id: "ET-SAAS-015"
|
|
title: "Especificacion Tecnica OAuth 2.0"
|
|
type: "TechnicalSpec"
|
|
status: "Implemented"
|
|
priority: "P1"
|
|
module: "oauth"
|
|
version: "1.0.0"
|
|
created_date: "2026-01-24"
|
|
updated_date: "2026-01-24"
|
|
story_points: 5
|
|
---
|
|
|
|
# ET-SAAS-015: Especificacion Tecnica - OAuth 2.0 Endpoints
|
|
|
|
## Metadata
|
|
- **Codigo:** ET-SAAS-015
|
|
- **Modulo:** Auth (extension OAuth)
|
|
- **Version:** 1.0.0
|
|
- **Estado:** Implementado
|
|
- **Fecha:** 2026-01-24
|
|
- **Basado en:** RFC 6749, Passport.js strategies
|
|
|
|
---
|
|
|
|
## 1. Resumen Ejecutivo
|
|
|
|
### 1.1 Estado Actual
|
|
|
|
El modulo de autenticacion actual incluye:
|
|
|
|
| Componente | Estado | Limitaciones |
|
|
|------------|--------|--------------|
|
|
| JWT Auth | Implementado | Funcional |
|
|
| Refresh Tokens | Implementado | Funcional |
|
|
| Email/Password | Implementado | Funcional |
|
|
| OAuth Connections (DDL) | Implementado | Tabla existe |
|
|
| OAuth Endpoints | NO | Sin implementacion |
|
|
| Passport Strategies | NO | Sin implementacion |
|
|
| OAuth UI | NO | Sin botones/callbacks |
|
|
|
|
### 1.2 Propuesta v1.0
|
|
|
|
Sistema OAuth 2.0 completo con:
|
|
|
|
- **4 Proveedores**: Google, Microsoft, GitHub, Apple
|
|
- **Passport.js**: Strategies oficiales para cada proveedor
|
|
- **Flujos soportados**: Authorization Code con PKCE
|
|
- **Vinculacion de cuentas**: Usuarios existentes pueden vincular OAuth
|
|
- **CSRF Protection**: State parameter con validacion
|
|
- **Token encryption**: Access/refresh tokens encriptados en BD
|
|
|
|
---
|
|
|
|
## 2. Analisis Comparativo
|
|
|
|
### 2.1 Proveedores OAuth
|
|
|
|
| Proveedor | Strategy NPM | Scopes Requeridos | Notas |
|
|
|-----------|--------------|-------------------|-------|
|
|
| Google | passport-google-oauth20 | email, profile | Mas comun |
|
|
| Microsoft | passport-microsoft | user.read | Azure AD |
|
|
| GitHub | passport-github2 | user:email | Developers |
|
|
| Apple | passport-apple | email, name | iOS users |
|
|
|
|
### 2.2 Flujo OAuth Comparado
|
|
|
|
| Paso | Google | Microsoft | GitHub | Apple |
|
|
|------|--------|-----------|--------|-------|
|
|
| Auth URL | accounts.google.com | login.microsoftonline.com | github.com | appleid.apple.com |
|
|
| Token URL | oauth2.googleapis.com | login.microsoftonline.com | github.com | appleid.apple.com |
|
|
| User Info | userinfo | graph.microsoft.com | api.github.com | Token JWT |
|
|
| Refresh | Si | Si | No | Si |
|
|
|
|
---
|
|
|
|
## 3. Arquitectura Propuesta
|
|
|
|
### 3.1 Diagrama de Componentes
|
|
|
|
```
|
|
+------------------+ +-------------------+ +------------------+
|
|
| Frontend | | OAuthController | | Passport.js |
|
|
| OAuth Buttons |---->| /auth/oauth/:prov |---->| Strategies |
|
|
+------------------+ +-------------------+ +------------------+
|
|
|
|
|
+--------------------------------+
|
|
|
|
|
v
|
|
+------------------+ +-------------------+ +------------------+
|
|
| Google/MS/GH/AP |---->| Callback Handler |---->| OAuthService |
|
|
| Auth Servers | | /callback | | (business logic) |
|
|
+------------------+ +-------------------+ +------------------+
|
|
|
|
|
v
|
|
+-------------------+
|
|
| User + Connection |
|
|
| (DB operations) |
|
|
+-------------------+
|
|
```
|
|
|
|
### 3.2 Flujo Detallado
|
|
|
|
```
|
|
INICIO OAUTH:
|
|
1. Usuario click "Login con Google"
|
|
2. Frontend GET /auth/oauth/google?redirect_uri=...
|
|
3. Controller invoca passport.authenticate('google')
|
|
4. Passport genera URL con state + code_verifier
|
|
5. Redirect 302 a Google
|
|
|
|
CALLBACK OAUTH:
|
|
6. Usuario autoriza en Google
|
|
7. Google redirige a /auth/oauth/google/callback?code=...&state=...
|
|
8. Passport valida state (CSRF)
|
|
9. Passport intercambia code por tokens
|
|
10. Passport obtiene perfil de usuario
|
|
11. OAuthService.handleOAuthCallback(profile, tokens)
|
|
a. Buscar OAuthConnection por provider_user_id
|
|
b. Si existe: obtener User vinculado
|
|
c. Si no existe: crear User + Tenant + OAuthConnection
|
|
12. Generar JWT tokens de sesion
|
|
13. Redirect a frontend con tokens
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Modelo de Datos
|
|
|
|
### 4.1 Tabla Existente: auth.oauth_connections
|
|
|
|
```sql
|
|
-- Ya existe en DDL, sin cambios necesarios
|
|
CREATE TABLE auth.oauth_connections (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
|
|
|
|
-- Provider info
|
|
provider auth.oauth_provider NOT NULL,
|
|
provider_user_id VARCHAR(255) NOT NULL,
|
|
|
|
-- Tokens (encriptados)
|
|
access_token TEXT,
|
|
refresh_token TEXT,
|
|
token_expires_at TIMESTAMPTZ,
|
|
|
|
-- Profile data
|
|
profile_data JSONB DEFAULT '{}',
|
|
|
|
-- Timestamps
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
-- Constraints
|
|
UNIQUE(provider, provider_user_id),
|
|
UNIQUE(user_id, provider)
|
|
);
|
|
|
|
-- RLS ya configurado
|
|
```
|
|
|
|
### 4.2 Enum Existente
|
|
|
|
```sql
|
|
CREATE TYPE auth.oauth_provider AS ENUM (
|
|
'google',
|
|
'microsoft',
|
|
'github',
|
|
'apple'
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Implementacion Backend
|
|
|
|
### 5.1 Estructura de Archivos
|
|
|
|
```
|
|
backend/src/modules/auth/
|
|
├── strategies/
|
|
│ ├── google.strategy.ts
|
|
│ ├── microsoft.strategy.ts
|
|
│ ├── github.strategy.ts
|
|
│ └── apple.strategy.ts
|
|
├── controllers/
|
|
│ └── oauth.controller.ts
|
|
├── services/
|
|
│ └── oauth.service.ts
|
|
├── entities/
|
|
│ └── oauth-connection.entity.ts
|
|
└── dto/
|
|
├── oauth-callback.dto.ts
|
|
└── oauth-connection.dto.ts
|
|
```
|
|
|
|
### 5.2 Entity: OAuthConnection
|
|
|
|
```typescript
|
|
@Entity('oauth_connections', { schema: 'auth' })
|
|
export class OAuthConnection {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column('uuid')
|
|
userId: string;
|
|
|
|
@ManyToOne(() => User)
|
|
@JoinColumn({ name: 'user_id' })
|
|
user: User;
|
|
|
|
@Column({
|
|
type: 'enum',
|
|
enum: OAuthProvider,
|
|
enumName: 'oauth_provider',
|
|
})
|
|
provider: OAuthProvider;
|
|
|
|
@Column({ name: 'provider_user_id' })
|
|
providerUserId: string;
|
|
|
|
@Column({ name: 'access_token', nullable: true })
|
|
accessToken: string;
|
|
|
|
@Column({ name: 'refresh_token', nullable: true })
|
|
refreshToken: string;
|
|
|
|
@Column({ name: 'token_expires_at', nullable: true })
|
|
tokenExpiresAt: Date;
|
|
|
|
@Column({ name: 'profile_data', type: 'jsonb', default: {} })
|
|
profileData: Record<string, any>;
|
|
|
|
@CreateDateColumn({ name: 'created_at' })
|
|
createdAt: Date;
|
|
|
|
@UpdateDateColumn({ name: 'updated_at' })
|
|
updatedAt: Date;
|
|
}
|
|
```
|
|
|
|
### 5.3 Service: OAuthService
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class OAuthService {
|
|
constructor(
|
|
@InjectRepository(OAuthConnection)
|
|
private readonly oauthRepo: Repository<OAuthConnection>,
|
|
private readonly usersService: UsersService,
|
|
private readonly tenantsService: TenantsService,
|
|
private readonly authService: AuthService,
|
|
private readonly encryptionService: EncryptionService,
|
|
) {}
|
|
|
|
async handleOAuthCallback(
|
|
provider: OAuthProvider,
|
|
profile: OAuthProfile,
|
|
tokens: OAuthTokens,
|
|
): Promise<AuthResult> {
|
|
// 1. Buscar conexion existente
|
|
let connection = await this.oauthRepo.findOne({
|
|
where: { provider, providerUserId: profile.id },
|
|
relations: ['user'],
|
|
});
|
|
|
|
let user: User;
|
|
let isNewUser = false;
|
|
|
|
if (connection) {
|
|
// Usuario existente con OAuth
|
|
user = connection.user;
|
|
|
|
// Actualizar tokens
|
|
await this.updateConnectionTokens(connection, tokens);
|
|
} else {
|
|
// Nuevo usuario via OAuth
|
|
const result = await this.createUserFromOAuth(provider, profile, tokens);
|
|
user = result.user;
|
|
connection = result.connection;
|
|
isNewUser = true;
|
|
}
|
|
|
|
// Generar JWT
|
|
const jwtTokens = await this.authService.generateTokens(user);
|
|
|
|
return {
|
|
...jwtTokens,
|
|
user: this.usersService.toDto(user),
|
|
isNewUser,
|
|
};
|
|
}
|
|
|
|
async linkOAuthToUser(
|
|
userId: string,
|
|
provider: OAuthProvider,
|
|
profile: OAuthProfile,
|
|
tokens: OAuthTokens,
|
|
): Promise<OAuthConnection> {
|
|
// Verificar que no existe otra cuenta con ese provider_user_id
|
|
const existing = await this.oauthRepo.findOne({
|
|
where: { provider, providerUserId: profile.id },
|
|
});
|
|
|
|
if (existing && existing.userId !== userId) {
|
|
throw new ConflictException(
|
|
'This OAuth account is already linked to another user',
|
|
);
|
|
}
|
|
|
|
// Verificar que el usuario no tiene ya este provider
|
|
const userConnection = await this.oauthRepo.findOne({
|
|
where: { userId, provider },
|
|
});
|
|
|
|
if (userConnection) {
|
|
throw new ConflictException(
|
|
`User already has a ${provider} account linked`,
|
|
);
|
|
}
|
|
|
|
// Crear conexion
|
|
return this.oauthRepo.save({
|
|
userId,
|
|
provider,
|
|
providerUserId: profile.id,
|
|
accessToken: this.encryptionService.encrypt(tokens.accessToken),
|
|
refreshToken: tokens.refreshToken
|
|
? this.encryptionService.encrypt(tokens.refreshToken)
|
|
: null,
|
|
tokenExpiresAt: tokens.expiresAt,
|
|
profileData: profile.raw,
|
|
});
|
|
}
|
|
|
|
async unlinkOAuth(userId: string, provider: OAuthProvider): Promise<void> {
|
|
// Verificar que el usuario tiene otra forma de login
|
|
const user = await this.usersService.findById(userId);
|
|
const connections = await this.oauthRepo.count({ where: { userId } });
|
|
|
|
if (!user.passwordHash && connections <= 1) {
|
|
throw new BadRequestException(
|
|
'Cannot unlink last OAuth provider without password set',
|
|
);
|
|
}
|
|
|
|
await this.oauthRepo.delete({ userId, provider });
|
|
}
|
|
|
|
async getConnections(userId: string): Promise<OAuthConnectionDto[]> {
|
|
const connections = await this.oauthRepo.find({
|
|
where: { userId },
|
|
select: ['id', 'provider', 'profileData', 'createdAt'],
|
|
});
|
|
|
|
return connections.map((c) => ({
|
|
provider: c.provider,
|
|
email: c.profileData.email,
|
|
linkedAt: c.createdAt,
|
|
}));
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5.4 Strategy: Google
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
|
constructor(private readonly configService: ConfigService) {
|
|
super({
|
|
clientID: configService.get('GOOGLE_CLIENT_ID'),
|
|
clientSecret: configService.get('GOOGLE_CLIENT_SECRET'),
|
|
callbackURL: configService.get('GOOGLE_CALLBACK_URL'),
|
|
scope: ['email', 'profile'],
|
|
state: true,
|
|
});
|
|
}
|
|
|
|
async validate(
|
|
accessToken: string,
|
|
refreshToken: string,
|
|
profile: GoogleProfile,
|
|
done: VerifyCallback,
|
|
): Promise<void> {
|
|
const oauthProfile: OAuthProfile = {
|
|
id: profile.id,
|
|
email: profile.emails?.[0]?.value,
|
|
name: profile.displayName,
|
|
firstName: profile.name?.givenName,
|
|
lastName: profile.name?.familyName,
|
|
picture: profile.photos?.[0]?.value,
|
|
raw: profile._json,
|
|
};
|
|
|
|
const tokens: OAuthTokens = {
|
|
accessToken,
|
|
refreshToken,
|
|
expiresAt: new Date(Date.now() + 3600 * 1000), // 1h default
|
|
};
|
|
|
|
done(null, { profile: oauthProfile, tokens });
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5.5 Controller: OAuthController
|
|
|
|
```typescript
|
|
@Controller('auth/oauth')
|
|
@ApiTags('OAuth')
|
|
export class OAuthController {
|
|
constructor(private readonly oauthService: OAuthService) {}
|
|
|
|
@Get(':provider')
|
|
@ApiOperation({ summary: 'Initiate OAuth flow' })
|
|
@UseGuards(AuthGuard('google')) // Dynamic based on provider
|
|
initiateOAuth(@Param('provider') provider: OAuthProvider): void {
|
|
// Passport handles redirect
|
|
}
|
|
|
|
@Get(':provider/callback')
|
|
@ApiOperation({ summary: 'OAuth callback' })
|
|
@UseGuards(AuthGuard('google')) // Dynamic based on provider
|
|
async oauthCallback(
|
|
@Param('provider') provider: OAuthProvider,
|
|
@Req() req: Request,
|
|
@Res() res: Response,
|
|
): Promise<void> {
|
|
const { profile, tokens } = req.user as OAuthCallbackData;
|
|
|
|
const result = await this.oauthService.handleOAuthCallback(
|
|
provider,
|
|
profile,
|
|
tokens,
|
|
);
|
|
|
|
// Redirect to frontend with tokens
|
|
const frontendUrl = this.configService.get('FRONTEND_URL');
|
|
const params = new URLSearchParams({
|
|
access_token: result.accessToken,
|
|
refresh_token: result.refreshToken,
|
|
is_new_user: result.isNewUser.toString(),
|
|
});
|
|
|
|
res.redirect(`${frontendUrl}/auth/callback?${params}`);
|
|
}
|
|
|
|
@Post('link/:provider')
|
|
@ApiOperation({ summary: 'Link OAuth to existing account' })
|
|
@UseGuards(JwtAuthGuard)
|
|
@UseGuards(AuthGuard('google')) // Dynamic
|
|
async linkOAuth(
|
|
@Param('provider') provider: OAuthProvider,
|
|
@GetUser() user: User,
|
|
@Req() req: Request,
|
|
): Promise<OAuthConnectionDto> {
|
|
const { profile, tokens } = req.user as OAuthCallbackData;
|
|
|
|
const connection = await this.oauthService.linkOAuthToUser(
|
|
user.id,
|
|
provider,
|
|
profile,
|
|
tokens,
|
|
);
|
|
|
|
return this.oauthService.toConnectionDto(connection);
|
|
}
|
|
|
|
@Delete(':provider')
|
|
@ApiOperation({ summary: 'Unlink OAuth provider' })
|
|
@UseGuards(JwtAuthGuard)
|
|
async unlinkOAuth(
|
|
@Param('provider') provider: OAuthProvider,
|
|
@GetUser() user: User,
|
|
): Promise<void> {
|
|
await this.oauthService.unlinkOAuth(user.id, provider);
|
|
}
|
|
|
|
@Get('connections')
|
|
@ApiOperation({ summary: 'List OAuth connections' })
|
|
@UseGuards(JwtAuthGuard)
|
|
async getConnections(@GetUser() user: User): Promise<OAuthConnectionDto[]> {
|
|
return this.oauthService.getConnections(user.id);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Implementacion Frontend
|
|
|
|
### 6.1 Componente: OAuthButtons
|
|
|
|
```tsx
|
|
interface OAuthButtonsProps {
|
|
mode: 'login' | 'register' | 'link';
|
|
onSuccess?: () => void;
|
|
}
|
|
|
|
export const OAuthButtons: React.FC<OAuthButtonsProps> = ({ mode, onSuccess }) => {
|
|
const { initiateOAuth, isLoading } = useOAuthLogin();
|
|
|
|
const providers = [
|
|
{ id: 'google', name: 'Google', icon: GoogleIcon, color: '#DB4437' },
|
|
{ id: 'microsoft', name: 'Microsoft', icon: MicrosoftIcon, color: '#00A4EF' },
|
|
{ id: 'github', name: 'GitHub', icon: GitHubIcon, color: '#333' },
|
|
{ id: 'apple', name: 'Apple', icon: AppleIcon, color: '#000' },
|
|
];
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{providers.map((provider) => (
|
|
<button
|
|
key={provider.id}
|
|
onClick={() => initiateOAuth(provider.id)}
|
|
disabled={isLoading}
|
|
className="flex items-center justify-center gap-2 px-4 py-2 border rounded-lg hover:bg-gray-50"
|
|
>
|
|
<provider.icon className="w-5 h-5" />
|
|
<span>
|
|
{mode === 'link' ? 'Link' : 'Continue'} with {provider.name}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### 6.2 Hook: useOAuthLogin
|
|
|
|
```tsx
|
|
export const useOAuthLogin = () => {
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
const initiateOAuth = useCallback((provider: string) => {
|
|
setIsLoading(true);
|
|
|
|
const currentUrl = window.location.href;
|
|
const redirectUri = encodeURIComponent(currentUrl);
|
|
|
|
// Redirect to backend OAuth endpoint
|
|
window.location.href = `/api/auth/oauth/${provider}?redirect_uri=${redirectUri}`;
|
|
}, []);
|
|
|
|
return { initiateOAuth, isLoading };
|
|
};
|
|
```
|
|
|
|
### 6.3 Pagina: OAuthCallback
|
|
|
|
```tsx
|
|
export const OAuthCallbackPage: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const { setAuth } = useAuthStore();
|
|
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const accessToken = params.get('access_token');
|
|
const refreshToken = params.get('refresh_token');
|
|
const isNewUser = params.get('is_new_user') === 'true';
|
|
const error = params.get('error');
|
|
|
|
if (error) {
|
|
toast.error(`OAuth failed: ${error}`);
|
|
navigate('/login');
|
|
return;
|
|
}
|
|
|
|
if (accessToken && refreshToken) {
|
|
setAuth({ accessToken, refreshToken });
|
|
|
|
if (isNewUser) {
|
|
navigate('/onboarding');
|
|
} else {
|
|
navigate('/dashboard');
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
return <LoadingSpinner message="Completing login..." />;
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Seguridad
|
|
|
|
### 7.1 CSRF Protection
|
|
|
|
```typescript
|
|
// State parameter generado con crypto
|
|
const generateState = (): string => {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
};
|
|
|
|
// Almacenar en session/cookie
|
|
const storeState = (res: Response, state: string): void => {
|
|
res.cookie('oauth_state', state, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
maxAge: 600000, // 10 min
|
|
sameSite: 'lax',
|
|
});
|
|
};
|
|
|
|
// Validar en callback
|
|
const validateState = (req: Request, state: string): boolean => {
|
|
const storedState = req.cookies.oauth_state;
|
|
return storedState === state;
|
|
};
|
|
```
|
|
|
|
### 7.2 Token Encryption
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class EncryptionService {
|
|
private readonly algorithm = 'aes-256-gcm';
|
|
private readonly key: Buffer;
|
|
|
|
constructor(configService: ConfigService) {
|
|
this.key = Buffer.from(configService.get('ENCRYPTION_KEY'), 'hex');
|
|
}
|
|
|
|
encrypt(text: string): string {
|
|
const iv = crypto.randomBytes(16);
|
|
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
|
|
const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
|
|
}
|
|
|
|
decrypt(encrypted: string): string {
|
|
const [ivHex, tagHex, dataHex] = encrypted.split(':');
|
|
const iv = Buffer.from(ivHex, 'hex');
|
|
const tag = Buffer.from(tagHex, 'hex');
|
|
const data = Buffer.from(dataHex, 'hex');
|
|
const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv);
|
|
decipher.setAuthTag(tag);
|
|
return decipher.update(data) + decipher.final('utf8');
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Testing
|
|
|
|
### 8.1 Unit Tests
|
|
|
|
```typescript
|
|
describe('OAuthService', () => {
|
|
describe('handleOAuthCallback', () => {
|
|
it('should create new user for new OAuth login', async () => {
|
|
// ...
|
|
});
|
|
|
|
it('should return existing user for known OAuth account', async () => {
|
|
// ...
|
|
});
|
|
|
|
it('should update tokens on existing connection', async () => {
|
|
// ...
|
|
});
|
|
});
|
|
|
|
describe('linkOAuthToUser', () => {
|
|
it('should link OAuth to existing user', async () => {
|
|
// ...
|
|
});
|
|
|
|
it('should throw if OAuth already linked to another user', async () => {
|
|
// ...
|
|
});
|
|
});
|
|
|
|
describe('unlinkOAuth', () => {
|
|
it('should unlink OAuth provider', async () => {
|
|
// ...
|
|
});
|
|
|
|
it('should throw if unlinking last auth method', async () => {
|
|
// ...
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
### 8.2 E2E Tests
|
|
|
|
```typescript
|
|
describe('OAuth Flow (E2E)', () => {
|
|
it('should complete Google OAuth flow', async () => {
|
|
// Mock Google OAuth
|
|
// Test full flow
|
|
});
|
|
|
|
it('should handle OAuth errors gracefully', async () => {
|
|
// Test error scenarios
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Criterios de Aceptacion
|
|
|
|
- [ ] GET /auth/oauth/:provider redirige correctamente a cada proveedor
|
|
- [ ] Callback procesa autorizacion y crea/vincula usuario
|
|
- [ ] State parameter previene CSRF
|
|
- [ ] Tokens OAuth se encriptan en BD
|
|
- [ ] Usuario puede vincular multiples proveedores
|
|
- [ ] Usuario puede desvincular proveedor (si tiene alternativa)
|
|
- [ ] Frontend muestra botones OAuth en login/register
|
|
- [ ] Frontend procesa callback correctamente
|
|
- [ ] Tests unitarios con cobertura >70%
|
|
- [ ] Tests E2E para flujo completo de cada proveedor
|
|
|
|
---
|
|
|
|
## 10. Riesgos y Mitigaciones
|
|
|
|
| Riesgo | Probabilidad | Impacto | Mitigacion |
|
|
|--------|--------------|---------|------------|
|
|
| Proveedor no disponible | Media | Alto | Retry + mensaje amigable |
|
|
| Token expirado | Alta | Bajo | Auto-refresh donde soportado |
|
|
| Cuenta duplicada | Baja | Medio | Verificar email antes de crear |
|
|
| CSRF attack | Baja | Alto | State parameter obligatorio |
|
|
|
|
---
|
|
|
|
## 11. Dependencias
|
|
|
|
### NPM Packages
|
|
|
|
```json
|
|
{
|
|
"@nestjs/passport": "^10.0.3",
|
|
"passport": "^0.7.0",
|
|
"passport-google-oauth20": "^2.0.0",
|
|
"passport-microsoft": "^1.0.0",
|
|
"passport-github2": "^0.1.12",
|
|
"passport-apple": "^2.0.2"
|
|
}
|
|
```
|
|
|
|
### Servicios Externos
|
|
|
|
- Google Cloud Console (OAuth credentials)
|
|
- Microsoft Azure Portal (App registration)
|
|
- GitHub Developer Settings (OAuth App)
|
|
- Apple Developer Portal (Sign in with Apple)
|
|
|
|
---
|
|
|
|
*ET-SAAS-015 v1.0.0 - Template SaaS*
|