client-side-oauth / index.js
coyotte508's picture
coyotte508 HF staff
Update index.js
497f0f2
raw
history blame
No virus
6.19 kB
const HUB_URL = "https://huggingface.co";
async function createApiError(res) {
throw new Error (await res.text());
}
function hexFromBytes(arr) {
if (globalThis.Buffer) {
return globalThis.Buffer.from(arr).toString("hex");
} else {
const bin = [];
arr.forEach((byte) => {
bin.push(byte.toString(16).padStart(2, "0"));
});
return bin.join("");
}
}
function base64FromBytes(arr) {
if (globalThis.Buffer) {
return globalThis.Buffer.from(arr).toString("base64");
} else {
const bin = [];
arr.forEach((byte) => {
bin.push(String.fromCharCode(byte));
});
return globalThis.btoa(bin.join(""));
}
}
/**
* Use "Sign in with Hub" to authenticate a user, and get oauth user info / access token.
*
* When called the first time, it will redirect the user to the Hub login page, which then redirects
* to the current URL (or custom URL set).
*
* When called the second time, after the redirect, it will check the query parameters and return
* the oauth user info / access token.
*
* If called inside an iframe, it will open a new window instead of redirecting the iframe, by default.
*
* When called from inside a static Space with OAuth enabled, it will load the config from the space.
*
* (Theoretically, this function could be used to authenticate a user for any OAuth provider supporting PKCE and OpenID Connect by changing `hubUrl`,
* but it is currently only tested with the Hugging Face Hub.)
*/
async function oauthLogin(opts) {
if (typeof window === "undefined") {
throw new Error("oauthLogin is only available in the browser");
}
console.log("localstorage before", JSON.parse(JSON.stringify(localStorage)));
const hubUrl = opts?.hubUrl || HUB_URL;
const openidConfigUrl = `${new URL(hubUrl).origin}/.well-known/openid-configuration`;
const openidConfigRes = await fetch(openidConfigUrl, {
headers: {
Accept: "application/json",
},
});
if (!openidConfigRes.ok) {
throw await createApiError(openidConfigRes);
}
const opendidConfig = await openidConfigRes.json();
const searchParams = new URLSearchParams(window.location.search);
const [error, errorDescription] = [searchParams.get("error"), searchParams.get("error_description")];
if (error) {
throw new Error(`${error}: ${errorDescription}`);
}
const code = searchParams.get("code");
const nonce = localStorage.getItem("huggingface.co:oauth:nonce");
if (code && !nonce) {
console.warn("Missing oauth nonce from localStorage");
}
if (code && nonce) {
const codeVerifier = localStorage.getItem("huggingface.co:oauth:code_verifier");
if (!codeVerifier) {
throw new Error("Missing oauth code_verifier from localStorage");
}
const state = searchParams.get("state");
if (!state) {
throw new Error("Missing oauth state from query parameters in redirected URL");
}
let parsedState;
try {
parsedState = JSON.parse(state);
} catch {
throw new Error("Invalid oauth state in redirected URL, unable to parse JSON: " + state);
}
if (parsedState.nonce !== nonce) {
throw new Error("Invalid oauth state in redirected URL");
}
console.log("codeVerifier", codeVerifier)
const tokenRes = await fetch(opendidConfig.token_endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: parsedState.redirectUri,
code_verifier: codeVerifier,
}).toString(),
});
localStorage.removeItem("huggingface.co:oauth:code_verifier");
localStorage.removeItem("huggingface.co:oauth:nonce");
if (!tokenRes.ok) {
throw await createApiError(tokenRes);
}
const token = await tokenRes.json();
const accessTokenExpiresAt = new Date(Date.now() + token.expires_in * 1000);
const userInfoRes = await fetch(opendidConfig.userinfo_endpoint, {
headers: {
Authorization: `Bearer ${token.access_token}`,
},
});
if (!userInfoRes.ok) {
throw await createApiError(userInfoRes);
}
const userInfo = await userInfoRes.json();
return {
accessToken: token.access_token,
accessTokenExpiresAt,
userInfo: {
id: userInfo.sub,
name: userInfo.name,
fullname: userInfo.preferred_username,
email: userInfo.email,
emailVerified: userInfo.email_verified,
avatarUrl: userInfo.picture,
websiteUrl: userInfo.website,
isPro: userInfo.isPro,
orgs: userInfo.orgs || [],
},
state: parsedState.state,
scope: token.scope,
};
}
const newNonce = crypto.randomUUID();
// Two random UUIDs concatenated together, because min length is 43 and max length is 128
const newCodeVerifier = crypto.randomUUID() + crypto.randomUUID();
localStorage.setItem("huggingface.co:oauth:nonce", newNonce);
localStorage.setItem("huggingface.co:oauth:code_verifier", newCodeVerifier);
const redirectUri = opts?.redirectUri || window.location.href;
const state = JSON.stringify({
nonce: newNonce,
redirectUri,
state: opts?.state,
});
// @ts-expect-error window.huggingface is defined inside static Spaces.
const variables = window?.huggingface?.variables ?? null;
const clientId = opts?.clientId || variables?.OAUTH_CLIENT_ID;
if (!clientId) {
if (variables) {
throw new Error("Missing clientId, please add hf_oauth: true to the README.md's metadata in your static Space");
}
throw new Error("Missing clientId");
}
const challenge = base64FromBytes(
new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(newCodeVerifier)))
)
.replace(/[+]/g, "-")
.replace(/[/]/g, "_")
.replace(/=/g, "");
console.log("localstorage after", JSON.parse(JSON.stringify(localStorage)))
console.log("challenge after", challenge, newCodeVerifier)
window.location.href = `${opendidConfig.authorization_endpoint}?${new URLSearchParams({
client_id: clientId,
scope: opts?.scopes || "openid profile",
response_type: "code",
redirect_uri: redirectUri,
state,
code_challenge: challenge,
code_challenge_method: "S256",
}).toString()}`;
throw new Error("Redirected");
}
oauthLogin().then(console.log);