template-saas/docs/02-especificaciones/ET-SAAS-015-oauth.md
Adrian Flores Cortes 3b654a34c8
Some checks are pending
CI / Backend CI (push) Waiting to run
CI / Frontend CI (push) Waiting to run
CI / Security Scan (push) Waiting to run
CI / CI Summary (push) Blocked by required conditions
[TASK-2026-01-24] docs: Add ET-SAAS-015, ET-SAAS-016, ET-SAAS-017 technical specs
2026-01-24 22:09:40 -06:00

20 KiB

id title type status priority module version created_date updated_date story_points
ET-SAAS-015 Especificacion Tecnica OAuth 2.0 TechnicalSpec Proposed 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

  • 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

{
  "@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