File size: 5,899 Bytes
c649ce2
99353b3
e6c40b4
99353b3
 
 
2445c98
99353b3
 
 
ed8d376
99353b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212a332
99353b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6732e81
99353b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33c55e3
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
const HUB_URL = "https://huggingface.co";

async function createApiError(res) {
  throw new Error (await res.text());
}

function hexFromBytes(arr) {
	if (globalThis.Buffer) {
		return globalThis.Buffer.from(arr).toString("hex");
	} else {
		const bin = [];
		arr.forEach((byte) => {
			bin.push(byte.toString(16).padStart(2, "0"));
		});
		return bin.join("");
	}
}


/**
 * Use "Sign in with Hub" to authenticate a user, and get oauth user info / access token.
 *
 * When called the first time, it will redirect the user to the Hub login page, which then redirects
 * to the current URL (or custom URL set).
 *
 * When called the second time, after the redirect, it will check the query parameters and return
 * the oauth user info / access token.
 *
 * If called inside an iframe, it will open a new window instead of redirecting the iframe, by default.
 *
 * When called from inside a static Space with OAuth enabled, it will load the config from the space.
 *
 * (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.)
 */
async function oauthLogin(opts) {
	if (typeof window === "undefined") {
		throw new Error("oauthLogin is only available in the browser");
	}

	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 = await openidConfigRes.json();

	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 && !nonce) {
		console.warn("Missing oauth nonce from localStorage");
	}

	if (code && nonce) {
		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");
		}

		if (!state.startsWith(nonce + ":")) {
            console.log(state, nonce+":")
			throw new Error("Invalid oauth state in redirected URL");
		}

		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: opts?.redirectUri || window.location.href,
				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 = 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 = 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: state.split(":")[1],
			scope: token.scope,
		};
	}

	const opensInNewWindow = opts?.newWindow ?? (window.self !== window.top && window.self !== window.parent);

	const newNonce = crypto.randomUUID();
	// Two random UUIDs concatenated together, because min length is 43 and max length is 128
	const newCodeVerifier = crypto.randomUUID() + crypto.randomUUID();

	localStorage.setItem("huggingface.co:oauth:nonce", newNonce);
	localStorage.setItem("huggingface.co:oauth:code_verifier", newCodeVerifier);

	const state = `${newNonce}:${opts?.state || ""}`;

	const redirectUri = opts?.redirectUri || window.location.href;

	// @ts-expect-error window.huggingface is defined inside static Spaces.
	const variables = window?.huggingface?.variables ?? 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 = hexFromBytes(
		new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(newCodeVerifier)))
	);

	if (opensInNewWindow) {
		window.open(
			`${opendidConfig.authorization_endpoint}?${new URLSearchParams({
				client_id: clientId,
				scope: opts?.scopes || "openid profile",
				response_type: "code",
				redirect_uri: redirectUri,
				state,
				code_challenge: challenge,
				code_challenge_method: "S256",
			}).toString()}`,
			"_blank"
		);
		throw new Error("Opened in new window");
	} else {
		window.location.href = `${opendidConfig.authorization_endpoint}?${new URLSearchParams({
			client_id: clientId,
			scope: opts?.scopes || "openid profile",
			response_type: "code",
			redirect_uri: redirectUri,
			state,
			code_challenge: challenge,
			code_challenge_method: "S256",
		}).toString()}`;
		throw new Error("Redirected");
	}
}

oauthLogin().then(console.log);