@bc-koenro/oauth2-client
Version:
OAuth2 client for browsers and Node.js. Tiny footprint, PKCE support
235 lines • 9.63 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateQueryString = exports.tokenResponseToOAuth2Token = exports.OAuth2Client = void 0;
const error_1 = require("./error");
const authorization_code_1 = require("./client/authorization-code");
class OAuth2Client {
constructor(clientSettings) {
this.discoveryDone = false;
this.serverMetadata = null;
if (!(clientSettings === null || clientSettings === void 0 ? void 0 : clientSettings.fetch)) {
clientSettings.fetch = fetch.bind(globalThis);
}
this.settings = clientSettings;
}
/**
* Refreshes an existing token, and returns a new one.
*/
async refreshToken(token) {
if (!token.refreshToken) {
throw new Error('This token didn\'t have a refreshToken. It\'s not possible to refresh this');
}
const body = {
grant_type: 'refresh_token',
refresh_token: token.refreshToken,
};
if (!this.settings.clientSecret) {
// If there's no secret, send the clientId in the body.
body.client_id = this.settings.clientId;
}
return tokenResponseToOAuth2Token(this.request('tokenEndpoint', body));
}
/**
* Retrieves an OAuth2 token using the client_credentials grant.
*/
async clientCredentials(params) {
var _a;
const disallowed = ['client_id', 'client_secret', 'grant_type', 'scope'];
if ((params === null || params === void 0 ? void 0 : params.extraParams) && Object.keys(params.extraParams).filter((key) => disallowed.includes(key)).length > 0) {
throw new Error(`The following extraParams are disallowed: '${disallowed.join("', '")}'`);
}
const body = {
grant_type: 'client_credentials',
scope: (_a = params === null || params === void 0 ? void 0 : params.scope) === null || _a === void 0 ? void 0 : _a.join(' '),
...params === null || params === void 0 ? void 0 : params.extraParams
};
if (!this.settings.clientSecret) {
throw new Error('A clientSecret must be provided to use client_credentials');
}
return tokenResponseToOAuth2Token(this.request('tokenEndpoint', body));
}
/**
* Retrieves an OAuth2 token using the 'password' grant'.
*/
async password(params) {
var _a;
const body = {
grant_type: 'password',
...params,
scope: (_a = params.scope) === null || _a === void 0 ? void 0 : _a.join(' '),
};
return tokenResponseToOAuth2Token(this.request('tokenEndpoint', body));
}
/**
* Returns the helper object for the `authorization_code` grant.
*/
get authorizationCode() {
return new authorization_code_1.OAuth2AuthorizationCodeClient(this);
}
/**
* Introspect a token
*
* This will give information about the validity, owner, which client
* created the token and more.
*
* @see https://datatracker.ietf.org/doc/html/rfc7662
*/
async introspect(token) {
const body = {
token: token.accessToken,
token_type_hint: 'access_token',
};
return this.request('introspectionEndpoint', body);
}
/**
* Returns a url for an OAuth2 endpoint.
*
* Potentially fetches a discovery document to get it.
*/
async getEndpoint(endpoint) {
if (this.settings[endpoint] !== undefined) {
return resolve(this.settings[endpoint], this.settings.server);
}
if (endpoint !== 'discoveryEndpoint') {
// This condition prevents infinite loops.
await this.discover();
if (this.settings[endpoint] !== undefined) {
return resolve(this.settings[endpoint], this.settings.server);
}
}
// If we got here it means we need to 'guess' the endpoint.
if (!this.settings.server) {
throw new Error(`Could not determine the location of ${endpoint}. Either specify ${endpoint} in the settings, or the "server" endpoint to let the client discover it.`);
}
switch (endpoint) {
case 'authorizationEndpoint':
return resolve('/authorize', this.settings.server);
case 'tokenEndpoint':
return resolve('/token', this.settings.server);
case 'discoveryEndpoint':
return resolve('/.well-known/oauth-authorization-server', this.settings.server);
case 'introspectionEndpoint':
return resolve('/introspect', this.settings.server);
}
}
/**
* Fetches the OAuth2 discovery document
*/
async discover() {
var _a;
// Never discover twice
if (this.discoveryDone)
return;
this.discoveryDone = true;
let discoverUrl;
try {
discoverUrl = await this.getEndpoint('discoveryEndpoint');
}
catch (err) {
console.warn('[oauth2] OAuth2 discovery endpoint could not be determined. Either specify the "server" or "discoveryEndpoint');
return;
}
const resp = await this.settings.fetch(discoverUrl, { headers: { Accept: 'application/json' } });
if (!resp.ok)
return;
if (!((_a = resp.headers.get('Content-Type')) === null || _a === void 0 ? void 0 : _a.startsWith('application/json'))) {
console.warn('[oauth2] OAuth2 discovery endpoint was not a JSON response. Response is ignored');
return;
}
this.serverMetadata = await resp.json();
const urlMap = [
['authorization_endpoint', 'authorizationEndpoint'],
['token_endpoint', 'tokenEndpoint'],
['introspection_endpoint', 'introspectionEndpoint'],
];
if (this.serverMetadata === null)
return;
for (const [property, setting] of urlMap) {
if (!this.serverMetadata[property])
continue;
this.settings[setting] = resolve(this.serverMetadata[property], discoverUrl);
}
if (this.serverMetadata.token_endpoint_auth_methods_supported && !this.settings.authenticationMethod) {
this.settings.authenticationMethod = this.serverMetadata.token_endpoint_auth_methods_supported[0];
}
}
async request(endpoint, body) {
const uri = await this.getEndpoint(endpoint);
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
};
let authMethod = this.settings.authenticationMethod;
if (!authMethod) {
authMethod = this.settings.clientSecret ? 'client_secret_basic' : 'client_secret_post';
}
switch (authMethod) {
case 'client_secret_basic':
headers.Authorization = 'Basic ' +
btoa(this.settings.clientId + ':' + this.settings.clientSecret);
break;
case 'client_secret_post':
body.client_id = this.settings.clientId;
if (this.settings.clientSecret) {
body.client_secret = this.settings.clientSecret;
}
break;
default:
throw new Error('Authentication method not yet supported:' + authMethod + '. Open a feature request if you want this!');
}
const resp = await this.settings.fetch(uri, {
method: 'POST',
body: generateQueryString(body),
headers,
});
if (resp.ok) {
return await resp.json();
}
let jsonError;
let errorMessage;
let oauth2Code;
if (resp.headers.has('Content-Type') && resp.headers.get('Content-Type').startsWith('application/json')) {
jsonError = await resp.json();
}
if (jsonError === null || jsonError === void 0 ? void 0 : jsonError.error) {
// This is likely an OAUth2-formatted error
errorMessage = 'OAuth2 error ' + jsonError.error + '.';
if (jsonError.error_description) {
errorMessage += ' ' + jsonError.error_description;
}
oauth2Code = jsonError.error;
}
else {
errorMessage = 'HTTP Error ' + resp.status + ' ' + resp.statusText;
if (resp.status === 401 && this.settings.clientSecret) {
errorMessage += '. It\'s likely that the clientId and/or clientSecret was incorrect';
}
oauth2Code = null;
}
throw new error_1.OAuth2Error(errorMessage, oauth2Code, resp.status);
}
}
exports.OAuth2Client = OAuth2Client;
function resolve(uri, base) {
return new URL(uri, base).toString();
}
function tokenResponseToOAuth2Token(resp) {
return resp.then(body => {
var _a;
return ({
accessToken: body.access_token,
expiresAt: body.expires_in ? Date.now() + (body.expires_in * 1000) : null,
refreshToken: (_a = body.refresh_token) !== null && _a !== void 0 ? _a : null,
});
});
}
exports.tokenResponseToOAuth2Token = tokenResponseToOAuth2Token;
/**
* Generates a query string.
*
* This function filters out any undefined values.
*/
function generateQueryString(params) {
return new URLSearchParams(Object.fromEntries(Object.entries(params).filter(([k, v]) => v !== undefined))).toString();
}
exports.generateQueryString = generateQueryString;
//# sourceMappingURL=client.js.map