Personal Site
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement access token generation in spotify.ts

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

+134 -2
+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 + }