feat(SongSpotlight): support refresh tokens (#870)

This commit is contained in:
nexpid 2026-03-04 16:04:23 +01:00 • committed by GitHub
parent 00d797b77c
commit a1a12eee41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 91 additions and 46 deletions

View file

@ -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;
}
}

View file

@ -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);
}
}}
/>

View file

@ -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) {

View file

@ -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,

View file

@ -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 {

View file

@ -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";