diff --git a/packages/hub/src/error.ts b/packages/hub/src/error.ts index f0a3e33c4..0da5b2dd9 100644 --- a/packages/hub/src/error.ts +++ b/packages/hub/src/error.ts @@ -15,6 +15,9 @@ export async function createApiError( if (response.headers.get("Content-Type")?.startsWith("application/json")) { const json = await response.json(); error.message = json.error || json.message || error.message; + if (json.error_description) { + error.message = error.message ? error.message + `: ${json.error_description}` : json.error_description; + } error.data = json; } else { error.data = { message: await response.text() }; diff --git a/packages/hub/src/lib/create-repo.ts b/packages/hub/src/lib/create-repo.ts index 4005844d6..c0323dc11 100644 --- a/packages/hub/src/lib/create-repo.ts +++ b/packages/hub/src/lib/create-repo.ts @@ -9,6 +9,9 @@ import { toRepoId } from "../utils/toRepoId"; export async function createRepo( params: { repo: RepoDesignation; + /** + * If unset, will follow the organization's default setting. (typically public, except for some Enterprise organizations) + */ private?: boolean; license?: string; /** diff --git a/packages/hub/src/lib/oauth-handle-redirect.spec.ts b/packages/hub/src/lib/oauth-handle-redirect.spec.ts new file mode 100644 index 000000000..f06f6a40e --- /dev/null +++ b/packages/hub/src/lib/oauth-handle-redirect.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { TEST_COOKIE, TEST_HUB_URL } from "../test/consts"; +import { oauthLoginUrl } from "./oauth-login-url"; +import { oauthHandleRedirect } from "./oauth-handle-redirect"; + +describe("oauthHandleRedirect", () => { + it("should work", async () => { + const localStorage = { + nonce: undefined, + codeVerifier: undefined, + }; + const url = await oauthLoginUrl({ + clientId: "dummy-app", + redirectUrl: "http://localhost:3000", + localStorage, + scopes: "openid profile email", + hubUrl: TEST_HUB_URL, + }); + const resp = await fetch(url, { + method: "POST", + headers: { + Cookie: `token=${TEST_COOKIE}`, + }, + redirect: "manual", + }); + if (resp.status !== 303) { + throw new Error(`Failed to fetch url ${url}: ${resp.status} ${resp.statusText}`); + } + const location = resp.headers.get("Location"); + if (!location) { + throw new Error(`No location header in response`); + } + const result = await oauthHandleRedirect({ + redirectedUrl: location, + codeVerifier: localStorage.codeVerifier, + nonce: localStorage.nonce, + hubUrl: TEST_HUB_URL, + }); + + if (!result) { + throw new Error("Expected result to be defined"); + } + expect(result.accessToken).toEqual(expect.any(String)); + expect(result.accessTokenExpiresAt).toBeInstanceOf(Date); + expect(result.accessTokenExpiresAt.getTime()).toBeGreaterThan(Date.now()); + expect(result.scope).toEqual(expect.any(String)); + expect(result.userInfo).toEqual({ + sub: "62f264b9f3c90f4b6514a269", + name: "@huggingface/hub CI bot", + preferred_username: "hub.js", + email_verified: true, + email: "eliott@huggingface.co", + isPro: false, + picture: "https://hub-ci.huggingface.co/avatars/934b830e9fdaa879487852f79eef7165.svg", + profile: "https://hub-ci.huggingface.co/hub.js", + website: "https://github.com/huggingface/hub.js", + orgs: [], + }); + }); +}); diff --git a/packages/hub/src/lib/oauth-handle-redirect.ts b/packages/hub/src/lib/oauth-handle-redirect.ts index 771b6d439..89bbb0b60 100644 --- a/packages/hub/src/lib/oauth-handle-redirect.ts +++ b/packages/hub/src/lib/oauth-handle-redirect.ts @@ -1,28 +1,100 @@ import { HUB_URL } from "../consts"; import { createApiError } from "../error"; +export interface UserInfo { + /** + * OpenID Connect field. Unique identifier for the user, even in case of rename. + */ + sub: string; + /** + * OpenID Connect field. The user's full name. + */ + name: string; + /** + * OpenID Connect field. The user's username. + */ + preferred_username: string; + /** + * OpenID Connect field, available if scope "email" was granted. + */ + email_verified?: boolean; + /** + * OpenID Connect field, available if scope "email" was granted. + */ + email?: string; + /** + * OpenID Connect field. The user's profile picture URL. + */ + picture: string; + /** + * OpenID Connect field. The user's profile URL. + */ + profile: string; + /** + * OpenID Connect field. The user's website URL. + */ + website?: string; + + /** + * Hugging Face field. Whether the user is a pro user. + */ + isPro: boolean; + /** + * Hugging Face field. Whether the user has a payment method set up. Needs "read-billing" scope. + */ + canPay?: boolean; + /** + * Hugging Face field. The user's orgs + */ + orgs?: Array<{ + /** + * OpenID Connect field. Unique identifier for the org. + */ + sub: string; + /** + * OpenID Connect field. The org's full name. + */ + name: string; + /** + * OpenID Connect field. The org's username. + */ + preferred_username: string; + /** + * OpenID Connect field. The org's profile picture URL. + */ + picture: string; + + /** + * Hugging Face field. Whether the org is an enterprise org. + */ + isEnterprise: boolean; + /** + * Hugging Face field. Whether the org has a payment method set up. Needs "read-billing" scope, and the user needs to approve access to the org in the OAuth page. + */ + canPay?: boolean; + /** + * Hugging Face field. The user's role in the org. The user needs to approve access to the org in the OAuth page. + */ + roleInOrg?: string; + /** + * HuggingFace field. When the user granted the oauth app access to the org, but didn't complete SSO. + * + * Should never happen directly after the oauth flow. + */ + pendingSSO?: boolean; + /** + * HuggingFace field. When the user granted the oauth app access to the org, but didn't complete MFA. + * + * Should never happen directly after the oauth flow. + */ + missingMFA?: boolean; + }>; +} + export interface OAuthResult { accessToken: string; accessTokenExpiresAt: Date; - userInfo: { - id: string; - name: string; - fullname: string; - email?: string; - emailVerified?: boolean; - avatarUrl: string; - websiteUrl?: string; - isPro: boolean; - canPay?: boolean; - orgs: Array<{ - id: string; - name: string; - isEnterprise: boolean; - canPay?: boolean; - avatarUrl: string; - roleInOrg?: string; - }>; - }; + userInfo: UserInfo; /** * State passed to the OAuth provider in the original request to the OAuth provider. */ @@ -39,12 +111,47 @@ export interface OAuthResult { * 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"); +export async function oauthHandleRedirect(opts?: { + /** + * The URL of the hub. Defaults to {@link HUB_URL}. + */ + hubUrl?: string; + /** + * The URL to analyze. + * + * @default window.location.href + */ + redirectedUrl?: string; + /** + * nonce generated by oauthLoginUrl + * + * @default localStorage.getItem("huggingface.co:oauth:nonce") + */ + nonce?: string; + /** + * codeVerifier generated by oauthLoginUrl + * + * @default localStorage.getItem("huggingface.co:oauth:code_verifier") + */ + codeVerifier?: string; +}): Promise { + if (typeof window === "undefined" && !opts?.redirectedUrl) { + throw new Error("oauthHandleRedirect is only available in the browser, unless you provide redirectedUrl"); + } + if (typeof localStorage === "undefined" && (!opts?.nonce || !opts?.codeVerifier)) { + throw new Error( + "oauthHandleRedirect requires localStorage to be available, unless you provide nonce and codeVerifier" + ); } - const searchParams = new URLSearchParams(window.location.search); + const redirectedUrl = opts?.redirectedUrl ?? window.location.href; + const searchParams = (() => { + try { + return new URL(redirectedUrl).searchParams; + } catch (err) { + throw new Error("Failed to parse redirected URL: " + redirectedUrl); + } + })(); const [error, errorDescription] = [searchParams.get("error"), searchParams.get("error_description")]; @@ -53,17 +160,17 @@ export async function oauthHandleRedirect(opts?: { hubUrl?: string }): Promise; - } = await userInfoRes.json(); + const userInfo: 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?.map((org) => ({ - id: org.sub, - name: org.name, - fullname: org.name, - isEnterprise: org.isEnterprise, - canPay: org.canPay, - avatarUrl: org.picture, - roleInOrg: org.roleInOrg, - })) ?? [], - }, + userInfo: userInfo, state: parsedState.state, scope: token.scope, }; @@ -207,12 +281,39 @@ export async function oauthHandleRedirect(opts?: { hubUrl?: string }): Promise { - if (typeof window === "undefined") { - throw new Error("oauthHandleRedirect is only available in the browser"); +export async function oauthHandleRedirectIfPresent(opts?: { + /** + * The URL of the hub. Defaults to {@link HUB_URL}. + */ + hubUrl?: string; + /** + * The URL to analyze. + * + * @default window.location.href + */ + redirectedUrl?: string; + /** + * nonce generated by oauthLoginUrl + * + * @default localStorage.getItem("huggingface.co:oauth:nonce") + */ + nonce?: string; + /** + * codeVerifier generated by oauthLoginUrl + * + * @default localStorage.getItem("huggingface.co:oauth:code_verifier") + */ + codeVerifier?: string; +}): Promise { + if (typeof window === "undefined" && !opts?.redirectedUrl) { + throw new Error("oauthHandleRedirect is only available in the browser, unless you provide redirectedUrl"); } - - const searchParams = new URLSearchParams(window.location.search); + if (typeof localStorage === "undefined" && (!opts?.nonce || !opts?.codeVerifier)) { + throw new Error( + "oauthHandleRedirect requires localStorage to be available, unless you provide nonce and codeVerifier" + ); + } + const searchParams = new URLSearchParams(opts?.redirectedUrl ?? window.location.search); if (searchParams.has("error")) { return oauthHandleRedirect(opts); diff --git a/packages/hub/src/lib/oauth-login-url.ts b/packages/hub/src/lib/oauth-login-url.ts index 9067ba994..9b8bca3f3 100644 --- a/packages/hub/src/lib/oauth-login-url.ts +++ b/packages/hub/src/lib/oauth-login-url.ts @@ -40,7 +40,7 @@ export async function oauthLoginUrl(opts?: { clientId?: string; hubUrl?: string; /** - * OAuth scope, a list of space separate scopes. + * 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. @@ -64,9 +64,24 @@ export async function oauthLoginUrl(opts?: { * 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") { - throw new Error("oauthLogin is only available in the browser"); + 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; @@ -91,18 +106,37 @@ export async function oauthLoginUrl(opts?: { // 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); + if (opts?.localStorage) { + if (opts.localStorage.codeVerifier !== undefined && opts.localStorage.codeVerifier !== null) { + throw new Error( + "localStorage.codeVerifier must be a 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 a 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 || window.location.href; + 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, }); - // @ts-expect-error window.huggingface is defined inside static Spaces. - const variables: Record | null = window?.huggingface?.variables ?? null; + 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; diff --git a/packages/hub/src/test/consts.ts b/packages/hub/src/test/consts.ts index 297ea01d0..6b8b7983d 100644 --- a/packages/hub/src/test/consts.ts +++ b/packages/hub/src/test/consts.ts @@ -1,3 +1,4 @@ export const TEST_HUB_URL = "https://hub-ci.huggingface.co"; export const TEST_USER = "hub.js"; export const TEST_ACCESS_TOKEN = "hf_hub.js"; +export const TEST_COOKIE = "huggingface-hub.js-cookie"; diff --git a/packages/hub/vitest-browser.config.mts b/packages/hub/vitest-browser.config.mts index db22fb67c..2c21588af 100644 --- a/packages/hub/vitest-browser.config.mts +++ b/packages/hub/vitest-browser.config.mts @@ -8,6 +8,8 @@ export default defineConfig({ "src/lib/cache-management.spec.ts", "src/lib/download-file-to-cache-dir.spec.ts", "src/lib/snapshot-download.spec.ts", + // Because we use redirect: "manual" in the test + "src/lib/oauth-handle-redirect.spec.ts", ], }, });