@badgateway/oauth2-client
Version:
OAuth2 client for browsers and Node.js. Tiny footprint, PKCE support
452 lines (451 loc) • 16.4 kB
JavaScript
class f extends Error {
constructor(e, t) {
super(e), this.oauth2Code = t;
}
}
class k extends f {
constructor(e, t, n, r) {
super(e, t), this.httpCode = n.status, this.response = n, this.parsedBody = r;
}
}
class g {
constructor(e) {
this.client = e;
}
/**
* Returns the URi that the user should open in a browser to initiate the
* authorization_code flow.
*/
async getAuthorizeUri(e) {
const [
t,
n
] = await Promise.all([
e.codeVerifier ? y(e.codeVerifier) : void 0,
this.client.getEndpoint("authorizationEndpoint")
]), r = new URLSearchParams({
client_id: this.client.settings.clientId,
response_type: "code",
redirect_uri: e.redirectUri
});
if (t && (r.set("code_challenge_method", t[0]), r.set("code_challenge", t[1])), e.state && r.set("state", e.state), e.scope && r.set("scope", e.scope.join(" ")), e.resource) for (const o of [].concat(e.resource))
r.append("resource", o);
if (e.responseMode && e.responseMode !== "query" && r.append("response_mode", e.responseMode), e.extraParams) for (const [o, i] of Object.entries(e.extraParams)) {
if (r.has(o)) throw new Error(`Property in extraParams would overwrite standard property: ${o}`);
r.set(o, i);
}
return n + "?" + r.toString();
}
async getTokenFromCodeRedirect(e, t) {
const { code: n } = this.validateResponse(e, {
state: t.state
});
return this.getToken({
code: n,
redirectUri: t.redirectUri,
codeVerifier: t.codeVerifier
});
}
/**
* After the user redirected back from the authorization endpoint, the
* url will contain a 'code' and other information.
*
* This function takes the url and validate the response. If the user
* redirected back with an error, an error will be thrown.
*/
validateResponse(e, t) {
e = new URL(e);
let n = e.searchParams;
if (!n.has("code") && !n.has("error") && e.hash.length > 0 && (n = new URLSearchParams(e.hash.slice(1))), n.has("error"))
throw new f(
n.get("error_description") ?? "OAuth2 error",
n.get("error")
);
if (!n.has("code")) throw new Error(`The url did not contain a code parameter ${e}`);
if (t.state && t.state !== n.get("state"))
throw new Error(`The "state" parameter in the url did not match the expected value of ${t.state}`);
return {
code: n.get("code"),
scope: n.has("scope") ? n.get("scope").split(" ") : void 0
};
}
/**
* Receives an OAuth2 token using 'authorization_code' grant
*/
async getToken(e) {
const t = {
grant_type: "authorization_code",
code: e.code,
redirect_uri: e.redirectUri,
code_verifier: e.codeVerifier,
resource: e.resource
};
return this.client.tokenResponseToOAuth2Token(this.client.request("tokenEndpoint", t));
}
}
async function A() {
const s = await p(), e = new Uint8Array(32);
return s.getRandomValues(e), w(e);
}
async function y(s) {
const e = await p();
return ["S256", w(await e.subtle.digest("SHA-256", _(s)))];
}
async function p() {
var e;
if (typeof window < "u" && window.crypto) {
if (!((e = window.crypto.subtle) != null && e.digest))
throw new Error(
"The context/environment is not secure, and does not support the 'crypto.subtle' module. See: https://developer.mozilla.org/en-US/docs/Web/API/Crypto/subtle for details"
);
return window.crypto;
}
return typeof self < "u" && self.crypto ? self.crypto : (await Promise.resolve().then(() => v)).webcrypto;
}
function _(s) {
const e = new Uint8Array(s.length);
for (let t = 0; t < s.length; t++)
e[t] = s.charCodeAt(t) & 255;
return e;
}
function w(s) {
return btoa(String.fromCharCode(...new Uint8Array(s))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
class E {
constructor(e) {
this.discoveryDone = !1, this.serverMetadata = null, e != null && e.fetch || (e.fetch = fetch.bind(globalThis)), this.settings = e;
}
/**
* Refreshes an existing token, and returns a new one.
*/
async refreshToken(e, t) {
if (!e.refreshToken)
throw new Error("This token didn't have a refreshToken. It's not possible to refresh this");
const n = {
grant_type: "refresh_token",
refresh_token: e.refreshToken
};
this.settings.clientSecret || (n.client_id = this.settings.clientId), t != null && t.scope && (n.scope = t.scope.join(" ")), t != null && t.resource && (n.resource = t.resource);
const r = await this.tokenResponseToOAuth2Token(this.request("tokenEndpoint", n));
return !r.refreshToken && e.refreshToken && (r.refreshToken = e.refreshToken), r;
}
/**
* Retrieves an OAuth2 token using the client_credentials grant.
*/
async clientCredentials(e) {
var r;
const t = ["client_id", "client_secret", "grant_type", "scope"];
if (e != null && e.extraParams && Object.keys(e.extraParams).filter((o) => t.includes(o)).length > 0)
throw new Error(`The following extraParams are disallowed: '${t.join("', '")}'`);
const n = {
grant_type: "client_credentials",
scope: (r = e == null ? void 0 : e.scope) == null ? void 0 : r.join(" "),
resource: e == null ? void 0 : e.resource,
...e == null ? void 0 : e.extraParams
};
if (!this.settings.clientSecret)
throw new Error("A clientSecret must be provided to use client_credentials");
return this.tokenResponseToOAuth2Token(this.request("tokenEndpoint", n));
}
/**
* Retrieves an OAuth2 token using the 'password' grant'.
*/
async password(e) {
var n;
const t = {
grant_type: "password",
...e,
scope: (n = e.scope) == null ? void 0 : n.join(" ")
};
return this.tokenResponseToOAuth2Token(this.request("tokenEndpoint", t));
}
/**
* Returns the helper object for the `authorization_code` grant.
*/
get authorizationCode() {
return new g(
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(e) {
const t = {
token: e.accessToken,
token_type_hint: "access_token"
};
return this.request("introspectionEndpoint", t);
}
/**
* Revoke a token
*
* This will revoke a token, provided that the server supports this feature.
*
* @see https://datatracker.ietf.org/doc/html/rfc7009
*/
async revoke(e, t = "access_token") {
let n = e.accessToken;
t === "refresh_token" && (n = e.refreshToken);
const r = {
token: n,
token_type_hint: t
};
return this.request("revocationEndpoint", r);
}
/**
* Returns a url for an OAuth2 endpoint.
*
* Potentially fetches a discovery document to get it.
*/
async getEndpoint(e) {
if (this.settings[e] !== void 0)
return h(this.settings[e], this.settings.server);
if (e !== "discoveryEndpoint" && (await this.discover(), this.settings[e] !== void 0))
return h(this.settings[e], this.settings.server);
if (!this.settings.server)
throw new Error(`Could not determine the location of ${e}. Either specify ${e} in the settings, or the "server" endpoint to let the client discover it.`);
switch (e) {
case "authorizationEndpoint":
return h("/authorize", this.settings.server);
case "tokenEndpoint":
return h("/token", this.settings.server);
case "discoveryEndpoint":
return h("/.well-known/oauth-authorization-server", this.settings.server);
case "introspectionEndpoint":
return h("/introspect", this.settings.server);
case "revocationEndpoint":
return h("/revoke", this.settings.server);
}
}
/**
* Fetches the OAuth2 discovery document
*/
async discover() {
var r;
if (this.discoveryDone) return;
this.discoveryDone = !0;
let e;
try {
e = await this.getEndpoint("discoveryEndpoint");
} catch {
console.warn('[oauth2] OAuth2 discovery endpoint could not be determined. Either specify the "server" or "discoveryEndpoint');
return;
}
const t = await this.settings.fetch(e, { headers: { Accept: "application/json" } });
if (!t.ok) return;
if (!((r = t.headers.get("Content-Type")) != null && r.startsWith("application/json"))) {
console.warn("[oauth2] OAuth2 discovery endpoint was not a JSON response. Response is ignored");
return;
}
this.serverMetadata = await t.json();
const n = [
["authorization_endpoint", "authorizationEndpoint"],
["token_endpoint", "tokenEndpoint"],
["introspection_endpoint", "introspectionEndpoint"],
["revocation_endpoint", "revocationEndpoint"]
];
if (this.serverMetadata !== null) {
for (const [o, i] of n)
this.serverMetadata[o] && (this.settings[i] = h(this.serverMetadata[o], e));
if (this.serverMetadata.token_endpoint_auth_methods_supported && !this.settings.authenticationMethod) {
for (const o of this.serverMetadata.token_endpoint_auth_methods_supported)
if (o === "client_secret_basic" || o === "client_secret_post") {
this.settings.authenticationMethod = o;
break;
}
}
}
}
async request(e, t) {
const n = await this.getEndpoint(e), r = {
"Content-Type": "application/x-www-form-urlencoded",
// Although it shouldn't be needed, Github OAUth2 will return JSON
// unless this is set.
Accept: "application/json"
};
let o = this.settings.authenticationMethod;
switch (this.settings.clientSecret || (o = "client_secret_post"), o || (o = "client_secret_basic_interop"), o) {
case "client_secret_basic":
r.Authorization = "Basic " + btoa(l(this.settings.clientId) + ":" + l(this.settings.clientSecret));
break;
case "client_secret_basic_interop":
r.Authorization = "Basic " + btoa(this.settings.clientId.replace(/:/g, "%3A") + ":" + this.settings.clientSecret.replace(/:/g, "%3A"));
break;
case "client_secret_post":
t.client_id = this.settings.clientId, this.settings.clientSecret && (t.client_secret = this.settings.clientSecret);
break;
default:
throw new Error("Authentication method not yet supported:" + o + ". Open a feature request if you want this!");
}
const i = await this.settings.fetch(n, {
method: "POST",
body: T(t),
headers: r
});
let c;
if (i.status !== 204 && i.headers.has("Content-Type") && i.headers.get("Content-Type").match(/^application\/(.*\+)?json/) && (c = await i.json()), i.ok)
return c;
let a, d;
throw c != null && c.error ? (a = "OAuth2 error " + c.error + ".", c.error_description && (a += " " + c.error_description), d = c.error) : (a = "HTTP Error " + i.status + " " + i.statusText, i.status === 401 && this.settings.clientSecret && (a += ". It's likely that the clientId and/or clientSecret was incorrect"), d = null), new k(a, d, i, c);
}
/**
* Converts the JSON response body from the token endpoint to an OAuth2Token type.
*/
async tokenResponseToOAuth2Token(e) {
const t = await e;
if (!(t != null && t.access_token))
throw console.warn("Invalid OAuth2 Token Response: ", t), new TypeError("We received an invalid token response from an OAuth2 server.");
const {
access_token: n,
refresh_token: r,
expires_in: o,
id_token: i,
scope: c,
token_type: a,
...d
} = t, u = {
accessToken: n,
expiresAt: o ? Date.now() + o * 1e3 : null,
refreshToken: r ?? null
};
return i && (u.idToken = i), c && (u.scope = c.split(" ")), Object.keys(d).length > 0 && (u.extraParams = d), u;
}
}
function h(s, e) {
return new URL(s, e).toString();
}
function T(s) {
const e = new URLSearchParams();
for (const [t, n] of Object.entries(s))
if (Array.isArray(n))
for (const r of n) e.append(t, r);
else n !== void 0 && e.set(t, n.toString());
return e.toString();
}
function l(s) {
return encodeURIComponent(s).replace(/%20/g, "+").replace(/[-_.!~*'()]/g, (e) => `%${e.charCodeAt(0).toString(16).toUpperCase()}`);
}
class b {
constructor(e) {
this.token = null, this.activeGetStoredToken = null, this.activeRefresh = null, this.refreshTimer = null, (e == null ? void 0 : e.scheduleRefresh) === void 0 && (e.scheduleRefresh = !0), this.options = e, e.getStoredToken && (this.activeGetStoredToken = (async () => {
this.token = await e.getStoredToken(), this.activeGetStoredToken = null;
})()), this.scheduleRefresh();
}
/**
* Does a fetch request and adds a Bearer / access token.
*
* If the access token is not known, this function attempts to fetch it
* first. If the access token is almost expiring, this function might attempt
* to refresh it.
*/
async fetch(e, t) {
const n = new Request(e, t);
return this.mw()(
n,
(r) => fetch(r)
);
}
/**
* This function allows the fetch-mw to be called as more traditional
* middleware.
*
* This function returns a middleware function with the signature
* (request, next): Response
*/
mw() {
return async (e, t) => {
const n = await this.getAccessToken();
let r = e.clone();
r.headers.set("Authorization", "Bearer " + n);
let o = await t(r);
if (!o.ok && o.status === 401) {
const i = await this.refreshToken();
r = e.clone(), r.headers.set("Authorization", "Bearer " + i.accessToken), o = await t(r);
}
return o;
};
}
/**
* Returns current token information.
*
* There result object will have:
* * accessToken
* * expiresAt - when the token expires, or null.
* * refreshToken - may be null
*
* This function will attempt to automatically refresh if stale.
*/
async getToken() {
return this.token && (this.token.expiresAt === null || this.token.expiresAt > Date.now()) ? this.token : this.refreshToken();
}
/**
* Returns an access token.
*
* If the current access token is not known, it will attempt to fetch it.
* If the access token is expiring, it will attempt to refresh it.
*/
async getAccessToken() {
return await this.activeGetStoredToken, (await this.getToken()).accessToken;
}
/**
* Forces an access token refresh
*/
async refreshToken() {
var t, n;
if (this.activeRefresh)
return this.activeRefresh;
const e = this.token;
this.activeRefresh = (async () => {
var o, i;
let r = null;
try {
e != null && e.refreshToken && (r = await this.options.client.refreshToken(e));
} catch {
console.warn("[oauth2] refresh token not accepted, we'll try reauthenticating");
}
if (r || (r = await this.options.getNewToken()), !r) {
const c = new Error("Unable to obtain OAuth2 tokens, a full reauth may be needed");
throw (i = (o = this.options).onError) == null || i.call(o, c), c;
}
return r;
})();
try {
const r = await this.activeRefresh;
return this.token = r, (n = (t = this.options).storeToken) == null || n.call(t, r), this.scheduleRefresh(), r;
} catch (r) {
throw this.options.onError && this.options.onError(r), r;
} finally {
this.activeRefresh = null;
}
}
scheduleRefresh() {
var t;
if (!this.options.scheduleRefresh || (this.refreshTimer && (clearTimeout(this.refreshTimer), this.refreshTimer = null), !((t = this.token) != null && t.expiresAt) || !this.token.refreshToken))
return;
const e = this.token.expiresAt - Date.now();
e < 120 * 1e3 || (this.refreshTimer = setTimeout(async () => {
try {
await this.refreshToken();
} catch (n) {
console.error("[fetch-mw-oauth2] error while doing a background OAuth2 auto-refresh", n);
}
}, e - 60 * 1e3));
}
}
const v = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
__proto__: null
}, Symbol.toStringTag, { value: "Module" }));
export {
g as OAuth2AuthorizationCodeClient,
E as OAuth2Client,
f as OAuth2Error,
b as OAuth2Fetch,
k as OAuth2HttpError,
A as generateCodeVerifier
};