From e578fc334f5c83a49e96fda24d0360669662b4ac Mon Sep 17 00:00:00 2001 From: Eliott C Date: Wed, 17 Jan 2024 12:05:58 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Introduce=20oauthLogin=20function?= =?UTF-8?q?=20(#433)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix https://github.com/huggingface/huggingface.js/issues/408 ```ts import { oauthLogin, oauthHandleRedirectIfPresent } from "@huggingface/hub"; import {HfInference} from "@huggingface/inference"; const oauthResult = await oauthHandleRedirectIfPresent(); if (!oauthResult) { // If the user is not logged in, redirect to the login page oauthLogin(); } // You can use oauthResult.accessToken and oauthResult.userInfo console.log(oauthResult); const inference = new HfInference(oauthResult.accessToken); await inference.textToImage({ model: 'stabilityai/stable-diffusion-2', inputs: 'award winning high resolution photo of a giant tortoise/((ladybird)) hybrid, [trending on artstation]', parameters: { negative_prompt: 'blurry', } }) ``` Tested inside a space: https://huggingface.co/spaces/coyotte508/client-side-oauth Mainly looking for reviews regarding the APIs / usability cc @xenova @radames @vvmnnnkv @jbilcke-hf @Wauplin . I started with a single `oauthLogin` that was split into `oauthLogin` and `oauthHandleRedirect` with an extra `oauthHandleRedirectIfPresent` for convenience. --------- Co-authored-by: Mishig --- .vscode/settings.json | 2 +- packages/hub/README.md | 20 ++ packages/hub/src/lib/index.ts | 2 + packages/hub/src/lib/list-datasets.spec.ts | 3 + packages/hub/src/lib/oauth-handle-redirect.ts | 214 ++++++++++++++++++ packages/hub/src/lib/oauth-login-url.ts | 132 +++++++++++ packages/hub/src/utils/randomUUID.ts | 7 - 7 files changed, 372 insertions(+), 8 deletions(-) create mode 100644 packages/hub/src/lib/oauth-handle-redirect.ts create mode 100644 packages/hub/src/lib/oauth-login-url.ts delete mode 100644 packages/hub/src/utils/randomUUID.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 09d96d362..5e40510b2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll": true + "source.fixAll": "explicit" }, "[svelte]": { "editor.defaultFormatter": "esbenp.prettier-vscode" diff --git a/packages/hub/README.md b/packages/hub/README.md index 85e275415..2c44eff63 100644 --- a/packages/hub/README.md +++ b/packages/hub/README.md @@ -89,6 +89,26 @@ for await (const fileInfo of listFiles({repo})) { await deleteRepo({ repo, credentials }); ``` +## OAuth Login + +It's possible to login using OAuth (["Sign in with HF"](https://huggingface.co/docs/hub/oauth)). + +This will allow you get an access token to use some of the API, depending on the scopes set inside the Space or the OAuth App. + +```ts +import { oauthLoginUrl, oauthHandleRedirectIfPresent } from "@huggingface/hub"; + +const oauthResult = await oauthHandleRedirectIfPresent(); + +if (!oauthResult) { + // If the user is not logged in, redirect to the login page + window.location.href = await oauthLoginUrl(); +} + +// You can use oauthResult.accessToken, oauthResult.accessTokenExpiresAt and oauthResult.userInfo +console.log(oauthResult); +``` + ## Performance considerations When uploading large files, you may want to run the `commit` calls inside a worker, to offload the sha256 computations. diff --git a/packages/hub/src/lib/index.ts b/packages/hub/src/lib/index.ts index e232345fa..c78b08aea 100644 --- a/packages/hub/src/lib/index.ts +++ b/packages/hub/src/lib/index.ts @@ -10,6 +10,8 @@ export * from "./list-datasets"; export * from "./list-files"; export * from "./list-models"; export * from "./list-spaces"; +export * from "./oauth-handle-redirect"; +export * from "./oauth-login-url"; export * from "./parse-safetensors-metadata"; export * from "./upload-file"; export * from "./upload-files"; diff --git a/packages/hub/src/lib/list-datasets.spec.ts b/packages/hub/src/lib/list-datasets.spec.ts index 565e2b4bf..24993130b 100644 --- a/packages/hub/src/lib/list-datasets.spec.ts +++ b/packages/hub/src/lib/list-datasets.spec.ts @@ -7,6 +7,9 @@ describe("listDatasets", () => { const results: DatasetEntry[] = []; for await (const entry of listDatasets({ search: { owner: "hf-doc-build" } })) { + if (entry.name === "hf-doc-build/doc-build-dev-test") { + continue; + } if (typeof entry.downloads === "number") { entry.downloads = 0; } diff --git a/packages/hub/src/lib/oauth-handle-redirect.ts b/packages/hub/src/lib/oauth-handle-redirect.ts new file mode 100644 index 000000000..786ef9f2b --- /dev/null +++ b/packages/hub/src/lib/oauth-handle-redirect.ts @@ -0,0 +1,214 @@ +import { HUB_URL } from "../consts"; +import { createApiError } from "../error"; + +export interface OAuthResult { + accessToken: string; + accessTokenExpiresAt: Date; + userInfo: { + id: string; + name: string; + fullname: string; + email?: string; + emailVerified?: boolean; + avatarUrl: string; + websiteUrl?: string; + isPro: boolean; + orgs: Array<{ + name: string; + isEnterprise: boolean; + }>; + }; + /** + * State passed to the OAuth provider in the original request to the OAuth provider. + */ + state?: string; + /** + * Granted scope + */ + scope: string; +} + +/** + * To call after the OAuth provider redirects back to the app. + * + * There is also a helper function {@link oauthHandleRedirectIfPresent}, which will call `oauthHandleRedirect` if the URL contains an oauth code + * in the query parameters and return `false` otherwise. + */ +export async function oauthHandleRedirect(opts?: { hubUrl?: string }): Promise { + if (typeof window === "undefined") { + throw new Error("oauthHandleRedirect is only available in the browser"); + } + + 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) { + throw new Error("Missing oauth code from query parameters in redirected URL"); + } + + if (!nonce) { + throw new Error("Missing oauth nonce from localStorage"); + } + + 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: { nonce: string; redirectUri: string; state?: string }; + + 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"); + } + + 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: { + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + } = await openidConfigRes.json(); + + 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: { + access_token: string; + expires_in: number; + id_token: string; + // refresh_token: string; + scope: string; + token_type: string; + } = 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: { + sub: string; + name: string; + preferred_username: string; + email_verified?: boolean; + email?: string; + picture: string; + website?: string; + isPro: boolean; + orgs?: Array<{ + name: string; + isEnterprise: boolean; + }>; + } = 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, + }; +} + +// if (code && !nonce) { +// console.warn("Missing oauth nonce from localStorage"); +// } + +/** + * To call after the OAuth provider redirects back to the app. + * + * It returns false if the URL does not contain an oauth code in the query parameters, otherwise + * it calls {@link oauthHandleRedirect}. + * + * Depending on your app, you may want to call {@link oauthHandleRedirect} directly instead. + */ +export async function oauthHandleRedirectIfPresent(opts?: { hubUrl?: string }): Promise { + if (typeof window === "undefined") { + throw new Error("oauthHandleRedirect is only available in the browser"); + } + + const searchParams = new URLSearchParams(window.location.search); + + if (searchParams.has("error")) { + return oauthHandleRedirect(opts); + } + + if (searchParams.has("code")) { + if (!localStorage.getItem("huggingface.co:oauth:nonce")) { + console.warn( + "Missing oauth nonce from localStorage. This can happen when the user refreshes the page after logging in, without changing the URL." + ); + return false; + } + + return oauthHandleRedirect(opts); + } + + return false; +} diff --git a/packages/hub/src/lib/oauth-login-url.ts b/packages/hub/src/lib/oauth-login-url.ts new file mode 100644 index 000000000..4762049e9 --- /dev/null +++ b/packages/hub/src/lib/oauth-login-url.ts @@ -0,0 +1,132 @@ +import { base64FromBytes } from "../../../shared/src"; +import { HUB_URL } from "../consts"; +import { createApiError } from "../error"; + +/** + * Use "Sign in with Hub" to authenticate a user, and get oauth user info / access token. + * + * Returns an url to redirect to. After the user is redirected back to your app, call `oauthHandleRedirect` to get the oauth user info / access token. + * + * When called from inside a static Space with OAuth enabled, it will load the config from the space, otherwise you need to at least specify + * the client ID of your OAuth App. + * + * @example + * ```ts + * import { oauthLoginUrl, oauthHandleRedirectIfPresent } from "@huggingface/hub"; + * + * const oauthResult = await oauthHandleRedirectIfPresent(); + * + * if (!oauthResult) { + * // If the user is not logged in, redirect to the login page + * window.location.href = await oauthLoginUrl(); + * } + * + * // You can use oauthResult.accessToken, oauthResult.accessTokenExpiresAt and oauthResult.userInfo + * console.log(oauthResult); + * ``` + * + * (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.) + */ +export async function oauthLoginUrl(opts?: { + /** + * OAuth client ID. + * + * For static Spaces, you can omit this and it will be loaded from the Space config, as long as `hf_oauth: true` is present in the README.md's metadata. + * For other Spaces, it is available to the backend in the OAUTH_CLIENT_ID environment variable, as long as `hf_oauth: true` is present in the README.md's metadata. + * + * You can also create a Developer Application at https://huggingface.co/settings/connected-applications and use its client ID. + */ + clientId?: string; + hubUrl?: string; + /** + * OAuth scope, a list of space separate scopes. + * + * For static Spaces, you can omit this and it will be loaded from the Space config, as long as `hf_oauth: true` is present in the README.md's metadata. + * For other Spaces, it is available to the backend in the OAUTH_SCOPES environment variable, as long as `hf_oauth: true` is present in the README.md's metadata. + * + * Defaults to "openid profile". + * + * You can also create a Developer Application at https://huggingface.co/settings/connected-applications and use its scopes. + * + * See https://huggingface.co/docs/hub/oauth for a list of available scopes. + */ + scopes?: string; + /** + * Redirect URI, defaults to the current URL. + * + * For Spaces, any URL within the Space is allowed. + * + * For Developer Applications, you can add any URL you want to the list of allowed redirect URIs at https://huggingface.co/settings/connected-applications. + */ + redirectUrl?: string; + /** + * State to pass to the OAuth provider, which will be returned in the call to `oauthLogin` after the redirect. + */ + state?: string; +}): Promise { + if (typeof window === "undefined") { + throw new Error("oauthLogin is only available in the browser"); + } + + 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: { + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + } = await openidConfigRes.json(); + + const newNonce = globalThis.crypto.randomUUID(); + // Two random UUIDs concatenated together, because min length is 43 and max length is 128 + const newCodeVerifier = globalThis.crypto.randomUUID() + globalThis.crypto.randomUUID(); + + localStorage.setItem("huggingface.co:oauth:nonce", newNonce); + localStorage.setItem("huggingface.co:oauth:code_verifier", newCodeVerifier); + + const redirectUri = opts?.redirectUrl || 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: Record | null = 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, ""); + + return `${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()}`; +} diff --git a/packages/hub/src/utils/randomUUID.ts b/packages/hub/src/utils/randomUUID.ts deleted file mode 100644 index 8eb66ea92..000000000 --- a/packages/hub/src/utils/randomUUID.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function randomUUID(): Promise { - if (globalThis.crypto) { - return globalThis.crypto.randomUUID(); - } else { - return (await import("node:crypto")).randomUUID(); - } -}