Skip to content

Commit dbf0327

Browse files
committed
📝 Document oauth
1 parent 655403a commit dbf0327

File tree

4 files changed

+228
-148
lines changed

4 files changed

+228
-148
lines changed

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 of the scopes set inside the Space or the OAuth App.
97+
98+
```ts
99+
import { oauthLogin, 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+
oauthLogin();
106+
}
107+
108+
// You can use oauthResult.accessToken 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ 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";
1314
export * from "./oauth-login";
1415
export * from "./parse-safetensors-metadata";
1516
export * from "./upload-file";
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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+
export async function oauthHandleRedirect(opts?: { hubUrl?: string }): Promise<OAuthResult> {
35+
if (typeof window === "undefined") {
36+
throw new Error("oauthHandleRedirect is only available in the browser");
37+
}
38+
39+
const searchParams = new URLSearchParams(window.location.search);
40+
41+
const [error, errorDescription] = [searchParams.get("error"), searchParams.get("error_description")];
42+
43+
if (error) {
44+
throw new Error(`${error}: ${errorDescription}`);
45+
}
46+
47+
const code = searchParams.get("code");
48+
const nonce = localStorage.getItem("huggingface.co:oauth:nonce");
49+
50+
if (!code) {
51+
throw new Error("Missing oauth code from query parameters in redirected URL");
52+
}
53+
54+
if (!nonce) {
55+
throw new Error("Missing oauth nonce from localStorage");
56+
}
57+
58+
const codeVerifier = localStorage.getItem("huggingface.co:oauth:code_verifier");
59+
60+
if (!codeVerifier) {
61+
throw new Error("Missing oauth code_verifier from localStorage");
62+
}
63+
64+
const state = searchParams.get("state");
65+
66+
if (!state) {
67+
throw new Error("Missing oauth state from query parameters in redirected URL");
68+
}
69+
70+
let parsedState: { nonce: string; redirectUri: string; state?: string };
71+
72+
try {
73+
parsedState = JSON.parse(state);
74+
} catch {
75+
throw new Error("Invalid oauth state in redirected URL, unable to parse JSON: " + state);
76+
}
77+
78+
if (parsedState.nonce !== nonce) {
79+
throw new Error("Invalid oauth state in redirected URL");
80+
}
81+
82+
const hubUrl = opts?.hubUrl || HUB_URL;
83+
84+
const openidConfigUrl = `${new URL(hubUrl).origin}/.well-known/openid-configuration`;
85+
const openidConfigRes = await fetch(openidConfigUrl, {
86+
headers: {
87+
Accept: "application/json",
88+
},
89+
});
90+
91+
if (!openidConfigRes.ok) {
92+
throw await createApiError(openidConfigRes);
93+
}
94+
95+
const opendidConfig: {
96+
authorization_endpoint: string;
97+
token_endpoint: string;
98+
userinfo_endpoint: string;
99+
} = await openidConfigRes.json();
100+
101+
const tokenRes = await fetch(opendidConfig.token_endpoint, {
102+
method: "POST",
103+
headers: {
104+
"Content-Type": "application/x-www-form-urlencoded",
105+
},
106+
body: new URLSearchParams({
107+
grant_type: "authorization_code",
108+
code,
109+
redirect_uri: parsedState.redirectUri,
110+
code_verifier: codeVerifier,
111+
}).toString(),
112+
});
113+
114+
localStorage.removeItem("huggingface.co:oauth:code_verifier");
115+
localStorage.removeItem("huggingface.co:oauth:nonce");
116+
117+
if (!tokenRes.ok) {
118+
throw await createApiError(tokenRes);
119+
}
120+
121+
const token: {
122+
access_token: string;
123+
expires_in: number;
124+
id_token: string;
125+
// refresh_token: string;
126+
scope: string;
127+
token_type: string;
128+
} = await tokenRes.json();
129+
130+
const accessTokenExpiresAt = new Date(Date.now() + token.expires_in * 1000);
131+
132+
const userInfoRes = await fetch(opendidConfig.userinfo_endpoint, {
133+
headers: {
134+
Authorization: `Bearer ${token.access_token}`,
135+
},
136+
});
137+
138+
if (!userInfoRes.ok) {
139+
throw await createApiError(userInfoRes);
140+
}
141+
142+
const userInfo: {
143+
sub: string;
144+
name: string;
145+
preferred_username: string;
146+
email_verified?: boolean;
147+
email?: string;
148+
picture: string;
149+
website?: string;
150+
isPro: boolean;
151+
orgs?: Array<{
152+
name: string;
153+
isEnterprise: boolean;
154+
}>;
155+
} = await userInfoRes.json();
156+
157+
return {
158+
accessToken: token.access_token,
159+
accessTokenExpiresAt,
160+
userInfo: {
161+
id: userInfo.sub,
162+
name: userInfo.name,
163+
fullname: userInfo.preferred_username,
164+
email: userInfo.email,
165+
emailVerified: userInfo.email_verified,
166+
avatarUrl: userInfo.picture,
167+
websiteUrl: userInfo.website,
168+
isPro: userInfo.isPro,
169+
orgs: userInfo.orgs || [],
170+
},
171+
state: parsedState.state,
172+
scope: token.scope,
173+
};
174+
}
175+
176+
// if (code && !nonce) {
177+
// console.warn("Missing oauth nonce from localStorage");
178+
// }
179+
180+
export async function oauthHandleRedirectIfPresent(opts?: { hubUrl?: string }): Promise<OAuthResult | false> {
181+
if (typeof window === "undefined") {
182+
throw new Error("oauthHandleRedirect is only available in the browser");
183+
}
184+
185+
const searchParams = new URLSearchParams(window.location.search);
186+
187+
if (searchParams.has("error")) {
188+
return oauthHandleRedirect(opts);
189+
}
190+
191+
if (searchParams.has("code")) {
192+
if (!localStorage.getItem("huggingface.co:oauth:nonce")) {
193+
console.warn(
194+
"Missing oauth nonce from localStorage. This can happen when the user refreshes the page after logging in, without changing the URL."
195+
);
196+
return false;
197+
}
198+
199+
return oauthHandleRedirect(opts);
200+
}
201+
202+
return false;
203+
}

0 commit comments

Comments
 (0)