import { HUB_URL } from "../consts"; import { createApiError } from "../error"; import { base64FromBytes } from "../utils/base64FromBytes"; /** * 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-separated 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; /** * If provided, will be filled with the code verifier and nonce used for the OAuth flow, * instead of using localStorage. * * When calling {@link `oauthHandleRedirectIfPresent`} or {@link `oauthHandleRedirect`} you will need to provide the same values. */ localStorage?: { codeVerifier?: string; nonce?: string; }; }): Promise { if (typeof window === "undefined" && (!opts?.redirectUrl || !opts?.clientId)) { throw new Error("oauthLogin is only available in the browser, unless you provide clientId and redirectUrl"); } if (typeof localStorage === "undefined" && !opts?.localStorage) { throw new Error( "oauthLogin requires localStorage to be available in the context, unless you provide a localStorage empty object as argument" ); } 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(); if (opts?.localStorage) { if (opts.localStorage.codeVerifier !== undefined && opts.localStorage.codeVerifier !== null) { throw new Error( "localStorage.codeVerifier must be initially set to null or undefined, and will be filled by oauthLoginUrl" ); } if (opts.localStorage.nonce !== undefined && opts.localStorage.nonce !== null) { throw new Error( "localStorage.nonce must be initially set to null or undefined, and will be filled by oauthLoginUrl" ); } opts.localStorage.codeVerifier = newCodeVerifier; opts.localStorage.nonce = newNonce; } else { localStorage.setItem("huggingface.co:oauth:nonce", newNonce); localStorage.setItem("huggingface.co:oauth:code_verifier", newCodeVerifier); } const redirectUri = opts?.redirectUrl || (typeof window !== "undefined" ? window.location.href : undefined); if (!redirectUri) { throw new Error("Missing redirectUrl"); } const state = JSON.stringify({ nonce: newNonce, redirectUri, state: opts?.state, }); const variables: Record | null = // @ts-expect-error window.huggingface is defined inside static Spaces. typeof window !== "undefined" ? window.huggingface?.variables ?? null : 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 || variables?.OAUTH_SCOPES || "openid profile", response_type: "code", redirect_uri: redirectUri, state, code_challenge: challenge, code_challenge_method: "S256", }).toString()}`; }