--- 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:** Propuesto - **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; @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, 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 { // 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 { // 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 { // 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 { 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 { 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 { 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 { 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 { await this.oauthService.unlinkOAuth(user.id, provider); } @Get('connections') @ApiOperation({ summary: 'List OAuth connections' }) @UseGuards(JwtAuthGuard) async getConnections(@GetUser() user: User): Promise { 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 = ({ 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 (
{providers.map((provider) => ( ))}
); }; ``` ### 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 ; }; ``` --- ## 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*