export class PietroCalOAuth { constructor({ clientId, redirectUri, clientSecret = null, baseUrl = 'https://pietrocal.com', fetchImpl = globalThis.fetch }) { if (!String(clientId || '').startsWith('pc_oauth_')) { throw new TypeError('El Client ID debe comenzar con pc_oauth_.'); } this.clientId = clientId; this.redirectUri = redirectUri; this.clientSecret = clientSecret; this.baseUrl = String(baseUrl).replace(/\/+$/, ''); this.fetchImpl = fetchImpl; } async createPkce() { const bytes = crypto.getRandomValues(new Uint8Array(48)); const codeVerifier = base64url(bytes); const digest = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier) ); return { codeVerifier, codeChallenge: base64url(new Uint8Array(digest)) }; } authorizationUrl({ scopes, state, codeChallenge }) { const url = new URL('/oauth/authorize.php', this.baseUrl); url.search = new URLSearchParams({ response_type: 'code', client_id: this.clientId, redirect_uri: this.redirectUri, scope: [...new Set(scopes)].join(' '), state, code_challenge: codeChallenge, code_challenge_method: 'S256' }); return url.toString(); } exchangeCode({ code, codeVerifier }) { return this.postForm('/oauth/token.php', { grant_type: 'authorization_code', client_id: this.clientId, client_secret: this.clientSecret, code, redirect_uri: this.redirectUri, code_verifier: codeVerifier }); } refresh(refreshToken) { return this.postForm('/oauth/token.php', { grant_type: 'refresh_token', client_id: this.clientId, client_secret: this.clientSecret, refresh_token: refreshToken }); } async revoke(token, tokenTypeHint = null) { await this.postForm('/oauth/revoke.php', { client_id: this.clientId, client_secret: this.clientSecret, token, token_type_hint: tokenTypeHint }, true); } async postForm(path, fields, allowEmpty = false) { const body = new URLSearchParams(); Object.entries(fields).forEach(([key, value]) => { if (value !== null && value !== undefined && value !== '') { body.set(key, String(value)); } }); const response = await this.fetchImpl(`${this.baseUrl}${path}`, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, body }); const raw = await response.text(); if (!response.ok) { let data = {}; try { data = JSON.parse(raw); } catch (_) {} throw new Error(data.error_description || 'La operación OAuth falló.'); } if (allowEmpty && !raw.trim()) return {}; return raw.trim() ? JSON.parse(raw) : {}; } } function base64url(bytes) { let binary = ''; bytes.forEach(byte => { binary += String.fromCharCode(byte); }); return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, ''); }