Spaces:
Running
Running
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); |