| id |
title |
type |
status |
priority |
module |
version |
created_date |
updated_date |
story_points |
| ET-SAAS-015 |
Especificacion Tecnica OAuth 2.0 |
TechnicalSpec |
Implemented |
P1 |
oauth |
1.0.0 |
2026-01-24 |
2026-01-24 |
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
-- 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
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
@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
@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
@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
@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
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
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
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
// 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
@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
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
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
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
{
"@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