rapidoc
Version:
RapiDoc - Open API spec viewer with built in console
1,273 lines (1,222 loc) • 47.8 kB
JavaScript
/**
* Renders the authentication modal dialog supporting API Keys, HTTP Basic/Bearer, OAuth2 flows, and OpenID Connect.
*/
import { html } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { sanitizeHTML } from "../utils/sanitize.js";
import { oauthReceiverUrl } from "../utils/url-utils.js";
import {
OAUTH_CHANNEL,
isExpectedOAuthReply,
pkceChallenge,
randomUrlSafe,
} from "../utils/oauth-utils.js";
import { marked } from "marked";
const localStorageKey = "rapidoc";
function toBase64(str) {
try {
const bytes = new TextEncoder().encode(str);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
} catch {
return btoa(str);
}
}
function normalizeParams(params) {
const result = new URLSearchParams();
if (!params) {
return result;
}
if (params instanceof URLSearchParams) {
for (const [k, v] of params.entries()) {
result.set(k, v);
}
return result;
}
if (typeof params === "object") {
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null) {
result.set(k, String(v));
}
});
return result;
}
if (typeof params === "string") {
const trimmed = params.trim();
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
try {
const obj = JSON.parse(trimmed);
if (typeof obj === "object" && obj !== null) {
Object.entries(obj).forEach(([k, v]) => {
if (v !== undefined && v !== null) {
result.set(k, String(v));
}
});
return result;
}
} catch {
// Not valid JSON, fall back to URLSearchParams
}
}
const cleanStr = trimmed.startsWith("?") ? trimmed.slice(1) : trimmed;
const parsed = new URLSearchParams(cleanStr);
for (const [k, v] of parsed.entries()) {
result.set(k, v);
}
return result;
}
return result;
}
/**
* Applies a key to a security scheme so Try sends it. `note` (from the page hosting RapiDoc, through setApiKey) says
* where the key came from, e.g. the reader's sign-in: the scheme then shows the note instead of its own form until
* the reader asks for the form. Any later change of the key (form, GET TOKEN, REMOVE, CLEAR ALL) drops the note.
*/
export function applyApiKey(
securitySchemeId,
username = "",
password = "",
providedApikeyVal = "",
note = "",
) {
const securityObj = this.resolvedSpec.securitySchemes?.find(
(v) => v.securitySchemeId === securitySchemeId,
);
if (!securityObj) {
return false;
}
let finalApiKeyValue = "";
if (securityObj.scheme?.toLowerCase() === "basic") {
if (username) {
finalApiKeyValue = `Basic ${toBase64(`${username}:${password}`)}`;
}
} else if (providedApikeyVal) {
securityObj.value = providedApikeyVal;
finalApiKeyValue = `${securityObj.scheme?.toLowerCase() === "bearer" ? "Bearer " : ""}${providedApikeyVal}`;
}
if (finalApiKeyValue) {
securityObj.finalKeyValue = finalApiKeyValue;
securityObj.hostNote = note ? String(note) : "";
securityObj.hostFormOpen = false;
this.requestUpdate();
return true;
}
return false;
}
export function onClearAllApiKeys() {
this.resolvedSpec.securitySchemes?.forEach((v) => {
v.user = "";
v.password = "";
v.value = "";
v.finalKeyValue = "";
v.hostNote = "";
});
if (this.persistAuth === "true") {
setPersistedApiKeys.call(this, {});
}
this.requestUpdate();
}
function getPersistedApiKeys() {
return JSON.parse(localStorage.getItem(localStorageKey)) || {};
}
function setPersistedApiKeys(obj) {
localStorage.setItem(localStorageKey, JSON.stringify(obj));
}
export function recoverPersistedApiKeys() {
const rapidocLs = getPersistedApiKeys.call(this);
Object.values(rapidocLs).forEach((p) => {
applyApiKey.call(this, p.securitySchemeId, "", "", p.value);
});
}
function onApiKeyChange(securitySchemeId) {
let apiKeyValue = "";
const securityObj = this.resolvedSpec.securitySchemes.find(
(v) => v.securitySchemeId === securitySchemeId,
);
if (securityObj) {
const schemeEl = this.shadowRoot.getElementById(`security-scheme-${securitySchemeId}`);
if (schemeEl) {
if (
securityObj.type &&
securityObj.scheme &&
securityObj.type === "http" &&
securityObj.scheme.toLowerCase() === "basic"
) {
const userVal = schemeEl.querySelector(".api-key-user").value.trim();
const passwordVal = schemeEl.querySelector(".api-key-password").value.trim();
applyApiKey.call(this, securitySchemeId, userVal, passwordVal);
} else {
apiKeyValue = schemeEl.querySelector(".api-key-input").value.trim();
applyApiKey.call(this, securitySchemeId, "", "", apiKeyValue);
}
if (this.persistAuth === "true") {
// Only what recoverPersistedApiKeys restores: the apiKey / bearer value. Basic credentials
// (and the derived Authorization header) are never written to localStorage.
const rapidocLs = getPersistedApiKeys.call(this);
if (securityObj.value) {
rapidocLs[securitySchemeId] = { securitySchemeId, value: securityObj.value };
} else {
delete rapidocLs[securitySchemeId];
}
setPersistedApiKeys.call(this, rapidocLs);
}
}
}
}
/** Shows a status line under an OAuth flow. Always as text: provider error messages are never HTML. */
function showOAuthStatus(el, text, colorVar) {
if (!el) {
return;
}
const span = document.createElement("span");
span.style.color = `var(${colorVar})`;
span.textContent = text;
el.replaceChildren(span);
}
// Updates the OAuth Access Token (API key), so it reflects in UI and gets used in TRY calls
function updateOAuthKey(securitySchemeId, accessToken, tokenType = "Bearer") {
const securityObj = this.resolvedSpec.securitySchemes.find(
(v) => v.securitySchemeId === securitySchemeId,
);
securityObj.finalKeyValue = `${tokenType.toLowerCase() === "bearer" ? "Bearer" : tokenType.toLowerCase() === "mac" ? "MAC" : tokenType} ${accessToken}`;
securityObj.hostNote = "";
this.requestUpdate();
}
// Gets Access-Token in exchange of Authorization Code
async function fetchAccessToken(
tokenUrl,
clientId,
clientSecret,
redirectUrl,
grantType,
authCode,
securitySchemeId,
authFlowDivEl,
sendClientSecretIn = "request-body",
scopes = null,
username = null,
password = null,
tokenParamsSpec = null,
codeVerifier = null,
) {
const respDisplayEl = authFlowDivEl
? authFlowDivEl.querySelector(".oauth-resp-display")
: undefined;
const urlFormParams = new URLSearchParams();
const headers = new Headers();
// Merge vendor extension x-token-params (if any)
const specTokenParams = normalizeParams(tokenParamsSpec);
for (const [k, v] of specTokenParams.entries()) {
urlFormParams.set(k, v);
}
// Merge additional-token-params property/attribute on <rapi-doc> (if any)
const attrTokenParams = normalizeParams(this.additionalTokenParams);
for (const [k, v] of attrTokenParams.entries()) {
urlFormParams.set(k, v);
}
urlFormParams.set("grant_type", grantType);
if (grantType !== "client_credentials" && grantType !== "password") {
urlFormParams.set("redirect_uri", redirectUrl);
}
if (authCode) {
urlFormParams.set("code", authCode);
if (codeVerifier) {
urlFormParams.set("code_verifier", codeVerifier); // PKCE: proves this page started the sign-in
}
}
if (sendClientSecretIn === "header" && clientSecret) {
headers.set("Authorization", `Basic ${toBase64(`${clientId}:${clientSecret}`)}`);
} else {
if (clientId) {
urlFormParams.set("client_id", clientId);
}
if (clientSecret) {
urlFormParams.set("client_secret", clientSecret);
}
}
if (grantType === "password") {
urlFormParams.set("username", username);
urlFormParams.set("password", password);
}
if (scopes) {
urlFormParams.set("scope", scopes);
}
const requestDetail = {
method: "POST",
headers,
body: urlFormParams,
};
const beforeTokenEvent = new CustomEvent("before-token-request", {
bubbles: true,
composed: true,
cancelable: true,
detail: {
securitySchemeId,
grantType,
tokenUrl,
params: urlFormParams,
headers,
request: requestDetail,
},
});
const notCancelled = this.dispatchEvent(beforeTokenEvent);
if (!notCancelled) {
return false;
}
try {
const resp = await fetch(tokenUrl, {
method: requestDetail.method || "POST",
headers: requestDetail.headers || headers,
body: requestDetail.body || urlFormParams,
});
const tokenResp = await resp.json();
if (resp.ok) {
if (tokenResp.token_type && tokenResp.access_token) {
updateOAuthKey.call(this, securitySchemeId, tokenResp.access_token, tokenResp.token_type);
showOAuthStatus(respDisplayEl, "Access Token Received", "--green");
return true;
}
} else {
showOAuthStatus(
respDisplayEl,
tokenResp.error_description || tokenResp.error || "Unable to get access token",
"--destructive",
);
return false;
}
} catch {
showOAuthStatus(respDisplayEl, "Failed to get access token", "--destructive");
return false;
}
}
/**
* Handles the provider's reply to this sign-in (already checked by waitForOAuthReply): exchanges a code for a
* token, or applies a token (implicit flow). Accepts the older receiver's key names too.
*/
function onOAuthReply(reply, flow) {
const respDisplayEl = flow.authFlowDivEl
? flow.authFlowDivEl.querySelector(".oauth-resp-display")
: undefined;
if (reply.error) {
showOAuthStatus(respDisplayEl, reply.errorDescription || reply.error, "--destructive");
return;
}
if (reply.responseType === "code") {
fetchAccessToken.call(
this,
flow.tokenUrl,
flow.clientId,
flow.clientSecret,
flow.redirectUrl,
flow.grantType,
reply.code,
flow.securitySchemeId,
flow.authFlowDivEl,
flow.sendClientSecretIn,
null,
null,
null,
flow.tokenParamsSpec,
flow.codeVerifier,
);
} else {
const accessToken = reply.accessToken || reply.access_token;
if (accessToken) {
updateOAuthKey.call(
this,
flow.securitySchemeId,
accessToken,
reply.tokenType || reply.token_type || "Bearer",
);
}
}
}
/**
* Waits for the return page's reply to this sign-in: a window message from the popup, or a BroadcastChannel
* message when the provider's page cut the popup's link to this window (Cross-Origin-Opener-Policy). Anything
* that isn't the reply to this sign-in (other origin, other window, other state) is ignored, so it can neither
* inject a code or token nor end the wait. Starting another sign-in stops the previous wait.
*/
function waitForOAuthReply(popup, state, onReply) {
if (typeof this.stopOAuthWait === "function") {
this.stopOAuthWait();
}
const channel =
typeof BroadcastChannel === "function" ? new BroadcastChannel(OAUTH_CHANNEL) : null;
let onMessage;
const stop = () => {
window.removeEventListener("message", onMessage);
if (channel) {
channel.close();
}
if (this.stopOAuthWait === stop) {
this.stopOAuthWait = null;
}
};
const finish = (reply) => {
stop();
try {
popup.close();
} catch {
// already closed
}
onReply(reply);
};
onMessage = (msgEvent) => {
if (
isExpectedOAuthReply(
msgEvent.data,
{ state, origin: window.location.origin },
msgEvent.origin,
msgEvent.source,
popup,
)
) {
finish(msgEvent.data);
}
};
window.addEventListener("message", onMessage);
if (channel) {
channel.onmessage = (msgEvent) => {
if (isExpectedOAuthReply(msgEvent.data, { state })) {
finish(msgEvent.data);
}
};
}
this.stopOAuthWait = stop;
}
async function onInvokeOAuthFlow(
securitySchemeId,
flowType,
authUrl,
tokenUrl,
e,
authorizeParamsSpec = null,
tokenParamsSpec = null,
) {
const authFlowDivEl = e.target.closest(".oauth-flow");
const clientId = authFlowDivEl.querySelector(".oauth-client-id")
? authFlowDivEl.querySelector(".oauth-client-id").value.trim()
: "";
const clientSecret = authFlowDivEl.querySelector(".oauth-client-secret")
? authFlowDivEl.querySelector(".oauth-client-secret").value.trim()
: "";
const username = authFlowDivEl.querySelector(".api-key-user")
? authFlowDivEl.querySelector(".api-key-user").value.trim()
: "";
const password = authFlowDivEl.querySelector(".api-key-password")
? authFlowDivEl.querySelector(".api-key-password").value.trim()
: "";
const sendClientSecretIn = authFlowDivEl.querySelector(".oauth-send-client-secret-in")
? authFlowDivEl.querySelector(".oauth-send-client-secret-in").value.trim()
: "request-body";
const checkedScopeEls = [...authFlowDivEl.querySelectorAll(".scope-checkbox:checked")];
const pkceCheckboxEl = authFlowDivEl.querySelector(`#${securitySchemeId}-pkce`);
// Unguessable per sign-in: state ties the reply to this sign-in, the PKCE verifier proves it at the token endpoint.
const state = randomUrlSafe(16);
const nonce = randomUrlSafe(16);
const usePkce = Boolean(pkceCheckboxEl && pkceCheckboxEl.checked);
const codeVerifier = usePkce ? randomUrlSafe(32) : null;
const redirectUrlObj = oauthReceiverUrl(window.location, this.oauthReceiver);
let grantType = "";
let responseType = "";
let newWindow;
// clear previous error messages
const errEls = [...authFlowDivEl.parentNode.querySelectorAll(".oauth-resp-display")];
errEls.forEach((v) => {
v.replaceChildren();
});
if (flowType === "authorizationCode" || flowType === "implicit") {
const authUrlObj = new URL(authUrl);
if (flowType === "authorizationCode") {
grantType = "authorization_code";
responseType = "code";
} else if (flowType === "implicit") {
responseType = "token";
}
const authCodeParams = new URLSearchParams(authUrlObj.search);
// Merge vendor extension x-authorize-params (if any)
const specAuthParams = normalizeParams(authorizeParamsSpec);
for (const [k, v] of specAuthParams.entries()) {
authCodeParams.set(k, v);
}
// Merge additional-authorize-params property/attribute on <rapi-doc> (if any)
const attrAuthParams = normalizeParams(this.additionalAuthorizeParams);
for (const [k, v] of attrAuthParams.entries()) {
authCodeParams.set(k, v);
}
const selectedScopes = checkedScopeEls.map((v) => v.value).join(" ");
if (selectedScopes) {
authCodeParams.set("scope", selectedScopes);
}
authCodeParams.set("client_id", clientId);
authCodeParams.set("redirect_uri", redirectUrlObj.toString());
authCodeParams.set("response_type", responseType);
authCodeParams.set("state", state);
authCodeParams.set("nonce", nonce);
if (codeVerifier) {
authCodeParams.set("code_challenge", await pkceChallenge(codeVerifier));
authCodeParams.set("code_challenge_method", "S256");
}
const beforeAuthorizeEvent = new CustomEvent("before-authorize", {
bubbles: true,
composed: true,
cancelable: true,
detail: {
securitySchemeId,
flowType,
authUrl,
authParams: authCodeParams,
},
});
const notCancelled = this.dispatchEvent(beforeAuthorizeEvent);
if (!notCancelled) {
return;
}
authUrlObj.search = authCodeParams.toString();
newWindow = window.open(authUrlObj.toString());
if (!newWindow) {
console.error(`RapiDoc: Unable to open ${authUrlObj.toString()} in a new window`);
showOAuthStatus(
authFlowDivEl.querySelector(".oauth-resp-display"),
"The sign-in window was blocked",
"--destructive",
);
return;
}
waitForOAuthReply.call(this, newWindow, state, (reply) =>
onOAuthReply.call(this, reply, {
tokenUrl,
clientId,
clientSecret,
redirectUrl: redirectUrlObj.toString(),
grantType,
sendClientSecretIn,
securitySchemeId,
authFlowDivEl,
tokenParamsSpec,
codeVerifier,
}),
);
} else if (flowType === "clientCredentials") {
grantType = "client_credentials";
const selectedScopes = checkedScopeEls.map((v) => v.value).join(" ");
fetchAccessToken.call(
this,
tokenUrl,
clientId,
clientSecret,
redirectUrlObj.toString(),
grantType,
"",
securitySchemeId,
authFlowDivEl,
sendClientSecretIn,
selectedScopes,
null,
null,
tokenParamsSpec,
);
} else if (flowType === "password") {
grantType = "password";
const selectedScopes = checkedScopeEls.map((v) => v.value).join(" ");
fetchAccessToken.call(
this,
tokenUrl,
clientId,
clientSecret,
redirectUrlObj.toString(),
grantType,
"",
securitySchemeId,
authFlowDivEl,
sendClientSecretIn,
selectedScopes,
username,
password,
tokenParamsSpec,
);
}
}
function oAuthFlowTemplate(
flowName,
clientId,
clientSecret,
securitySchemeId,
authFlow,
defaultScopes = [],
receiveTokenIn = "request-body",
receiveTokenInOptions = undefined,
allowTry = "true",
authorizeParams = null,
tokenParams = null,
) {
let { authorizationUrl, tokenUrl, refreshUrl } = authFlow;
const pkceOnly = authFlow["x-pkce-only"] || false;
const isUrlAbsolute = (url) => url.indexOf("://") > 0 || url.indexOf("//") === 0;
/*
Calculcate Relative URL based on the following logic
IF this.selectedServer?.computedUrl ends with slash and the refreshUrl / tokenUrl / authorizationUrl do not start with slash
THEN Relative URL is concatenate else use this.selectedServer?.computedUrl + refreshUrl / tokenUrl / authorizationUrl
ELSE Relative URL is concatenate else use (origin of this.selectedServer?.computedUrl) + refreshUrl / tokenUrl / authorizationUrl
*/
const url = new URL(this.selectedServer?.computedUrl);
const originUrl = url.origin;
if (refreshUrl && !isUrlAbsolute(refreshUrl)) {
if (
this.selectedServer?.computedUrl.trim().endsWith("/") &&
!refreshUrl.trim().startsWith("/")
) {
refreshUrl = `${this.selectedServer?.computedUrl.trim()}${refreshUrl.trim()}`;
} else {
refreshUrl = `${originUrl}/${refreshUrl.replace(/^\//, "")}`;
}
}
if (tokenUrl && !isUrlAbsolute(tokenUrl)) {
if (this.selectedServer?.computedUrl.trim().endsWith("/") && !tokenUrl.trim().startsWith("/")) {
tokenUrl = `${this.selectedServer?.computedUrl.trim()}${tokenUrl.trim()}`;
} else {
tokenUrl = `${originUrl}/${tokenUrl.replace(/^\//, "")}`;
}
}
if (authorizationUrl && !isUrlAbsolute(authorizationUrl)) {
if (
this.selectedServer?.computedUrl.trim().endsWith("/") &&
!authorizationUrl.trim().startsWith("/")
) {
authorizationUrl = `${this.selectedServer?.computedUrl.trim()}${authorizationUrl.trim()}`;
} else {
authorizationUrl = `${originUrl}/${authorizationUrl.replace(/^\//, "")}`;
}
}
let flowNameDisplay;
if (flowName === "authorizationCode") {
flowNameDisplay = "Authorization Code Flow";
} else if (flowName === "clientCredentials") {
flowNameDisplay = "Client Credentials Flow";
} else if (flowName === "implicit") {
flowNameDisplay = "Implicit Flow";
} else if (flowName === "password") {
flowNameDisplay = "Password Flow";
} else {
flowNameDisplay = flowName;
}
const showCredentials =
flowName === "authorizationCode" || flowName === "clientCredentials" || flowName === "password";
const isKnownFlow = showCredentials || flowName === "implicit";
return html`
<div class="oauth-flow ${flowName}">
<h3 class="rapidoc-heading label-heading tiny-title upper" part="heading heading-3">${flowNameDisplay}</h3>
${
authorizationUrl || tokenUrl || refreshUrl
? html`<dl class="oauth-urls">
${
authorizationUrl
? html`<dt>Auth URL</dt>
<dd class="mono-font">${authorizationUrl}</dd>`
: ""
}
${
tokenUrl
? html`<dt>Token URL</dt>
<dd class="mono-font">${tokenUrl}</dd>`
: ""
}
${
refreshUrl
? html`<dt>Refresh URL</dt>
<dd class="mono-font">${refreshUrl}</dd>`
: ""
}
</dl>`
: ""
}
${
isKnownFlow && authFlow.scopes
? html`<div class="oauth-scopes-label">Scopes</div>
<div class="oauth-scopes" part="section-auth-scopes">
${Object.entries(authFlow.scopes).map(
([scope, scopeDescr], index) =>
html`<label class="auth-toggle" for="${securitySchemeId}${flowName}${index}">
<input
type="checkbox"
part="checkbox checkbox-auth-scope"
class="scope-checkbox"
id="${securitySchemeId}${flowName}${index}"
?checked="${defaultScopes.includes(scope)}"
value="${scope}"
/>
<span
><span class="oauth-scope-name mono-font">${scope}</span>${
scope !== scopeDescr && scopeDescr
? html` <span class="oauth-scope-descr">${scopeDescr}</span>`
: ""
}</span
>
</label>`,
)}
</div>`
: ""
}
${
isKnownFlow && allowTry === "true"
? html`${
flowName === "authorizationCode"
? html`<label class="auth-toggle oauth-pkce" for="${securitySchemeId}-pkce">
<input
type="checkbox"
part="checkbox checkbox-auth-scope"
id="${securitySchemeId}-pkce"
checked
?disabled=${pkceOnly}
/>
<span>Send Proof Key for Code Exchange (PKCE)</span>
</label>`
: ""
}
${
flowName === "password"
? html`<div class="auth-controls">
<input
type="text"
value=""
placeholder="username"
spellcheck="false"
class="oauth2 ${flowName} ${securitySchemeId} api-key-user"
part="textbox textbox-username"
id="input-${securitySchemeId}-${flowName}-api-key-user"
/>
<input
type="password"
value=""
placeholder="password"
spellcheck="false"
class="oauth2 ${flowName} ${securitySchemeId} api-key-password"
part="textbox textbox-password"
id="input-${securitySchemeId}-${flowName}-api-key-password"
/>
</div>`
: ""
}
<div class="auth-controls">
<input
type="text"
part="textbox textbox-auth-client-id"
value="${clientId || ""}"
placeholder="client-id"
spellcheck="false"
class="oauth2 ${flowName} ${securitySchemeId} oauth-client-id"
/>
${
showCredentials
? html`<input
id="${securitySchemeId}-${flowName}-oauth-client-secret"
type="password"
part="textbox textbox-auth-client-secret"
value="${clientSecret || ""}"
placeholder="client-secret"
spellcheck="false"
class="oauth2 ${flowName} ${securitySchemeId} oauth-client-secret"
?hidden=${pkceOnly}
/>
<select
class="${flowName} ${securitySchemeId} oauth-send-client-secret-in"
aria-label="Send client secret in"
?hidden=${pkceOnly}
>
${
!receiveTokenInOptions || receiveTokenInOptions.includes("request-body")
? html`<option value="request-body" .selected=${receiveTokenIn === "request-body"}>Request Body</option>`
: ""
}
${
!receiveTokenInOptions || receiveTokenInOptions.includes("header")
? html`<option value="header" .selected=${receiveTokenIn === "header"}>Authorization Header</option>`
: ""
}
</select>`
: ""
}
<button
class="btn btn-outline"
part="btn btn-outline"
="${(e) => {
onInvokeOAuthFlow.call(
this,
securitySchemeId,
flowName,
authorizationUrl,
tokenUrl,
e,
authorizeParams,
tokenParams,
);
}}"
>
GET TOKEN
</button>
</div>
<div class="oauth-resp-display red-text small-font-size"></div>`
: ""
}
</div>
`;
}
function removeApiKey(securitySchemeId) {
const securityObj = this.resolvedSpec.securitySchemes?.find(
(v) => v.securitySchemeId === securitySchemeId,
);
securityObj.user = "";
securityObj.password = "";
securityObj.value = "";
securityObj.finalKeyValue = "";
securityObj.hostNote = "";
if (this.persistAuth === "true") {
const rapidocLs = getPersistedApiKeys.call(this);
delete rapidocLs[securityObj.securitySchemeId];
setPersistedApiKeys.call(this, rapidocLs);
}
this.requestUpdate();
}
export default function securitySchemeTemplate(allowTry = "true") {
if (!this.resolvedSpec) {
return "";
}
const providedApiKeys = this.resolvedSpec.securitySchemes?.filter((v) => v.finalKeyValue);
if (!providedApiKeys) {
return;
}
return html`
<section
id="auth"
part="section-auth"
style="text-align:left; direction:ltr; margin-top:24px; margin-bottom:24px;"
class="observe-me ${"read focused".includes(this.renderStyle) ? "section-gap--read-mode" : "section-gap "}"
>
<h2 class="rapidoc-heading sub-title regular-font" part="section-auth-title heading heading-2">Authentication</h2>
${
allowTry === "true"
? html`<div class="auth-status small-font-size">
${
providedApiKeys.length > 0
? html`<div class="blue-text">${providedApiKeys.length} API key applied</div>
<button
class="btn btn-outline"
part="btn btn-outline"
=${() => {
onClearAllApiKeys.call(this);
}}
>
CLEAR ALL API KEYS
</button>`
: html`<div class="red-text">No API key applied</div>`
}
</div>`
: ""
}
${
this.resolvedSpec.securitySchemes && this.resolvedSpec.securitySchemes.length > 0
? html` <ul class="auth-list" id="auth-list" part="auth-list" role="list">
${this.resolvedSpec.securitySchemes
.filter((v) => v.type)
.map((v) => {
// A key the host page applied with a note (e.g. the reader's sign-in): the note replaces the
// scheme's form, which stays one click away
const hostNote = allowTry === "true" && v.finalKeyValue ? v.hostNote : "";
const folded = hostNote && !v.hostFormOpen;
return html`
<li id="security-scheme-${v.securitySchemeId}" class="auth-scheme ${v.type.toLowerCase()}" part="auth-scheme">
<div class="auth-scheme-body">
<div class="auth-scheme-header">
<span class="auth-scheme-title">${v.typeDisplay}</span>
${
v.finalKeyValue
? html`<span class="blue-text">Key Applied</span>
<button
class="btn btn-outline btn-sm"
part="btn btn-outline"
=${() => {
removeApiKey.call(this, v.securitySchemeId);
}}
>
REMOVE
</button>`
: ""
}
</div>
${v.description ? html`<div class="m-markdown">${unsafeHTML(sanitizeHTML(marked(v.description || "")))}</div>` : ""}
${
hostNote
? html`<div class="auth-host-note" part="auth-host-note">${hostNote}</div>
<button
class="btn btn-link auth-host-toggle"
part="btn btn-link"
aria-expanded="${folded ? "false" : "true"}"
=${() => {
v.hostFormOpen = !v.hostFormOpen;
this.requestUpdate();
}}
>
${folded ? "Use a different token" : "Hide token form"}
</button>`
: ""
}
${
!folded && v.type.toLowerCase() === "apikey"
? html`<div class="auth-hint">Send <code>${v.name}</code> in <code>${v.in}</code></div>
${
allowTry === "true"
? v.in !== "cookie"
? html`<div class="auth-controls">
<input
type="text"
value="${v.value}"
class="${v.type} ${v.securitySchemeId} api-key-input"
placeholder="api-token"
spellcheck="false"
id="${v.type}-${v.securitySchemeId}-api-key-input"
/>
<button
class="btn btn-outline"
part="btn btn-outline"
="${() => {
onApiKeyChange.call(this, v.securitySchemeId);
}}"
>
${v.finalKeyValue ? "UPDATE" : "SET"}
</button>
</div>`
: html`<div class="auth-hint gray-text">Cookies cannot be set from here</div>`
: ""
}`
: ""
}
${
!folded &&
v.type.toLowerCase() === "http" &&
v.scheme?.toLowerCase() === "basic"
? html`<div class="auth-hint">
Send <code>Authorization</code> in <code>header</code> containing the word <code>Basic</code> followed by
a space and a base64 encoded string of <code>username:password</code>.
</div>
${
allowTry === "true"
? html`<div class="auth-controls">
<input
type="text"
value="${v.user}"
placeholder="username"
spellcheck="false"
class="${v.type} ${v.securitySchemeId} api-key-user"
id="input-${v.type}-${v.securitySchemeId}-api-key-user"
/>
<input
type="password"
value="${v.password}"
placeholder="password"
spellcheck="false"
class="${v.type} ${v.securitySchemeId} api-key-password"
id="input-${v.type}-${v.securitySchemeId}-api-key-password"
/>
<button
class="btn btn-outline"
="${() => {
onApiKeyChange.call(this, v.securitySchemeId);
}}"
part="btn btn-outline"
>
${v.finalKeyValue ? "UPDATE" : "SET"}
</button>
</div>`
: ""
}`
: ""
}
${
!folded &&
v.type.toLowerCase() === "http" &&
v.scheme?.toLowerCase() === "bearer"
? html`<div class="auth-hint">
Send <code>Authorization</code> in <code>header</code> containing the word <code>Bearer</code> followed by
a space and token value
</div>
${
allowTry === "true"
? html`<div class="auth-controls">
<input
type="text"
value="${v.value}"
class="${v.type} ${v.securitySchemeId} api-key-input"
placeholder="api-token"
spellcheck="false"
id="${v.type}-${v.securitySchemeId}-api-key-input"
/>
<button
class="btn btn-outline"
part="btn btn-outline"
="${() => {
onApiKeyChange.call(this, v.securitySchemeId);
}}"
>
${v.finalKeyValue ? "UPDATE" : "SET"}
</button>
</div>`
: ""
}`
: ""
}
</div>
${
!folded && v.type.toLowerCase() === "oauth2"
? html`<div class="auth-flows" part="auth-flows">
${Object.keys(v.flows).map((f) =>
oAuthFlowTemplate.call(
this,
f,
v.flows[f]["x-client-id"] || v["x-client-id"] || "",
v.flows[f]["x-client-secret"] || v["x-client-secret"] || "",
v.securitySchemeId,
v.flows[f],
v.flows[f]["x-default-scopes"] || v["x-default-scopes"],
v.flows[f]["x-receive-token-in"] || v["x-receive-token-in"],
v.flows[f]["x-receive-token-in-options"] ||
v["x-receive-token-in-options"],
allowTry,
v.flows[f]["x-authorize-params"] ||
v["x-authorize-params"] ||
null,
v.flows[f]["x-token-params"] || v["x-token-params"] || null,
),
)}
</div>`
: ""
}
</li>
`;
})}
</ul>`
: ""
}
<slot name="auth"></slot>
</section>
`;
}
function renderSecuritySchemeDetail(andSecurityItem, isMultiple, j) {
const scopeHtml = andSecurityItem.scopes
? html` <div>
<b>Required scopes:</b><br />
<div style="margin-left:8px">
${andSecurityItem.scopes.split(",").map((scope, cnt) => html`${cnt === 0 ? "" : "┃"}<span>${scope.trim()}</span>`)}
</div>
</div>`
: "";
if (andSecurityItem.type === "oauth2") {
return html` <div>
${isMultiple ? html`<b>${j + 1}.</b> ` : "Needs "}OAuth Token
<span style="font-family:var(--font-mono); color:var(--primary);">${andSecurityItem.securitySchemeId}</span>
in <b>Authorization header</b>
${scopeHtml}
</div>`;
}
if (andSecurityItem.type === "http") {
return html` <div>
${isMultiple ? html`<b>${j + 1}.</b> ` : "Requires "}
${andSecurityItem.scheme === "basic" ? "Base 64 encoded username:password" : html`Bearer Token <b>${andSecurityItem.nameId}</b>`} in
<b>Authorization header</b>
${scopeHtml}
</div>`;
}
return html` <div>
${isMultiple ? html`<b>${j + 1}.</b> ` : "Requires "} Token in <b>${andSecurityItem.name} ${andSecurityItem.in}</b>
${scopeHtml}
</div>`;
}
/*
* Operation security chips: each chip shows its scheme details in a popover="hint" panel (a manual popover
* where "hint" is unsupported) on hover or keyboard focus. The panel renders in the top layer, so it is never
* clipped by scroll containers, and is anchored to its chip with CSS anchor positioning (JS fallback below).
*/
const supportsAnchorPositioning = typeof CSS !== "undefined" && CSS.supports?.("anchor-name: --a");
const securityHideTimers = new WeakMap();
let openSecurityPopover = null;
function onSecurityPopoverKeydown(e) {
if (e.key === "Escape" && openSecurityPopover) {
hideSecurityPopover(openSecurityPopover);
}
}
function hideSecurityPopover(popover) {
if (popover.matches(":popover-open")) {
popover.hidePopover();
}
if (openSecurityPopover === popover) {
openSecurityPopover = null;
document.removeEventListener("keydown", onSecurityPopoverKeydown);
}
}
// Fallback without CSS anchor positioning: places the panel below the chip (above when there is no room),
// left-aligned with it and kept inside the viewport
function placeSecurityPopover(chip, popover) {
const gap = 6;
const edge = 8;
const r = chip.getBoundingClientRect();
const { offsetWidth: w, offsetHeight: h } = popover;
const left = Math.max(edge, Math.min(r.left, window.innerWidth - w - edge));
const top =
r.bottom + gap + h > window.innerHeight && r.top - gap - h >= 0
? r.top - gap - h
: r.bottom + gap;
Object.assign(popover.style, { inset: "auto", margin: "0", left: `${left}px`, top: `${top}px` });
}
function showSecurityPopover(e) {
const chip = e.currentTarget.closest(".security-chip");
const popover = chip?.querySelector(".security-popover");
if (!popover || typeof popover.showPopover !== "function") {
return;
}
clearTimeout(securityHideTimers.get(chip));
if (openSecurityPopover && openSecurityPopover !== popover) {
hideSecurityPopover(openSecurityPopover);
}
if (!popover.matches(":popover-open")) {
popover.showPopover();
}
if (!supportsAnchorPositioning) {
placeSecurityPopover(chip, popover);
}
if (openSecurityPopover !== popover) {
openSecurityPopover = popover;
document.addEventListener("keydown", onSecurityPopoverKeydown);
}
}
// Delayed so the pointer can cross the gap into the panel; the panel stays open while the chip is hovered or focused
function scheduleHideSecurityPopover(e) {
const chip = e.currentTarget;
clearTimeout(securityHideTimers.get(chip));
securityHideTimers.set(
chip,
setTimeout(() => {
const popover = chip.querySelector(".security-popover");
if (popover && !chip.matches(":hover, :focus-within")) {
hideSecurityPopover(popover);
}
}, 150),
);
}
const lockIcon = html`<svg class="security-lock" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke-width="1.75">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>`;
export function pathSecurityTemplate(pathSecurity, elementId = "") {
if (
!this.resolvedSpec.securitySchemes ||
!Array.isArray(pathSecurity) ||
pathSecurity.length === 0
) {
return "";
}
const isOptional = pathSecurity.some((p) => !p || Object.keys(p).length === 0);
const activeSecurity = pathSecurity.filter((p) => p && Object.keys(p).length > 0);
if (activeSecurity.length === 0) {
return "";
}
const orSecurityKeys = [];
activeSecurity.forEach((pSecurity) => {
const andSecurityKeys = [];
const andKeyTypes = [];
Object.keys(pSecurity).forEach((pathSecurityKey) => {
let pathScopes = "";
const s = this.resolvedSpec.securitySchemes.find(
(ss) => ss.securitySchemeId === pathSecurityKey,
);
if (pSecurity[pathSecurityKey] && Array.isArray(pSecurity[pathSecurityKey])) {
pathScopes = pSecurity[pathSecurityKey].join(", ");
}
if (s) {
andKeyTypes.push(s.typeDisplay);
andSecurityKeys.push({ ...s, scopes: pathScopes });
}
});
const displayType =
andKeyTypes.length > 1
? `${andKeyTypes[0]} + ${andKeyTypes.length - 1} more`
: andKeyTypes[0];
orSecurityKeys.push({
securityTypes: isOptional ? `${displayType} (Optional)` : displayType,
securityDefs: andSecurityKeys,
});
});
const idPrefix = `${elementId || "operation"}--security`;
const anchorPrefix = `--${idPrefix.replace(/[^\w-]/g, "_")}`;
const isLink = this.updateRoute === "true" && this.allowAuthentication === "true";
return html`
<div class="operation-security" part="section-operation-security" role="group" aria-labelledby="${idPrefix}-label">
<span id="${idPrefix}-label" class="security-label visually-hidden" part="label-operation-security">Authorization</span>
${
isLink
? html`<a
class="security-lock-btn"
part="anchor anchor-operation-security"
href="#auth"
title="Go to Authentication"
aria-label="Authorization: go to Authentication section"
>${lockIcon}</a
>`
: html`<span class="security-lock-btn">${lockIcon}</span>`
}
<ul class="value-list-items security-chips" role="list">
${orSecurityKeys.map((orItem, i) => {
if (!orItem.securityTypes) {
return "";
}
const isMultiple = orItem.securityDefs.length > 1;
const hasDetails = orItem.securityDefs.length > 0;
const popoverId = `${idPrefix}-${i}`;
const anchorName = `${anchorPrefix}-${i}`;
// Chips only open their details popover; the lock before them links to the Authentication section
const labelHtml = hasDetails
? html`<button type="button" class="security-chip-label" aria-describedby="${popoverId}" ="${showSecurityPopover}">
${orItem.securityTypes}
</button>`
: html`<span class="security-chip-label">${orItem.securityTypes}</span>`;
return html`
<li>
<span
class="security-chip ${hasDetails ? "has-details" : ""}"
part="operation-security-chip"
style="anchor-name:${anchorName}"
="${showSecurityPopover}"
="${scheduleHideSecurityPopover}"
="${showSecurityPopover}"
="${scheduleHideSecurityPopover}"
>
${labelHtml}
${
hasDetails
? html`
<div
id="${popoverId}"
class="security-popover"
part="operation-security-details"
popover="hint"
role="tooltip"
style="position-anchor:${anchorName}"
>
${isMultiple ? html`<div>Requires <b>all</b> of the following:</div>` : ""}
<div style="padding-left:${isMultiple ? "8px" : "0"};">
${orItem.securityDefs.map((secDef, j) => renderSecuritySchemeDetail(secDef, isMultiple, j))}
</div>
</div>
`
: ""
}
</span>
</li>
`;
})}
</ul>
</div>
`;
}