Skip to content

Steam OAuth

O CSGOFlip utiliza Steam OpenID 2.0 para autenticação. Usuários fazem login com sua conta Steam, sem precisar criar senha no site.

Por que Steam OAuth?

BenefícioDescrição
Sem senhasNão armazenamos credenciais sensíveis
ConfiançaUsuários confiam no Steam
Dados verificadosSteam ID é único e verificado
Avatar e nomeObtemos automaticamente
SegurançaSteam cuida da autenticação

Fluxo Completo

Implementação

1. Iniciar Login

typescript
// src/presentation/controllers/auth.controller.ts

@Get('steam/login')
@Public()
async steamLogin(
  @Query('redirect') redirect: string,
  @Res() response: FastifyReply,
) {
  // URL de retorno após Steam
  const returnUrl = `${this.configService.get('API_URL')}/auth/steam/callback`;
  
  // Salva redirect desejado em cookie temporário
  if (redirect) {
    response.setCookie('auth_redirect', redirect, {
      httpOnly: true,
      maxAge: 60 * 5, // 5 minutos
    });
  }

  // Monta URL do Steam OpenID
  const steamLoginUrl = this.steamOAuthService.getLoginUrl(returnUrl);
  
  // Redirect para Steam
  return response.redirect(steamLoginUrl);
}

2. Steam OAuth Service

typescript
// src/infrastructure/external-apis/steam-oauth.service.ts

@Injectable()
export class SteamOAuthService {
  private readonly STEAM_OPENID_URL = 'https://steamcommunity.com/openid/login';

  getLoginUrl(returnUrl: string): string {
    const params = new URLSearchParams({
      'openid.ns': 'http://specs.openid.net/auth/2.0',
      'openid.mode': 'checkid_setup',
      'openid.return_to': returnUrl,
      'openid.realm': returnUrl,
      'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
      'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
    });

    return `${this.STEAM_OPENID_URL}?${params.toString()}`;
  }

  async verifyCallback(query: Record<string, string>): Promise<string | null> {
    // Verifica que é uma resposta válida
    if (query['openid.mode'] !== 'id_res') {
      return null;
    }

    // Prepara verificação
    const verifyParams = new URLSearchParams();
    for (const [key, value] of Object.entries(query)) {
      verifyParams.append(key, value);
    }
    verifyParams.set('openid.mode', 'check_authentication');

    // Envia para Steam verificar
    const response = await fetch(this.STEAM_OPENID_URL, {
      method: 'POST',
      body: verifyParams,
    });

    const text = await response.text();

    // Steam responde com "is_valid:true" se válido
    if (!text.includes('is_valid:true')) {
      return null;
    }

    // Extrai Steam ID da claimed_id
    // Formato: https://steamcommunity.com/openid/id/76561198012345678
    const claimedId = query['openid.claimed_id'];
    const steamId = claimedId.split('/').pop();

    return steamId;
  }

  async getUserProfile(steamId: string): Promise<SteamProfile> {
    const apiKey = this.configService.get('STEAM_API_KEY');
    const url = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${apiKey}&steamids=${steamId}`;

    const response = await fetch(url);
    const data = await response.json();

    const player = data.response.players[0];

    return {
      steamId: player.steamid,
      username: player.personaname,
      avatarUrl: player.avatarfull,
      profileUrl: player.profileurl,
    };
  }
}

3. Callback Handler

typescript
// src/presentation/controllers/auth.controller.ts

@Get('steam/callback')
@Public()
async steamCallback(
  @Query() query: Record<string, string>,
  @Req() request: FastifyRequest,
  @Res() response: FastifyReply,
) {
  // 1. Verifica resposta do Steam
  const steamId = await this.steamOAuthService.verifyCallback(query);
  
  if (!steamId) {
    return response.redirect('/auth/error?reason=invalid_steam_response');
  }

  // 2. Busca perfil completo
  const profile = await this.steamOAuthService.getUserProfile(steamId);

  // 3. Cria ou atualiza usuário
  let user = await this.userRepository.findBySteamId(steamId);

  if (user) {
    // Atualiza dados do Steam (pode ter mudado avatar/nome)
    user = await this.userRepository.update(user.id, {
      username: profile.username,
      avatarUrl: profile.avatarUrl,
      lastLoginAt: new Date(),
    });
  } else {
    // Cria novo usuário
    user = await this.userRepository.create({
      steamId: profile.steamId,
      username: profile.username,
      avatarUrl: profile.avatarUrl,
      role: 'USER',
      status: 'ACTIVE',
    });

    // Loga criação de conta
    await this.auditService.log({
      action: 'USER_CREATED',
      userId: user.id,
      metadata: { steamId, username: profile.username },
    });
  }

  // 4. Cria sessão
  const session = await this.sessionService.create({
    userId: user.id,
    steamId: user.steamId,
    ip: request.ip,
    userAgent: request.headers['user-agent'],
  });

  // 5. Seta cookie de sessão
  response.setCookie('sessionId', session.id, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7, // 7 dias
    path: '/',
  });

  // 6. Redirect para frontend
  const redirectUrl = request.cookies['auth_redirect'] || '/';
  response.clearCookie('auth_redirect');
  
  return response.redirect(`${this.configService.get('FRONTEND_URL')}${redirectUrl}`);
}

4. Frontend Integration

typescript
// client/app/lib/auth-context.tsx

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  // Busca usuário logado ao carregar
  useEffect(() => {
    api.auth.getMe()
      .then(setUser)
      .catch(() => setUser(null))
      .finally(() => setIsLoading(false));
  }, []);

  const login = () => {
    // Redirect para Steam OAuth
    const currentPath = window.location.pathname;
    window.location.href = `${API_BASE_URL}/auth/steam/login?redirect=${currentPath}`;
  };

  const logout = async () => {
    await api.auth.logout();
    setUser(null);
    window.location.href = '/';
  };

  return (
    <AuthContext.Provider value={{ user, isLoading, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

Dados Obtidos do Steam

CampoDescriçãoExemplo
steamIdID único de 17 dígitos76561198012345678
usernameNome de exibiçãoPlayerOne
avatarUrlURL do avatar (184x184)https://steamcdn.../abc.jpg
profileUrlURL do perfilhttps://steamcommunity.com/id/playerone

Tratamento de Erros

typescript
// Possíveis erros no callback

enum SteamAuthError {
  INVALID_RESPONSE = 'invalid_steam_response',
  USER_BANNED = 'user_banned',
  STEAM_API_ERROR = 'steam_api_error',
  SESSION_CREATE_ERROR = 'session_create_error',
}

// Controller com tratamento
@Get('steam/callback')
async steamCallback(@Query() query, @Res() response) {
  try {
    const steamId = await this.steamOAuthService.verifyCallback(query);
    
    if (!steamId) {
      return this.handleAuthError(response, SteamAuthError.INVALID_RESPONSE);
    }

    const user = await this.getOrCreateUser(steamId);
    
    if (user.status === 'BANNED') {
      return this.handleAuthError(response, SteamAuthError.USER_BANNED);
    }

    // ... resto do fluxo
  } catch (error) {
    this.logger.error('Steam callback error', error);
    return this.handleAuthError(response, SteamAuthError.STEAM_API_ERROR);
  }
}

private handleAuthError(response: FastifyReply, error: SteamAuthError) {
  return response.redirect(
    `${this.frontendUrl}/auth/error?reason=${error}`
  );
}

Segurança

Validação da Resposta Steam

typescript
// SEMPRE valide a resposta do Steam!
// Não confie apenas nos parâmetros do callback.

async verifyCallback(query: Record<string, string>): Promise<string | null> {
  // 1. Verifica modo de resposta
  if (query['openid.mode'] !== 'id_res') {
    return null;
  }

  // 2. OBRIGATÓRIO: Envia para Steam verificar
  // Sem isso, atacante poderia forjar resposta!
  const verifyParams = new URLSearchParams();
  verifyParams.set('openid.mode', 'check_authentication');
  // ... copia todos os parâmetros

  const response = await fetch(STEAM_OPENID_URL, {
    method: 'POST',
    body: verifyParams,
  });

  const text = await response.text();

  // 3. Steam confirma se é válido
  if (!text.includes('is_valid:true')) {
    return null; // Resposta forjada!
  }

  return steamId;
}

Rate Limiting

typescript
// Limite tentativas de login
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 tentativas / 15 min
@Get('steam/callback')
async steamCallback() {
  // ...
}

Troubleshooting

Steam não retorna para o callback

Causa: URL de retorno não está autorizada no Steam.

Solução: Verifique que o domínio está configurado corretamente nas configurações de desenvolvedor do Steam.

Avatar não carrega

Causa: Steam CDN pode ter URLs temporárias.

Solução: Salve o avatar localmente ou use proxy.

"Invalid Steam response" frequente

Causa: Timeout na verificação com Steam API.

Solução: Aumente timeout e adicione retry:

typescript
const response = await fetch(url, {
  timeout: 10000, // 10 segundos
});

// Com retry
const response = await retryFetch(url, { retries: 3 });

Arquivos Fonte Relacionados

Principais Arquivos

  • src/presentation/controllers/auth.controller.ts - Endpoints de auth
  • src/infrastructure/external-apis/steam-oauth.service.ts - Integração Steam
  • client/app/lib/auth-context.tsx - Context de auth no frontend
  • client/app/auth/callback/page.tsx - Página de callback

Documentação Técnica CSGOFlip