pietrocal-oauth.jsjavascript/pietrocal-oauth.js · 109 líneas
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, '');
}