UNPKG

@badgateway/oauth2-client

Version:

OAuth2 client for browsers and Node.js. Tiny footprint, PKCE support

444 lines (443 loc) 16.2 kB
class l extends Error { constructor(e, t) { super(e), this.oauth2Code = t; } } class w extends l { constructor(e, t, r, n) { super(e, t), this.httpCode = r.status, this.response = r, this.parsedBody = n; } } class k { 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, r ] = await Promise.all([ e.codeVerifier ? g(e.codeVerifier) : void 0, this.client.getEndpoint("authorizationEndpoint") ]), n = new URLSearchParams({ client_id: this.client.settings.clientId, response_type: "code", redirect_uri: e.redirectUri }); if (t && (n.set("code_challenge_method", t[0]), n.set("code_challenge", t[1])), e.state && n.set("state", e.state), e.scope && n.set("scope", e.scope.join(" ")), e.resource) for (const o of [].concat(e.resource)) n.append("resource", o); if (e.responseMode && e.responseMode !== "query" && n.append("response_mode", e.responseMode), e.extraParams) for (const [o, i] of Object.entries(e.extraParams)) { if (n.has(o)) throw new Error(`Property in extraParams would overwrite standard property: ${o}`); n.set(o, i); } return r + "?" + n.toString(); } async getTokenFromCodeRedirect(e, t) { const { code: r } = this.validateResponse(e, { state: t.state }); return this.getToken({ code: r, 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 r = e.searchParams; if (!r.has("code") && !r.has("error") && e.hash.length > 0 && (r = new URLSearchParams(e.hash.slice(1))), r.has("error")) throw new l( r.get("error_description") ?? "OAuth2 error", r.get("error") ); if (!r.has("code")) throw new Error(`The url did not contain a code parameter ${e}`); if (t.state && t.state !== r.get("state")) throw new Error(`The "state" parameter in the url did not match the expected value of ${t.state}`); return { code: r.get("code"), scope: r.has("scope") ? r.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 v() { const s = await f(), e = new Uint8Array(32); return s.getRandomValues(e), p(e); } async function g(s) { const e = await f(); return ["S256", p(await e.subtle.digest("SHA-256", y(s)))]; } async function f() { 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(() => T)).webcrypto; } function y(s) { const e = new Uint8Array(s.length); for (let t = 0; t < s.length; t++) e[t] = s.charCodeAt(t) & 255; return e; } function p(s) { return btoa(String.fromCharCode(...new Uint8Array(s))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } class A { 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 r = { grant_type: "refresh_token", refresh_token: e.refreshToken }; this.settings.clientSecret || (r.client_id = this.settings.clientId), t != null && t.scope && (r.scope = t.scope.join(" ")), t != null && t.resource && (r.resource = t.resource); const n = await this.tokenResponseToOAuth2Token(this.request("tokenEndpoint", r)); return !n.refreshToken && e.refreshToken && (n.refreshToken = e.refreshToken), n; } /** * Retrieves an OAuth2 token using the client_credentials grant. */ async clientCredentials(e) { var n; 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 r = { grant_type: "client_credentials", scope: (n = e == null ? void 0 : e.scope) == null ? void 0 : n.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", r)); } /** * Retrieves an OAuth2 token using the 'password' grant'. */ async password(e) { var r; const t = { grant_type: "password", ...e, scope: (r = e.scope) == null ? void 0 : r.join(" ") }; return this.tokenResponseToOAuth2Token(this.request("tokenEndpoint", t)); } /** * Returns the helper object for the `authorization_code` grant. */ get authorizationCode() { return new k( 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 r = e.accessToken; t === "refresh_token" && (r = e.refreshToken); const n = { token: r, token_type_hint: t }; return this.request("revocationEndpoint", n); } /** * 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 n; 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 (!((n = t.headers.get("Content-Type")) != null && n.startsWith("application/json"))) { console.warn("[oauth2] OAuth2 discovery endpoint was not a JSON response. Response is ignored"); return; } this.serverMetadata = await t.json(); const r = [ ["authorization_endpoint", "authorizationEndpoint"], ["token_endpoint", "tokenEndpoint"], ["introspection_endpoint", "introspectionEndpoint"], ["revocation_endpoint", "revocationEndpoint"] ]; if (this.serverMetadata !== null) { for (const [o, i] of r) 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 r = await this.getEndpoint(e), n = { "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": n.Authorization = "Basic " + btoa(u(this.settings.clientId) + ":" + u(this.settings.clientSecret)); break; case "client_secret_basic_interop": n.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(r, { method: "POST", body: _(t), headers: n }); 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 w(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 r = { accessToken: t.access_token, expiresAt: t.expires_in ? Date.now() + t.expires_in * 1e3 : null, refreshToken: t.refresh_token ?? null }; return t.id_token && (r.idToken = t.id_token), r; } } function h(s, e) { return new URL(s, e).toString(); } function _(s) { const e = new URLSearchParams(); for (const [t, r] of Object.entries(s)) if (Array.isArray(r)) for (const n of r) e.append(t, n); else r !== void 0 && e.set(t, r.toString()); return e.toString(); } function u(s) { return encodeURIComponent(s).replace(/%20/g, "+").replace(/[-_.!~*'()]/g, (e) => `%${e.charCodeAt(0).toString(16).toUpperCase()}`); } class E { 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 r = new Request(e, t); return this.mw()( r, (n) => fetch(n) ); } /** * 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 r = await this.getAccessToken(); let n = e.clone(); n.headers.set("Authorization", "Bearer " + r); let o = await t(n); if (!o.ok && o.status === 401) { const i = await this.refreshToken(); n = e.clone(), n.headers.set("Authorization", "Bearer " + i.accessToken), o = await t(n); } 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, r; if (this.activeRefresh) return this.activeRefresh; const e = this.token; this.activeRefresh = (async () => { var o, i; let n = null; try { e != null && e.refreshToken && (n = await this.options.client.refreshToken(e)); } catch { console.warn("[oauth2] refresh token not accepted, we'll try reauthenticating"); } if (n || (n = await this.options.getNewToken()), !n) { 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 n; })(); try { const n = await this.activeRefresh; return this.token = n, (r = (t = this.options).storeToken) == null || r.call(t, n), this.scheduleRefresh(), n; } catch (n) { throw this.options.onError && this.options.onError(n), n; } 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 (r) { console.error("[fetch-mw-oauth2] error while doing a background OAuth2 auto-refresh", r); } }, e - 60 * 1e3)); } } const T = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null }, Symbol.toStringTag, { value: "Module" })); export { k as OAuth2AuthorizationCodeClient, A as OAuth2Client, l as OAuth2Error, E as OAuth2Fetch, w as OAuth2HttpError, v as generateCodeVerifier };