Implement access token generation in spotify.ts

.refreshToken stores the refresh token in plaintext and should not be commited to git by mistake

vielle.dev 36c58ff9 47b06643

verified
Changed files
+134 -2
src
components
playing
+1
.gitignore
··· 22 22 23 23 # jetbrains setting folder 24 24 .idea/ 25 + .refreshToken
+133 -2
src/components/playing/spotify.ts
··· 1 + import { 2 + SPOTIFY_CLIENT_ID, 3 + SPOTIFY_CLIENT_SECRET, 4 + SPOTIFY_REDIRECT_URI, 5 + } from "astro:env/server"; 6 + import fs from "fs/promises"; 7 + 8 + const throws = (val: unknown) => { 9 + throw val; 10 + }; 11 + 12 + /** via: https://www.totaltypescript.com/concepts/the-prettify-helper */ 13 + type Prettify<T> = { 14 + [K in keyof T]: T[K]; 15 + } & {}; 16 + 17 + type AuthToken = { 18 + access_token: string; 19 + token_type: "Bearer"; 20 + scope: string; 21 + expires_in: number; 22 + refresh_token: string; 23 + }; 24 + 25 + type RefreshToken = Prettify< 26 + Omit<AuthToken, "refresh_token"> & { refresh_token?: string } 27 + >; 28 + 29 + const isRefreshToken = (obj: unknown): obj is RefreshToken => 30 + // validate is object 31 + typeof obj === "object" && 32 + obj !== null && 33 + // validate properties 34 + "access_token" in obj && 35 + typeof obj.access_token === "string" && 36 + "token_type" in obj && 37 + obj.token_type === "Bearer" && 38 + "scope" in obj && 39 + typeof obj.scope === "string" && 40 + "expires_in" in obj && 41 + typeof obj.expires_in === "number" && 42 + // either refresh token exists as string or not at all 43 + (("refresh_token" in obj && typeof obj.refresh_token === "string") || 44 + !("refresh_token" in obj)); 45 + 46 + // auth token is just refresh with a non optional refresh_token 47 + const isAuthToken = (obj: unknown): obj is AuthToken => 48 + isRefreshToken(obj) && "refresh_token" in obj; 49 + 1 50 export async function getAccessCode(userAuthCode?: string) { 2 - return "Not implemented!" 3 - } 51 + const refreshToken = await fs 52 + .readFile("./.refreshToken", { encoding: "utf-8" }) 53 + .catch((_) => undefined) 54 + .then((x) => (x === "" || x === "REFRESH_TOKEN" ? undefined : x)); 55 + if (!(userAuthCode || refreshToken)) 56 + throw new Error( 57 + "No auth code or refresh token.\nGenerate an auth code at `/src/pages/_callback`\nA refresh token will be generated from this auth token.", 58 + ); 59 + 60 + // prefer auth codes over refresh tokens 61 + // since the auth code may have updated scopes. 62 + 63 + const accessFrom: 64 + | { 65 + userAuthCode: string; 66 + } 67 + | { 68 + refreshToken: string; 69 + } = userAuthCode 70 + ? { userAuthCode } 71 + : refreshToken 72 + ? { refreshToken } 73 + : (undefined as never); 74 + 75 + const req = fetch("https://accounts.spotify.com/api/token", { 76 + method: "POST", 77 + headers: { 78 + "Content-Type": "application/x-www-form-urlencoded", 79 + Authorization: `Basic ${Buffer.from(SPOTIFY_CLIENT_ID + ":" + SPOTIFY_CLIENT_SECRET).toString("base64")}`, 80 + }, 81 + body: new URLSearchParams({ 82 + grant_type: 83 + "userAuthCode" in accessFrom ? "authorization_code" : "refresh_token", 84 + ...("userAuthCode" in accessFrom 85 + ? { 86 + code: accessFrom.userAuthCode, 87 + redirect_uri: SPOTIFY_REDIRECT_URI, 88 + } 89 + : { 90 + refresh_token: accessFrom.refreshToken, 91 + }), 92 + }).toString(), 93 + }); 94 + 95 + return ( 96 + req 97 + // if res isn't 200 handle it in the catch 98 + .then((res) => (res.ok ? res : throws(res))) 99 + // request is 200-299 100 + // json can throw SyntaxError in this case 101 + .then((res) => res.json()) 102 + .then((res) => 103 + "userAuthCode" in accessFrom 104 + ? isAuthToken(res) 105 + ? { code: res.access_token, refresh: res.refresh_token } 106 + : throws({ err: "INVALID_RESPONSE", res }) 107 + : isRefreshToken(res) 108 + ? { 109 + code: res.access_token, 110 + refresh: res.refresh_token ?? accessFrom.refreshToken, 111 + } 112 + : throws({ err: "INVALID_RESPONSE", res }), 113 + ) 114 + // res is now an access token and refresh token 115 + .then((res) => { 116 + fs.writeFile("./.refreshToken", res.refresh, { encoding: "utf-8" }); 117 + return res.code; 118 + }) 119 + .catch((err) => { 120 + // SyntaxError 121 + // Response 122 + // {err: string, res: Response} 123 + if (err instanceof Response) console.error("Request failed:", err); 124 + else if (err instanceof SyntaxError) 125 + console.error("Response JSON failed", err); 126 + else if (err.err === "INVALID_RESPONSE") 127 + console.error("Response malformed:", err); 128 + else { 129 + console.error("Unhandled exception."); 130 + throw err; 131 + } 132 + }) 133 + ); 134 + }