|
import { HUB_URL } from "../consts"; |
|
import { createApiError } from "../error"; |
|
|
|
export interface UserInfo { |
|
|
|
|
|
|
|
sub: string; |
|
|
|
|
|
|
|
name: string; |
|
|
|
|
|
|
|
preferred_username: string; |
|
|
|
|
|
|
|
email_verified?: boolean; |
|
|
|
|
|
|
|
email?: string; |
|
|
|
|
|
|
|
picture: string; |
|
|
|
|
|
|
|
profile: string; |
|
|
|
|
|
|
|
website?: string; |
|
|
|
|
|
|
|
|
|
isPro: boolean; |
|
|
|
|
|
|
|
canPay?: boolean; |
|
|
|
|
|
|
|
orgs?: Array<{ |
|
|
|
|
|
|
|
sub: string; |
|
|
|
|
|
|
|
name: string; |
|
|
|
|
|
|
|
preferred_username: string; |
|
|
|
|
|
|
|
picture: string; |
|
|
|
|
|
|
|
|
|
isEnterprise: boolean; |
|
|
|
|
|
|
|
canPay?: boolean; |
|
|
|
|
|
|
|
roleInOrg?: string; |
|
|
|
|
|
|
|
|
|
|
|
pendingSSO?: boolean; |
|
|
|
|
|
|
|
|
|
|
|
missingMFA?: boolean; |
|
}>; |
|
} |
|
|
|
export interface OAuthResult { |
|
accessToken: string; |
|
accessTokenExpiresAt: Date; |
|
userInfo: UserInfo; |
|
|
|
|
|
|
|
state?: string; |
|
|
|
|
|
|
|
scope: string; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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<OAuthResult> { |
|
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 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")]; |
|
|
|
if (error) { |
|
throw new Error(`${error}: ${errorDescription}`); |
|
} |
|
|
|
const code = searchParams.get("code"); |
|
const nonce = opts?.nonce ?? localStorage.getItem("huggingface.co:oauth:nonce"); |
|
|
|
if (!code) { |
|
throw new Error("Missing oauth code from query parameters in redirected URL: " + redirectedUrl); |
|
} |
|
|
|
if (!nonce) { |
|
throw new Error("Missing oauth nonce from localStorage"); |
|
} |
|
|
|
const codeVerifier = opts?.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 openidConfig: { |
|
authorization_endpoint: string; |
|
token_endpoint: string; |
|
userinfo_endpoint: string; |
|
} = await openidConfigRes.json(); |
|
|
|
const tokenRes = await fetch(openidConfig.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(), |
|
}); |
|
|
|
if (!opts?.codeVerifier) { |
|
localStorage.removeItem("huggingface.co:oauth:code_verifier"); |
|
} |
|
if (!opts?.nonce) { |
|
localStorage.removeItem("huggingface.co:oauth:nonce"); |
|
} |
|
|
|
if (!tokenRes.ok) { |
|
throw await createApiError(tokenRes); |
|
} |
|
|
|
const token: { |
|
access_token: string; |
|
expires_in: number; |
|
id_token: string; |
|
|
|
scope: string; |
|
token_type: string; |
|
} = await tokenRes.json(); |
|
|
|
const accessTokenExpiresAt = new Date(Date.now() + token.expires_in * 1000); |
|
|
|
const userInfoRes = await fetch(openidConfig.userinfo_endpoint, { |
|
headers: { |
|
Authorization: `Bearer ${token.access_token}`, |
|
}, |
|
}); |
|
|
|
if (!userInfoRes.ok) { |
|
throw await createApiError(userInfoRes); |
|
} |
|
|
|
const userInfo: UserInfo = await userInfoRes.json(); |
|
|
|
return { |
|
accessToken: token.access_token, |
|
accessTokenExpiresAt, |
|
userInfo: userInfo, |
|
state: parsedState.state, |
|
scope: token.scope, |
|
}; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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<OAuthResult | false> { |
|
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(opts?.redirectedUrl ?? 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; |
|
} |
|
|