Skip to content

Commit

Permalink
✨ Introduce oauthLogin function (#433)
Browse files Browse the repository at this point in the history
Fix #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 <[email protected]>
  • Loading branch information
coyotte508 and Mishig authored Jan 17, 2024
1 parent b1adcc4 commit e578fc3
Show file tree
Hide file tree
Showing 7 changed files with 372 additions and 8 deletions.
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions packages/hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/hub/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
3 changes: 3 additions & 0 deletions packages/hub/src/lib/list-datasets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
214 changes: 214 additions & 0 deletions packages/hub/src/lib/oauth-handle-redirect.ts
Original file line number Diff line number Diff line change
@@ -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<OAuthResult> {
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<OAuthResult | false> {
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;
}
Loading

0 comments on commit e578fc3

Please sign in to comment.