feat(SongSpotlight): support refresh tokens (#870)
This commit is contained in:
parent
00d797b77c
commit
a1a12eee41
6 changed files with 91 additions and 46 deletions
|
|
@ -9,7 +9,6 @@ import { showToast, Toasts, UserStore } from "@webpack/common";
|
|||
|
||||
import { useAuthorizationStore } from "./stores/AuthorizationStore";
|
||||
import { useSongStore } from "./stores/SongStore";
|
||||
import { logger } from "./utils";
|
||||
|
||||
export const apiConstants = {
|
||||
api: "https://dc.songspotlight.nexpid.xyz/",
|
||||
|
|
@ -20,16 +19,36 @@ export const apiConstants = {
|
|||
songLimit: 6,
|
||||
};
|
||||
|
||||
export async function authFetch(_url: string | URL, options?: RequestInit) {
|
||||
const url = new URL(_url);
|
||||
let refreshPromise: Promise<boolean> | undefined;
|
||||
async function refreshAccessToken() {
|
||||
const token = useAuthorizationStore.getState().getToken();
|
||||
if (!token) return false;
|
||||
|
||||
return refreshPromise ??= fetch(new URL("api/auth/refresh", apiConstants.api), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Refresh-Token": token.refresh,
|
||||
},
|
||||
body: token.access,
|
||||
}).then(async res => {
|
||||
if (!res.ok) return false;
|
||||
|
||||
const access = await res.text();
|
||||
useAuthorizationStore.getState().setToken(access, token.refresh);
|
||||
return true;
|
||||
}).finally(() => refreshPromise = undefined);
|
||||
}
|
||||
|
||||
export async function authFetch(url: string | URL, options?: RequestInit, retried = false) {
|
||||
url = new URL(url);
|
||||
try {
|
||||
const token = useAuthorizationStore.getState().getToken();
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
Authorization: useAuthorizationStore.getState().getToken()!,
|
||||
},
|
||||
Authorization: token?.access,
|
||||
} as HeadersInit,
|
||||
});
|
||||
|
||||
if (res.ok) return res;
|
||||
|
|
@ -37,36 +56,29 @@ export async function authFetch(_url: string | URL, options?: RequestInit) {
|
|||
// not modified
|
||||
if (res.status === 304) return null;
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
// unauthorized
|
||||
if (res.status === 401) {
|
||||
useAuthorizationStore.getState().deleteToken();
|
||||
const retry = !retried && await refreshAccessToken();
|
||||
if (retry) return await authFetch(url, options, true);
|
||||
else {
|
||||
useAuthorizationStore.getState().deleteTokens();
|
||||
showToast("You have been signed out from Song Spotlight. Please sign in again.", Toasts.Type.FAILURE);
|
||||
}
|
||||
} else {
|
||||
showToast(
|
||||
!text.includes("<body>") && res.status >= 400 && res.status <= 599
|
||||
? `Song Spotlight: ${text}`
|
||||
: `Song Spotlight fetch error at ${url.pathname}`,
|
||||
Toasts.Type.FAILURE,
|
||||
);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
showToast(
|
||||
!text.includes("<body>") && res.status >= 400 && res.status <= 599
|
||||
? `Song Spotlight: ${text}`
|
||||
: `Song Spotlight fetch error at ${url.pathname}`,
|
||||
Toasts.Type.FAILURE,
|
||||
);
|
||||
|
||||
logger.error(
|
||||
"Got an authFetch response error",
|
||||
options?.method ?? "GET",
|
||||
url.toString(),
|
||||
res.status,
|
||||
text,
|
||||
);
|
||||
throw new Error(text);
|
||||
} catch (error) {
|
||||
showToast(`Song Spotlight: ${error}`, Toasts.Type.FAILURE);
|
||||
|
||||
logger.error(
|
||||
"Got an authFetch request error",
|
||||
options?.method ?? "GET",
|
||||
url.toString(),
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,15 +30,23 @@ export function presentOAuth2Modal() {
|
|||
const url = new URL(location);
|
||||
url.searchParams.append("whois", "equicord");
|
||||
|
||||
const token = await (await authFetch(url))?.text();
|
||||
if (!token) return;
|
||||
const res = await authFetch(url);
|
||||
if (!res) throw "Response wasn't ok";
|
||||
|
||||
useAuthorizationStore.getState().setToken(token);
|
||||
const access = await res.text();
|
||||
if (!access) throw "Access token is missing";
|
||||
|
||||
const refresh = res.headers.get("X-Refresh-Token");
|
||||
// STUB uncomment this once API is fully rolled out
|
||||
// if (!refresh) throw "Refresh token is missing";
|
||||
|
||||
useAuthorizationStore.getState().setToken(access, refresh || "");
|
||||
getData();
|
||||
|
||||
showToast("Successfully authorized!", Toasts.Type.SUCCESS);
|
||||
} catch (error) {
|
||||
logger.error("Got an error during OAuth2", error);
|
||||
if (typeof error === "string") showToast(error, Toasts.Type.FAILURE);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -10,11 +10,16 @@ import { UserStore, zustandCreate, zustandPersist } from "@webpack/common";
|
|||
|
||||
import { PersistedZustandStore, ZustandDefinition } from "./zustand";
|
||||
|
||||
export interface Token {
|
||||
access: string;
|
||||
refresh: string;
|
||||
}
|
||||
|
||||
interface AuthorizationState {
|
||||
tokens: Record<string, string>;
|
||||
getToken(): string | undefined;
|
||||
setToken(token: string): void;
|
||||
deleteToken(): void;
|
||||
tokens: Record<string, Token>;
|
||||
getToken(): Token | undefined;
|
||||
setToken(access: string, refresh: string): void;
|
||||
deleteTokens(): void;
|
||||
isAuthorized(): boolean;
|
||||
}
|
||||
|
||||
|
|
@ -26,17 +31,18 @@ export const useAuthorizationStore: PersistedZustandStore<AuthorizationState> =
|
|||
getToken() {
|
||||
return get().tokens[UserStore.getCurrentUser()?.id];
|
||||
},
|
||||
setToken(token) {
|
||||
setToken(access, refresh) {
|
||||
const userId = UserStore.getCurrentUser()?.id;
|
||||
if (userId) {
|
||||
set({
|
||||
tokens: Object.assign(get().tokens, {
|
||||
[userId]: token,
|
||||
}),
|
||||
tokens: {
|
||||
...get().tokens,
|
||||
[userId]: { access, refresh },
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
deleteToken() {
|
||||
deleteTokens() {
|
||||
set({ tokens: {} });
|
||||
},
|
||||
isAuthorized() {
|
||||
|
|
@ -45,11 +51,24 @@ export const useAuthorizationStore: PersistedZustandStore<AuthorizationState> =
|
|||
})) as ZustandDefinition<AuthorizationState>,
|
||||
{
|
||||
name: "songspotlight-auth",
|
||||
version: 1,
|
||||
migrate(persisted: any, version: number) {
|
||||
if (version === 0) {
|
||||
persisted.tokens = Object.fromEntries(
|
||||
Object.entries(persisted.tokens).map(([userId, access]) => [userId, {
|
||||
access,
|
||||
refresh: "",
|
||||
}]),
|
||||
);
|
||||
}
|
||||
|
||||
return persisted;
|
||||
},
|
||||
storage: {
|
||||
async getItem(name: string) {
|
||||
return (await DataStore.get(name)) ?? null;
|
||||
},
|
||||
async setItem(name: string, value: string) {
|
||||
async setItem(name: string, value: unknown) {
|
||||
return await DataStore.set(name, value);
|
||||
},
|
||||
async removeItem(name: string) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { definePluginSettings } from "@api/Settings";
|
||||
import { makeRange, OptionType } from "@utils/types";
|
||||
|
||||
import { apiConstants } from "./lib/api";
|
||||
import Settings from "./ui/settings";
|
||||
|
||||
export default definePluginSettings({
|
||||
|
|
@ -18,8 +19,8 @@ export default definePluginSettings({
|
|||
profileSongsLimit: {
|
||||
type: OptionType.SLIDER,
|
||||
description: "How many songs are shown when initially clicking on a user",
|
||||
default: 6,
|
||||
markers: makeRange(1, 6),
|
||||
default: apiConstants.songLimit,
|
||||
markers: makeRange(1, apiConstants.songLimit),
|
||||
},
|
||||
manager: {
|
||||
type: OptionType.COMPONENT,
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ interface SettingsProps {
|
|||
}
|
||||
|
||||
export default function Settings({ templateData }: SettingsProps) {
|
||||
const { isAuthorized, deleteToken } = useAuthorizationStore();
|
||||
const { isAuthorized, deleteTokens } = useAuthorizationStore();
|
||||
const { self } = useSongStore();
|
||||
|
||||
const ticked = useRef(false);
|
||||
|
|
@ -172,7 +172,7 @@ export default function Settings({ templateData }: SettingsProps) {
|
|||
<Button
|
||||
variant="dangerPrimary"
|
||||
onClick={() => {
|
||||
deleteToken();
|
||||
deleteTokens();
|
||||
showToast("Successfully signed out!", Toasts.Type.SUCCESS);
|
||||
}}
|
||||
disabled={pending}
|
||||
|
|
@ -189,7 +189,7 @@ export default function Settings({ templateData }: SettingsProps) {
|
|||
setPending(true);
|
||||
try {
|
||||
await deleteData();
|
||||
deleteToken();
|
||||
deleteTokens();
|
||||
|
||||
showToast("Successfully deleted songs!", Toasts.Type.SUCCESS);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@
|
|||
|
||||
import { BaseText } from "@components/index";
|
||||
import { Native } from "@equicordplugins/songSpotlight.desktop/service";
|
||||
import { CardClasses, ContainerClasses, OverlayClasses, Spinner } from "@equicordplugins/songSpotlight.desktop/ui/common";
|
||||
import {
|
||||
CardClasses,
|
||||
ContainerClasses,
|
||||
OverlayClasses,
|
||||
Spinner,
|
||||
} from "@equicordplugins/songSpotlight.desktop/ui/common";
|
||||
import { RenderSongInfo } from "@song-spotlight/api/handlers";
|
||||
import { UserData } from "@song-spotlight/api/structs";
|
||||
import { sid } from "@song-spotlight/api/util";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue