Skip to content

Commit e578fc3

Browse files
coyotte508Mishig
and
Mishig
authored
✨ Introduce oauthLogin function (#433)
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]>
1 parent b1adcc4 commit e578fc3

File tree

7 files changed

+372
-8
lines changed

7 files changed

+372
-8
lines changed

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"editor.defaultFormatter": "esbenp.prettier-vscode",
33
"editor.formatOnSave": true,
44
"editor.codeActionsOnSave": {
5-
"source.fixAll": true
5+
"source.fixAll": "explicit"
66
},
77
"[svelte]": {
88
"editor.defaultFormatter": "esbenp.prettier-vscode"

packages/hub/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,26 @@ for await (const fileInfo of listFiles({repo})) {
8989
await deleteRepo({ repo, credentials });
9090
```
9191

92+
## OAuth Login
93+
94+
It's possible to login using OAuth (["Sign in with HF"](https://huggingface.co/docs/hub/oauth)).
95+
96+
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.
97+
98+
```ts
99+
import { oauthLoginUrl, oauthHandleRedirectIfPresent } from "@huggingface/hub";
100+
101+
const oauthResult = await oauthHandleRedirectIfPresent();
102+
103+
if (!oauthResult) {
104+
// If the user is not logged in, redirect to the login page
105+
window.location.href = await oauthLoginUrl();
106+
}
107+
108+
// You can use oauthResult.accessToken, oauthResult.accessTokenExpiresAt and oauthResult.userInfo
109+
console.log(oauthResult);
110+
```
111+
92112
## Performance considerations
93113

94114
When uploading large files, you may want to run the `commit` calls inside a worker, to offload the sha256 computations.

packages/hub/src/lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export * from "./list-datasets";
1010
export * from "./list-files";
1111
export * from "./list-models";
1212
export * from "./list-spaces";
13+
export * from "./oauth-handle-redirect";
14+
export * from "./oauth-login-url";
1315
export * from "./parse-safetensors-metadata";
1416
export * from "./upload-file";
1517
export * from "./upload-files";

packages/hub/src/lib/list-datasets.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ describe("listDatasets", () => {
77
const results: DatasetEntry[] = [];
88

99
for await (const entry of listDatasets({ search: { owner: "hf-doc-build" } })) {
10+
if (entry.name === "hf-doc-build/doc-build-dev-test") {
11+
continue;
12+
}
1013
if (typeof entry.downloads === "number") {
1114
entry.downloads = 0;
1215
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { HUB_URL } from "../consts";
2+
import { createApiError } from "../error";
3+
4+
export interface OAuthResult {
5+
accessToken: string;
6+
accessTokenExpiresAt: Date;
7+
userInfo: {
8+
id: string;
9+
name: string;
10+
fullname: string;
11+
email?: string;
12+
emailVerified?: boolean;
13+
avatarUrl: string;
14+
websiteUrl?: string;
15+
isPro: boolean;
16+
orgs: Array<{
17+
name: string;
18+
isEnterprise: boolean;
19+
}>;
20+
};
21+
/**
22+
* State passed to the OAuth provider in the original request to the OAuth provider.
23+
*/
24+
state?: string;
25+
/**
26+
* Granted scope
27+
*/
28+
scope: string;
29+
}
30+
31+
/**
32+
* To call after the OAuth provider redirects back to the app.
33+
*
34+
* There is also a helper function {@link oauthHandleRedirectIfPresent}, which will call `oauthHandleRedirect` if the URL contains an oauth code
35+
* in the query parameters and return `false` otherwise.
36+
*/
37+
export async function oauthHandleRedirect(opts?: { hubUrl?: string }): Promise<OAuthResult> {
38+
if (typeof window === "undefined") {
39+
throw new Error("oauthHandleRedirect is only available in the browser");
40+
}
41+
42+
const searchParams = new URLSearchParams(window.location.search);
43+
44+
const [error, errorDescription] = [searchParams.get("error"), searchParams.get("error_description")];
45+
46+
if (error) {
47+
throw new Error(`${error}: ${errorDescription}`);
48+
}
49+
50+
const code = searchParams.get("code");
51+
const nonce = localStorage.getItem("huggingface.co:oauth:nonce");
52+
53+
if (!code) {
54+
throw new Error("Missing oauth code from query parameters in redirected URL");
55+
}
56+
57+
if (!nonce) {
58+
throw new Error("Missing oauth nonce from localStorage");
59+
}
60+
61+
const codeVerifier = localStorage.getItem("huggingface.co:oauth:code_verifier");
62+
63+
if (!codeVerifier) {
64+
throw new Error("Missing oauth code_verifier from localStorage");
65+
}
66+
67+
const state = searchParams.get("state");
68+
69+
if (!state) {
70+
throw new Error("Missing oauth state from query parameters in redirected URL");
71+
}
72+
73+
let parsedState: { nonce: string; redirectUri: string; state?: string };
74+
75+
try {
76+
parsedState = JSON.parse(state);
77+
} catch {
78+
throw new Error("Invalid oauth state in redirected URL, unable to parse JSON: " + state);
79+
}
80+
81+
if (parsedState.nonce !== nonce) {
82+
throw new Error("Invalid oauth state in redirected URL");
83+
}
84+
85+
const hubUrl = opts?.hubUrl || HUB_URL;
86+
87+
const openidConfigUrl = `${new URL(hubUrl).origin}/.well-known/openid-configuration`;
88+
const openidConfigRes = await fetch(openidConfigUrl, {
89+
headers: {
90+
Accept: "application/json",
91+
},
92+
});
93+
94+
if (!openidConfigRes.ok) {
95+
throw await createApiError(openidConfigRes);
96+
}
97+
98+
const opendidConfig: {
99+
authorization_endpoint: string;
100+
token_endpoint: string;
101+
userinfo_endpoint: string;
102+
} = await openidConfigRes.json();
103+
104+
const tokenRes = await fetch(opendidConfig.token_endpoint, {
105+
method: "POST",
106+
headers: {
107+
"Content-Type": "application/x-www-form-urlencoded",
108+
},
109+
body: new URLSearchParams({
110+
grant_type: "authorization_code",
111+
code,
112+
redirect_uri: parsedState.redirectUri,
113+
code_verifier: codeVerifier,
114+
}).toString(),
115+
});
116+
117+
localStorage.removeItem("huggingface.co:oauth:code_verifier");
118+
localStorage.removeItem("huggingface.co:oauth:nonce");
119+
120+
if (!tokenRes.ok) {
121+
throw await createApiError(tokenRes);
122+
}
123+
124+
const token: {
125+
access_token: string;
126+
expires_in: number;
127+
id_token: string;
128+
// refresh_token: string;
129+
scope: string;
130+
token_type: string;
131+
} = await tokenRes.json();
132+
133+
const accessTokenExpiresAt = new Date(Date.now() + token.expires_in * 1000);
134+
135+
const userInfoRes = await fetch(opendidConfig.userinfo_endpoint, {
136+
headers: {
137+
Authorization: `Bearer ${token.access_token}`,
138+
},
139+
});
140+
141+
if (!userInfoRes.ok) {
142+
throw await createApiError(userInfoRes);
143+
}
144+
145+
const userInfo: {
146+
sub: string;
147+
name: string;
148+
preferred_username: string;
149+
email_verified?: boolean;
150+
email?: string;
151+
picture: string;
152+
website?: string;
153+
isPro: boolean;
154+
orgs?: Array<{
155+
name: string;
156+
isEnterprise: boolean;
157+
}>;
158+
} = await userInfoRes.json();
159+
160+
return {
161+
accessToken: token.access_token,
162+
accessTokenExpiresAt,
163+
userInfo: {
164+
id: userInfo.sub,
165+
name: userInfo.name,
166+
fullname: userInfo.preferred_username,
167+
email: userInfo.email,
168+
emailVerified: userInfo.email_verified,
169+
avatarUrl: userInfo.picture,
170+
websiteUrl: userInfo.website,
171+
isPro: userInfo.isPro,
172+
orgs: userInfo.orgs || [],
173+
},
174+
state: parsedState.state,
175+
scope: token.scope,
176+
};
177+
}
178+
179+
// if (code && !nonce) {
180+
// console.warn("Missing oauth nonce from localStorage");
181+
// }
182+
183+
/**
184+
* To call after the OAuth provider redirects back to the app.
185+
*
186+
* It returns false if the URL does not contain an oauth code in the query parameters, otherwise
187+
* it calls {@link oauthHandleRedirect}.
188+
*
189+
* Depending on your app, you may want to call {@link oauthHandleRedirect} directly instead.
190+
*/
191+
export async function oauthHandleRedirectIfPresent(opts?: { hubUrl?: string }): Promise<OAuthResult | false> {
192+
if (typeof window === "undefined") {
193+
throw new Error("oauthHandleRedirect is only available in the browser");
194+
}
195+
196+
const searchParams = new URLSearchParams(window.location.search);
197+
198+
if (searchParams.has("error")) {
199+
return oauthHandleRedirect(opts);
200+
}
201+
202+
if (searchParams.has("code")) {
203+
if (!localStorage.getItem("huggingface.co:oauth:nonce")) {
204+
console.warn(
205+
"Missing oauth nonce from localStorage. This can happen when the user refreshes the page after logging in, without changing the URL."
206+
);
207+
return false;
208+
}
209+
210+
return oauthHandleRedirect(opts);
211+
}
212+
213+
return false;
214+
}

0 commit comments

Comments
 (0)