From 36c58ff9fdeb08ba24037df6b94e00668bc8289c Mon Sep 17 00:00:00 2001 From: afterlifepro Date: Wed, 23 Jul 2025 23:12:13 +0100 Subject: [PATCH] Implement access token generation in spotify.ts .refreshToken stores the refresh token in plaintext and should not be commited to git by mistake --- .gitignore | 1 + src/components/playing/spotify.ts | 135 +++++++++++++++++++++++++++++- 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 16d54bb..a8ef08a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ pnpm-debug.log* # jetbrains setting folder .idea/ +.refreshToken diff --git a/src/components/playing/spotify.ts b/src/components/playing/spotify.ts index e577545..20e5db9 100644 --- a/src/components/playing/spotify.ts +++ b/src/components/playing/spotify.ts @@ -1,3 +1,134 @@ +import { + SPOTIFY_CLIENT_ID, + SPOTIFY_CLIENT_SECRET, + SPOTIFY_REDIRECT_URI, +} from "astro:env/server"; +import fs from "fs/promises"; + +const throws = (val: unknown) => { + throw val; +}; + +/** via: https://www.totaltypescript.com/concepts/the-prettify-helper */ +type Prettify = { + [K in keyof T]: T[K]; +} & {}; + +type AuthToken = { + access_token: string; + token_type: "Bearer"; + scope: string; + expires_in: number; + refresh_token: string; +}; + +type RefreshToken = Prettify< + Omit & { refresh_token?: string } +>; + +const isRefreshToken = (obj: unknown): obj is RefreshToken => + // validate is object + typeof obj === "object" && + obj !== null && + // validate properties + "access_token" in obj && + typeof obj.access_token === "string" && + "token_type" in obj && + obj.token_type === "Bearer" && + "scope" in obj && + typeof obj.scope === "string" && + "expires_in" in obj && + typeof obj.expires_in === "number" && + // either refresh token exists as string or not at all + (("refresh_token" in obj && typeof obj.refresh_token === "string") || + !("refresh_token" in obj)); + +// auth token is just refresh with a non optional refresh_token +const isAuthToken = (obj: unknown): obj is AuthToken => + isRefreshToken(obj) && "refresh_token" in obj; + export async function getAccessCode(userAuthCode?: string) { - return "Not implemented!" -} \ No newline at end of file + const refreshToken = await fs + .readFile("./.refreshToken", { encoding: "utf-8" }) + .catch((_) => undefined) + .then((x) => (x === "" || x === "REFRESH_TOKEN" ? undefined : x)); + if (!(userAuthCode || refreshToken)) + throw new Error( + "No auth code or refresh token.\nGenerate an auth code at `/src/pages/_callback`\nA refresh token will be generated from this auth token.", + ); + + // prefer auth codes over refresh tokens + // since the auth code may have updated scopes. + + const accessFrom: + | { + userAuthCode: string; + } + | { + refreshToken: string; + } = userAuthCode + ? { userAuthCode } + : refreshToken + ? { refreshToken } + : (undefined as never); + + const req = fetch("https://accounts.spotify.com/api/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from(SPOTIFY_CLIENT_ID + ":" + SPOTIFY_CLIENT_SECRET).toString("base64")}`, + }, + body: new URLSearchParams({ + grant_type: + "userAuthCode" in accessFrom ? "authorization_code" : "refresh_token", + ...("userAuthCode" in accessFrom + ? { + code: accessFrom.userAuthCode, + redirect_uri: SPOTIFY_REDIRECT_URI, + } + : { + refresh_token: accessFrom.refreshToken, + }), + }).toString(), + }); + + return ( + req + // if res isn't 200 handle it in the catch + .then((res) => (res.ok ? res : throws(res))) + // request is 200-299 + // json can throw SyntaxError in this case + .then((res) => res.json()) + .then((res) => + "userAuthCode" in accessFrom + ? isAuthToken(res) + ? { code: res.access_token, refresh: res.refresh_token } + : throws({ err: "INVALID_RESPONSE", res }) + : isRefreshToken(res) + ? { + code: res.access_token, + refresh: res.refresh_token ?? accessFrom.refreshToken, + } + : throws({ err: "INVALID_RESPONSE", res }), + ) + // res is now an access token and refresh token + .then((res) => { + fs.writeFile("./.refreshToken", res.refresh, { encoding: "utf-8" }); + return res.code; + }) + .catch((err) => { + // SyntaxError + // Response + // {err: string, res: Response} + if (err instanceof Response) console.error("Request failed:", err); + else if (err instanceof SyntaxError) + console.error("Response JSON failed", err); + else if (err.err === "INVALID_RESPONSE") + console.error("Response malformed:", err); + else { + console.error("Unhandled exception."); + throw err; + } + }) + ); +} -- 2.51.2