ML Engine Updates: - Updated BTCUSD with Polygon API data (2024-2025): 215,699 new records - Re-trained all ML models: Attention (R²: 0.223), Base, Metamodel (87.3% confidence) - Backtest results: +176.71R profit with aggressive_filter strategy Documentation Consolidation: - Created docs/99-analisis/_MAP.md index with 13 new analysis documents - Consolidated inventories: removed duplicates from orchestration/inventarios/ - Updated ML_INVENTORY.yml with BTCUSD metrics and training results - Added execution reports: FASE11-BTCUSD, correction issues, alignment validation Architecture & Integration: - Updated all module documentation with NEXUS v3.4 frontmatter - Fixed _MAP.md indexes across all folders - Updated orchestration plans and traces Files: 229 changed, 5064 insertions(+), 1872 deletions(-) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
609 lines
20 KiB
Markdown
609 lines
20 KiB
Markdown
---
|
|
id: "ET-AUTH-001"
|
|
title: "OAuth Providers Implementation"
|
|
type: "Specification"
|
|
status: "Done"
|
|
rf_parent: "RF-AUTH-001"
|
|
epic: "OQI-001"
|
|
version: "1.0"
|
|
created_date: "2025-12-05"
|
|
updated_date: "2026-01-04"
|
|
---
|
|
|
|
# ET-AUTH-001: Especificación Técnica - OAuth Providers
|
|
|
|
**Version:** 1.0.0
|
|
**Fecha:** 2025-12-05
|
|
**Estado:** ✅ Implementado
|
|
**Épica:** [OQI-001](../_MAP.md)
|
|
**Requerimiento:** [RF-AUTH-001](../requerimientos/RF-AUTH-001-oauth.md)
|
|
|
|
---
|
|
|
|
## Resumen
|
|
|
|
Esta especificación detalla la implementación técnica del sistema de autenticación OAuth 2.0 multi-proveedor para Trading Platform.
|
|
|
|
---
|
|
|
|
## Arquitectura
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────────┐
|
|
│ FRONTEND │
|
|
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
|
│ │ SocialLogin │ │ AuthCallback │ │ AuthStore │ │
|
|
│ │ Buttons.tsx │───▶│ Page.tsx │───▶│ (Zustand) │ │
|
|
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
|
└────────────────────────────────┬────────────────────────────────────────┘
|
|
│
|
|
│ HTTPS
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────────────┐
|
|
│ BACKEND │
|
|
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
|
│ │ auth.routes.ts │───▶│ oauth.service │───▶│ token.service │ │
|
|
│ │ │ │ .ts │ │ .ts │ │
|
|
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
|
│ │ │ │ │
|
|
│ │ │ │ │
|
|
│ ▼ ▼ ▼ │
|
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
|
│ │ PostgreSQL (public schema) │ │
|
|
│ │ ┌──────────┐ ┌────────────────┐ ┌──────────────────────┐ │ │
|
|
│ │ │ users │ │ oauth_accounts │ │ sessions │ │ │
|
|
│ │ └──────────┘ └────────────────┘ └──────────────────────┘ │ │
|
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────────────┐
|
|
│ OAUTH PROVIDERS │
|
|
│ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐ │
|
|
│ │ Google │ │ Facebook │ │ Twitter │ │ Apple │ │ GitHub │ │
|
|
│ └─────────┘ └──────────┘ └─────────┘ └─────────┘ └──────────┘ │
|
|
└─────────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Componentes
|
|
|
|
### 1. OAuth Service (`oauth.service.ts`)
|
|
|
|
**Ubicación:** `apps/backend/src/modules/auth/services/oauth.service.ts`
|
|
|
|
```typescript
|
|
import { google } from 'googleapis';
|
|
import axios from 'axios';
|
|
|
|
export interface OAuthUserInfo {
|
|
provider: AuthProvider;
|
|
providerId: string;
|
|
email: string | null;
|
|
emailVerified: boolean;
|
|
firstName: string;
|
|
lastName: string;
|
|
avatarUrl: string | null;
|
|
accessToken: string;
|
|
refreshToken?: string;
|
|
}
|
|
|
|
export class OAuthService {
|
|
// Google OAuth
|
|
async getGoogleAuthUrl(state: string): Promise<string>;
|
|
async getGoogleUserInfo(code: string): Promise<OAuthUserInfo>;
|
|
|
|
// Facebook OAuth
|
|
async getFacebookAuthUrl(state: string): Promise<string>;
|
|
async getFacebookUserInfo(code: string): Promise<OAuthUserInfo>;
|
|
|
|
// Twitter OAuth 2.0
|
|
async getTwitterAuthUrl(state: string, codeChallenge: string): Promise<string>;
|
|
async getTwitterUserInfo(code: string, codeVerifier: string): Promise<OAuthUserInfo>;
|
|
|
|
// Apple Sign In
|
|
async getAppleAuthUrl(state: string): Promise<string>;
|
|
async getAppleUserInfo(code: string): Promise<OAuthUserInfo>;
|
|
|
|
// GitHub OAuth
|
|
async getGitHubAuthUrl(state: string): Promise<string>;
|
|
async getGitHubUserInfo(code: string): Promise<OAuthUserInfo>;
|
|
}
|
|
```
|
|
|
|
### 2. OAuth Routes
|
|
|
|
**Ubicación:** `apps/backend/src/modules/auth/auth.routes.ts`
|
|
|
|
```typescript
|
|
// Get authorization URL
|
|
router.get('/oauth/:provider/url', authController.getOAuthUrl);
|
|
|
|
// Exchange code for tokens
|
|
router.post('/oauth/:provider', authController.handleOAuthCallback);
|
|
|
|
// Unlink OAuth provider
|
|
router.delete('/oauth/:provider', authenticate, authController.unlinkOAuthProvider);
|
|
|
|
// List linked providers
|
|
router.get('/oauth/providers', authenticate, authController.getLinkedProviders);
|
|
```
|
|
|
|
---
|
|
|
|
## Configuración por Proveedor
|
|
|
|
### Google
|
|
|
|
```typescript
|
|
const googleConfig = {
|
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
|
redirectUri: `${process.env.API_URL}/auth/oauth/google/callback`,
|
|
scopes: ['profile', 'email'],
|
|
accessType: 'offline',
|
|
prompt: 'consent',
|
|
};
|
|
|
|
// Authorization URL
|
|
const oauth2Client = new google.auth.OAuth2(
|
|
googleConfig.clientId,
|
|
googleConfig.clientSecret,
|
|
googleConfig.redirectUri
|
|
);
|
|
|
|
const authUrl = oauth2Client.generateAuthUrl({
|
|
access_type: googleConfig.accessType,
|
|
scope: googleConfig.scopes,
|
|
state: stateToken,
|
|
prompt: googleConfig.prompt,
|
|
});
|
|
```
|
|
|
|
### Facebook
|
|
|
|
```typescript
|
|
const facebookConfig = {
|
|
clientId: process.env.FACEBOOK_APP_ID,
|
|
clientSecret: process.env.FACEBOOK_APP_SECRET,
|
|
redirectUri: `${process.env.API_URL}/auth/oauth/facebook/callback`,
|
|
scopes: ['email', 'public_profile'],
|
|
};
|
|
|
|
// Authorization URL
|
|
const authUrl = `https://www.facebook.com/v18.0/dialog/oauth?` +
|
|
`client_id=${facebookConfig.clientId}` +
|
|
`&redirect_uri=${encodeURIComponent(facebookConfig.redirectUri)}` +
|
|
`&scope=${facebookConfig.scopes.join(',')}` +
|
|
`&state=${stateToken}`;
|
|
```
|
|
|
|
### X/Twitter (OAuth 2.0 with PKCE)
|
|
|
|
```typescript
|
|
const twitterConfig = {
|
|
clientId: process.env.TWITTER_CLIENT_ID,
|
|
clientSecret: process.env.TWITTER_CLIENT_SECRET,
|
|
redirectUri: `${process.env.API_URL}/auth/oauth/twitter/callback`,
|
|
scopes: ['tweet.read', 'users.read', 'offline.access'],
|
|
};
|
|
|
|
// PKCE challenge
|
|
const codeVerifier = generateCodeVerifier();
|
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
|
|
// Authorization URL
|
|
const authUrl = `https://twitter.com/i/oauth2/authorize?` +
|
|
`response_type=code` +
|
|
`&client_id=${twitterConfig.clientId}` +
|
|
`&redirect_uri=${encodeURIComponent(twitterConfig.redirectUri)}` +
|
|
`&scope=${encodeURIComponent(twitterConfig.scopes.join(' '))}` +
|
|
`&state=${stateToken}` +
|
|
`&code_challenge=${codeChallenge}` +
|
|
`&code_challenge_method=S256`;
|
|
```
|
|
|
|
### Apple Sign In
|
|
|
|
```typescript
|
|
const appleConfig = {
|
|
clientId: process.env.APPLE_CLIENT_ID, // Service ID
|
|
teamId: process.env.APPLE_TEAM_ID,
|
|
keyId: process.env.APPLE_KEY_ID,
|
|
privateKey: process.env.APPLE_PRIVATE_KEY,
|
|
redirectUri: `${process.env.API_URL}/auth/oauth/apple/callback`,
|
|
scopes: ['name', 'email'],
|
|
};
|
|
|
|
// Generate client secret (JWT)
|
|
function generateAppleClientSecret(): string {
|
|
return jwt.sign({}, appleConfig.privateKey, {
|
|
algorithm: 'ES256',
|
|
expiresIn: '180d',
|
|
audience: 'https://appleid.apple.com',
|
|
issuer: appleConfig.teamId,
|
|
subject: appleConfig.clientId,
|
|
keyid: appleConfig.keyId,
|
|
});
|
|
}
|
|
```
|
|
|
|
### GitHub
|
|
|
|
```typescript
|
|
const githubConfig = {
|
|
clientId: process.env.GITHUB_CLIENT_ID,
|
|
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
|
redirectUri: `${process.env.API_URL}/auth/oauth/github/callback`,
|
|
scopes: ['read:user', 'user:email'],
|
|
};
|
|
|
|
// Authorization URL
|
|
const authUrl = `https://github.com/login/oauth/authorize?` +
|
|
`client_id=${githubConfig.clientId}` +
|
|
`&redirect_uri=${encodeURIComponent(githubConfig.redirectUri)}` +
|
|
`&scope=${encodeURIComponent(githubConfig.scopes.join(' '))}` +
|
|
`&state=${stateToken}`;
|
|
```
|
|
|
|
---
|
|
|
|
## Esquema de Base de Datos
|
|
|
|
### Tabla: oauth_accounts
|
|
|
|
```sql
|
|
CREATE TABLE oauth_accounts (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
provider auth_provider_enum NOT NULL,
|
|
provider_user_id VARCHAR(255) NOT NULL,
|
|
email VARCHAR(255),
|
|
access_token TEXT, -- Encriptado
|
|
refresh_token TEXT, -- Encriptado
|
|
token_expires_at TIMESTAMPTZ,
|
|
raw_profile JSONB, -- Perfil completo del proveedor
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
UNIQUE(provider, provider_user_id),
|
|
UNIQUE(user_id, provider)
|
|
);
|
|
|
|
-- ENUM para proveedores
|
|
CREATE TYPE auth_provider_enum AS ENUM (
|
|
'google',
|
|
'facebook',
|
|
'twitter',
|
|
'apple',
|
|
'github'
|
|
);
|
|
|
|
-- Índices
|
|
CREATE INDEX idx_oauth_accounts_user_id ON oauth_accounts(user_id);
|
|
CREATE INDEX idx_oauth_accounts_provider ON oauth_accounts(provider);
|
|
CREATE INDEX idx_oauth_accounts_email ON oauth_accounts(email);
|
|
```
|
|
|
|
---
|
|
|
|
## Flujo de Implementación
|
|
|
|
### 1. Obtener URL de Autorización
|
|
|
|
```typescript
|
|
// GET /auth/oauth/:provider/url
|
|
async getOAuthUrl(req: Request, res: Response) {
|
|
const { provider } = req.params;
|
|
const { redirectTo } = req.query;
|
|
|
|
// Generar state con información de redirect
|
|
const state = await this.generateState({
|
|
provider,
|
|
redirectTo: redirectTo || '/dashboard',
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
// Almacenar state en Redis (10 min TTL)
|
|
await redis.setEx(`oauth:state:${state}`, 600, JSON.stringify({
|
|
provider,
|
|
redirectTo,
|
|
createdAt: Date.now(),
|
|
}));
|
|
|
|
let authUrl: string;
|
|
let codeVerifier: string | null = null;
|
|
|
|
switch (provider) {
|
|
case 'google':
|
|
authUrl = await this.oauthService.getGoogleAuthUrl(state);
|
|
break;
|
|
case 'twitter':
|
|
codeVerifier = generateCodeVerifier();
|
|
await redis.setEx(`oauth:pkce:${state}`, 600, codeVerifier);
|
|
authUrl = await this.oauthService.getTwitterAuthUrl(state, codeVerifier);
|
|
break;
|
|
// ... otros proveedores
|
|
}
|
|
|
|
res.json({ authUrl, state });
|
|
}
|
|
```
|
|
|
|
### 2. Manejar Callback
|
|
|
|
```typescript
|
|
// POST /auth/oauth/:provider
|
|
async handleOAuthCallback(req: Request, res: Response) {
|
|
const { provider } = req.params;
|
|
const { code, state } = req.body;
|
|
|
|
// Validar state
|
|
const stateData = await redis.get(`oauth:state:${state}`);
|
|
if (!stateData) {
|
|
throw new UnauthorizedError('Invalid or expired state');
|
|
}
|
|
await redis.del(`oauth:state:${state}`);
|
|
|
|
// Obtener user info del proveedor
|
|
let userInfo: OAuthUserInfo;
|
|
|
|
switch (provider) {
|
|
case 'google':
|
|
userInfo = await this.oauthService.getGoogleUserInfo(code);
|
|
break;
|
|
case 'twitter':
|
|
const codeVerifier = await redis.get(`oauth:pkce:${state}`);
|
|
userInfo = await this.oauthService.getTwitterUserInfo(code, codeVerifier);
|
|
await redis.del(`oauth:pkce:${state}`);
|
|
break;
|
|
// ... otros proveedores
|
|
}
|
|
|
|
// Buscar o crear usuario
|
|
const user = await this.findOrCreateUser(userInfo);
|
|
|
|
// Generar tokens JWT
|
|
const tokens = await this.tokenService.generateTokens(user);
|
|
|
|
res.json({
|
|
user: sanitizeUser(user),
|
|
tokens,
|
|
redirectTo: JSON.parse(stateData).redirectTo,
|
|
});
|
|
}
|
|
```
|
|
|
|
### 3. Buscar o Crear Usuario
|
|
|
|
```typescript
|
|
async findOrCreateUser(oauthInfo: OAuthUserInfo): Promise<User> {
|
|
// Buscar cuenta OAuth existente
|
|
let oauthAccount = await db.query(`
|
|
SELECT * FROM oauth_accounts
|
|
WHERE provider = $1 AND provider_user_id = $2
|
|
`, [oauthInfo.provider, oauthInfo.providerId]);
|
|
|
|
if (oauthAccount.rows[0]) {
|
|
// Usuario existente - actualizar tokens
|
|
await this.updateOAuthTokens(oauthAccount.rows[0].id, oauthInfo);
|
|
return this.userService.findById(oauthAccount.rows[0].user_id);
|
|
}
|
|
|
|
// Buscar usuario por email (si disponible)
|
|
if (oauthInfo.email) {
|
|
const existingUser = await this.userService.findByEmail(oauthInfo.email);
|
|
if (existingUser) {
|
|
// Vincular OAuth a usuario existente
|
|
await this.linkOAuthAccount(existingUser.id, oauthInfo);
|
|
return existingUser;
|
|
}
|
|
}
|
|
|
|
// Crear nuevo usuario
|
|
const newUser = await this.userService.create({
|
|
email: oauthInfo.email,
|
|
firstName: oauthInfo.firstName,
|
|
lastName: oauthInfo.lastName,
|
|
avatarUrl: oauthInfo.avatarUrl,
|
|
emailVerified: oauthInfo.emailVerified,
|
|
status: oauthInfo.emailVerified ? 'active' : 'pending_verification',
|
|
});
|
|
|
|
// Vincular cuenta OAuth
|
|
await this.linkOAuthAccount(newUser.id, oauthInfo);
|
|
|
|
return newUser;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Componentes Frontend
|
|
|
|
### SocialLoginButtons
|
|
|
|
```typescript
|
|
// apps/frontend/src/modules/auth/components/SocialLoginButtons.tsx
|
|
export const SocialLoginButtons: React.FC<Props> = ({ mode, onStart, onSuccess, onError }) => {
|
|
const handleOAuthLogin = async (provider: OAuthProvider) => {
|
|
onStart?.();
|
|
|
|
try {
|
|
// Obtener URL de autorización
|
|
const { data } = await api.get(`/auth/oauth/${provider}/url`, {
|
|
params: { redirectTo: window.location.pathname },
|
|
});
|
|
|
|
// Guardar state para verificación
|
|
sessionStorage.setItem('oauth_state', data.state);
|
|
|
|
// Redirigir a proveedor
|
|
window.location.href = data.authUrl;
|
|
} catch (error) {
|
|
onError?.(error);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<OAuthButton provider="google" onClick={() => handleOAuthLogin('google')} />
|
|
<OAuthButton provider="facebook" onClick={() => handleOAuthLogin('facebook')} />
|
|
<OAuthButton provider="twitter" onClick={() => handleOAuthLogin('twitter')} />
|
|
<OAuthButton provider="apple" onClick={() => handleOAuthLogin('apple')} />
|
|
<OAuthButton provider="github" onClick={() => handleOAuthLogin('github')} />
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### AuthCallback Page
|
|
|
|
```typescript
|
|
// apps/frontend/src/modules/auth/pages/AuthCallback.tsx
|
|
export const AuthCallback: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const { setUser, setTokens } = useAuthStore();
|
|
|
|
useEffect(() => {
|
|
const handleCallback = async () => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const code = params.get('code');
|
|
const state = params.get('state');
|
|
const provider = params.get('provider') || extractProvider(pathname);
|
|
|
|
// Validar state
|
|
const savedState = sessionStorage.getItem('oauth_state');
|
|
if (state !== savedState) {
|
|
navigate('/login?error=invalid_state');
|
|
return;
|
|
}
|
|
sessionStorage.removeItem('oauth_state');
|
|
|
|
try {
|
|
const { data } = await api.post(`/auth/oauth/${provider}`, {
|
|
code,
|
|
state,
|
|
});
|
|
|
|
setUser(data.user);
|
|
setTokens(data.tokens);
|
|
navigate(data.redirectTo || '/dashboard');
|
|
} catch (error) {
|
|
navigate(`/login?error=${error.code}`);
|
|
}
|
|
};
|
|
|
|
handleCallback();
|
|
}, []);
|
|
|
|
return <LoadingSpinner message="Completando inicio de sesión..." />;
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Seguridad
|
|
|
|
### Encriptación de Tokens
|
|
|
|
```typescript
|
|
import crypto from 'crypto';
|
|
|
|
const ENCRYPTION_KEY = process.env.TOKEN_ENCRYPTION_KEY; // 32 bytes
|
|
const IV_LENGTH = 16;
|
|
|
|
export function encryptToken(token: string): string {
|
|
const iv = crypto.randomBytes(IV_LENGTH);
|
|
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
|
|
|
|
let encrypted = cipher.update(token, 'utf8', 'hex');
|
|
encrypted += cipher.final('hex');
|
|
|
|
const authTag = cipher.getAuthTag();
|
|
|
|
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
|
}
|
|
|
|
export function decryptToken(encrypted: string): string {
|
|
const [ivHex, authTagHex, encryptedData] = encrypted.split(':');
|
|
|
|
const iv = Buffer.from(ivHex, 'hex');
|
|
const authTag = Buffer.from(authTagHex, 'hex');
|
|
|
|
const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
|
|
decipher.setAuthTag(authTag);
|
|
|
|
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
|
|
decrypted += decipher.final('utf8');
|
|
|
|
return decrypted;
|
|
}
|
|
```
|
|
|
|
### PKCE para OAuth 2.0
|
|
|
|
```typescript
|
|
import crypto from 'crypto';
|
|
|
|
export function generateCodeVerifier(): string {
|
|
return crypto.randomBytes(32).toString('base64url');
|
|
}
|
|
|
|
export function generateCodeChallenge(verifier: string): string {
|
|
return crypto
|
|
.createHash('sha256')
|
|
.update(verifier)
|
|
.digest('base64url');
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
### Unit Tests
|
|
|
|
```typescript
|
|
describe('OAuthService', () => {
|
|
describe('getGoogleUserInfo', () => {
|
|
it('should return user info from Google', async () => {
|
|
const mockCode = 'test_code';
|
|
|
|
// Mock Google API response
|
|
nock('https://oauth2.googleapis.com')
|
|
.post('/token')
|
|
.reply(200, { access_token: 'test_token' });
|
|
|
|
nock('https://www.googleapis.com')
|
|
.get('/oauth2/v2/userinfo')
|
|
.reply(200, {
|
|
id: '12345',
|
|
email: 'test@gmail.com',
|
|
verified_email: true,
|
|
given_name: 'Test',
|
|
family_name: 'User',
|
|
picture: 'https://...',
|
|
});
|
|
|
|
const result = await oauthService.getGoogleUserInfo(mockCode);
|
|
|
|
expect(result.provider).toBe('google');
|
|
expect(result.email).toBe('test@gmail.com');
|
|
expect(result.emailVerified).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Referencias
|
|
|
|
- [Google OAuth 2.0 Documentation](https://developers.google.com/identity/protocols/oauth2)
|
|
- [Facebook Login Documentation](https://developers.facebook.com/docs/facebook-login)
|
|
- [Twitter OAuth 2.0 Documentation](https://developer.twitter.com/en/docs/authentication/oauth-2-0)
|
|
- [Sign in with Apple Documentation](https://developer.apple.com/documentation/sign_in_with_apple)
|
|
- [GitHub OAuth Apps Documentation](https://docs.github.com/en/apps/oauth-apps)
|