From 03071a3476ccbb0f250f47de592a88513ad9c29a Mon Sep 17 00:00:00 2001 From: Petter Machado Date: Fri, 8 Nov 2024 15:35:34 +0100 Subject: [PATCH] feat: User authentication (#15) * fix: Allow unauthenticated requests Also, adds tokenType to request opts to be able to pass a token that should be sent with Bearer . * fix: Add /auth/login * fix: Add refreshAccessToken * fix: Add User model * fix: Save user after logging in * fix: Add TokenSource * fix: Use tokenSource * fix: Logout * fix: Do not retry token errors * fix: Log in/Log out components * fix: Do not fake expireAt * fix: Move semaphore into Api to cover the whole transaction * fix: Do not query User in non-user mode * fix: Use Record * fix: Update README.md --- README.md | 4 +- api/index.ts | 46 +++++++- app/fetchers.ts | 2 + app/routes/settings.tsx | 179 ++++++++++++++++++++++++++++- app/types.ts | 5 + lib/db/index.ts | 12 +- lib/soundtrack-api/client.ts | 58 +++++++--- lib/soundtrack-api/index.ts | 214 +++++++++++++++++++++++++++++------ lib/soundtrack-api/types.ts | 6 + lib/token/index.ts | 57 ++++++++++ lib/worker/index.ts | 3 +- 11 files changed, 529 insertions(+), 57 deletions(-) create mode 100644 lib/token/index.ts diff --git a/README.md b/README.md index 1579365..4277d68 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ To configure your database, edit `./lib/db/index.ts`. ## Soundtrack API configuration -In order to make requests to the Soundtrack API you will need to provide the app with an API token. The API will only let you see and take actions on the accounts and zones that you have configured. +In order to make requests to the Soundtrack API you will need to provide the app with an [API token](https://api.soundtrackyourbrand.com/v2/docs#requirements), or use [User authentication](https://api.soundtrackyourbrand.com/v2/docs#authorizing-as-a-user). The API will only let you see and take actions on the accounts and zones that you have access to as an API client or Soundtrack user. The `.env.sample` file contains the required fields to make requests to the Soundtrack API. @@ -61,7 +61,7 @@ cp .env.sample .env | `SYNC_DB` | - | When set to anything truthy will sync all database tables. **Note: All your data will be deleted**. | | `LOG_LEVEL` | `info` when `NODE_ENV=production` else `debug` | The log level passed to `pino({ level: logLevel })`. | | `SOUNDTRACK_API_URL` | - | The url of the Soundtrack API. | -| `SOUNDTRACK_API_TOKEN` | - | The Soundtrack API token, used in all requests towards the Soundtrack API. | +| `SOUNDTRACK_API_TOKEN` | - | The Soundtrack API token, used in all requests towards the Soundtrack API when provided. | | `REQUEST_LOG` | - | when set the anything truthy will enable http request logs. | | `WORKER_INTERVAL` | `60` | The worker check interval in seconds. | diff --git a/api/index.ts b/api/index.ts index 299ce5f..28a5fb1 100644 --- a/api/index.ts +++ b/api/index.ts @@ -11,11 +11,13 @@ import { Event, Run, ZoneEvent, + User, } from "../lib/db/index.js"; import { Model } from "sequelize"; import { InMemoryCache } from "../lib/cache/index.js"; import { SequelizeCache } from "../lib/db/cache.js"; import { getLogger } from "../lib/logger/index.js"; +import tokenSource from "lib/token/index.js"; const logger = getLogger("api/index"); @@ -350,7 +352,49 @@ router.get("/events/:eventId/actions", async (req, res) => { const cache = process.env["DB_CACHE"] ? new SequelizeCache() : new InMemoryCache(); -const soundtrackApi = new Api({ cache }); + +const soundtrackApi = new Api({ cache, tokenSource }); + +router.get("/auth/mode", async (req, res) => { + const mode = soundtrackApi.mode; + if (mode === "user") { + const user = await User.findByPk(0); + res.json({ mode, loggedIn: !!user }); + } else { + res.json({ mode, loggedIn: false }); + } +}); + +router.post("/auth/login", async (req, res) => { + if (soundtrackApi.mode !== "user") { + res.status(409).send("Not in user mode"); + return; + } + const { email, password } = req.body; + if (!email || !password) { + res.status(400).send("Missing email or password"); + return; + } + try { + const loginResponse = await soundtrackApi.login(email, password); + await tokenSource.updateToken(loginResponse); + await cache.clear(); + res.sendStatus(200); + } catch (e) { + logger.error("Failed to login: " + e); + res.sendStatus(500); + } +}); + +router.post("/auth/logout", async (req, res) => { + if (soundtrackApi.mode !== "user") { + res.status(409).send("Not in user mode"); + return; + } + tokenSource.logout(); + cache.clear(); + res.sendStatus(200); +}); router.get("/zones/:zoneId", async (req, res) => { try { diff --git a/app/fetchers.ts b/app/fetchers.ts index 409277c..5161292 100644 --- a/app/fetchers.ts +++ b/app/fetchers.ts @@ -5,6 +5,7 @@ import { Account, AccountLibrary, Assignable, + AuthMode, CacheMetadata, Event, EventAction, @@ -82,6 +83,7 @@ export const assignableFetcher: Fetcher = (url) => defaultFetcher(url).then(toAssignable); export const cacheFetcher: Fetcher = defaultFetcher; +export const authModeFetcher: Fetcher = defaultFetcher; export const errorHandler = async (res: Response) => { if (!res.ok) { diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index bf99d36..c430078 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useCallback, useState } from "react"; import useSWR, { mutate } from "swr"; import pluralize from "pluralize"; import { @@ -17,7 +17,7 @@ import { CommandList, } from "~/components/ui/command"; import { Button } from "~/components/ui/button"; -import { accountsFetcher, cacheFetcher } from "~/fetchers"; +import { accountsFetcher, authModeFetcher, cacheFetcher } from "~/fetchers"; import { useMusicLibrary } from "~/lib/MusicLibraryContext"; import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; import { cn, pageTitle } from "~/lib/utils"; @@ -27,6 +27,9 @@ import { PopoverContent, PopoverTrigger, } from "~/components/ui/popover"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import useSWRMutation from "swr/mutation"; export const meta: MetaFunction = () => { return [{ title: pageTitle("Settings") }]; @@ -35,6 +38,7 @@ export const meta: MetaFunction = () => { export default function Settings() { const { data: accounts } = useSWR("/api/v1/accounts", accountsFetcher); const { data: cache } = useSWR("/api/v1/cache", cacheFetcher); + const { data: authMode } = useSWR("/api/v1/auth/mode", authModeFetcher); const [loading, setLoading] = useState([]); const { libraryId, setLibraryId } = useMusicLibrary(); const selectedAccount = libraryId @@ -216,6 +220,177 @@ export default function Settings() { Refresh count +
+

Authentication

+

+ This app is using {authMode?.mode} authentication. +

+
+ {authMode?.mode === "token" && ( +

+ With token authentication this app is using a Soundtrack API token + to make requests to the Soundtrack API. +

+ )} + {authMode?.mode === "user" && ( + <> + + {!authMode.loggedIn && } + {authMode.loggedIn && } + + )} +
); } + +function LoggedInAlert({ + loggedIn, + className, +}: { + loggedIn: boolean; + className?: string; +}) { + return ( + + + {loggedIn ? "Logged in, all set!" : "Not logged in"} + + {!loggedIn && ( + + Until you are logged in you will not be able to access the Soundtrack + API. + + )} + + ); +} + +type LoginData = { + email: string; + password: string; +}; + +async function userLogin( + url: string, + { arg }: { arg: LoginData }, +): Promise { + return await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(arg), + }).then((res) => { + if (!res.ok) { + throw new Error("Login failed"); + } + }); +} + +function UserLogin() { + const { trigger } = useSWRMutation("/api/v1/auth/login", userLogin); + const [loading, setLoading] = useState(false); + const [data, setData] = useState({ email: "", password: "" }); + const [error, setError] = useState(null); + + const handleLogin = useCallback(async (data: LoginData) => { + if (loading) return; + setError(null); + try { + await trigger(data); + mutate("/api/v1/auth/mode"); + toast("Logged in"); + } catch (e) { + console.error(e); + setError("Login failed"); + } finally { + setLoading(false); + } + }, []); + + return ( + <> +

Log in

+
{ + e.preventDefault(); + handleLogin(data); + }} + className="max-w-80" + > + + setData((d) => ({ ...d, email: e.target.value }))} + /> + + setData((d) => ({ ...d, password: e.target.value }))} + /> +
+ +
+
+ {error && ( + + Login failed + {error} + + )} + + ); +} + +async function userLogout(url: string): Promise { + return await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }).then((res) => { + if (!res.ok) { + throw new Error("Logout failed"); + } + }); +} + +function UserLogout() { + const { trigger } = useSWRMutation("/api/v1/auth/logout", userLogout); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleLogout = useCallback(async () => { + setError(null); + try { + await trigger(); + mutate("/api/v1/auth/mode"); + toast("Logged out"); + } catch (e) { + console.error(e); + setError("Logout failed"); + } finally { + setLoading(false); + } + }, []); + + return ( + <> + + {error && ( + + Login failed + {error} + + )} + + ); +} diff --git a/app/types.ts b/app/types.ts index c7d498c..2004fad 100644 --- a/app/types.ts +++ b/app/types.ts @@ -93,3 +93,8 @@ export type AccountLibrary = { export type CacheMetadata = { count: number; }; + +export type AuthMode = { + mode: "user" | "token"; + loggedIn: boolean; +}; diff --git a/lib/db/index.ts b/lib/db/index.ts index dd96c2e..437cf81 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -119,6 +119,16 @@ export const CacheEntry = _sequelize.define("CacheEntry", { value: DataTypes.TEXT, }); +export const User = _sequelize.define("User", { + key: { + type: DataTypes.NUMBER, + primaryKey: true, + }, + token: DataTypes.STRING, + expiresAt: DataTypes.DATE, + refreshToken: DataTypes.STRING, +}); + // Relations // ========= @@ -146,7 +156,7 @@ Action.belongsTo(Event); * @param options Options passed to the models sync call. */ export async function sync(options: SyncOptions) { - const types = [Event, ZoneEvent, Run, Action, CacheEntry]; + const types = [Event, ZoneEvent, Run, Action, CacheEntry, User]; types.forEach(async (type) => { logger.info(`Syncing table name ${type.getTableName()} ...`); await type.sync(options); diff --git a/lib/soundtrack-api/client.ts b/lib/soundtrack-api/client.ts index a8bd4bb..a860be6 100644 --- a/lib/soundtrack-api/client.ts +++ b/lib/soundtrack-api/client.ts @@ -1,5 +1,5 @@ import { inspect } from "util"; -import retry from "retry"; +import retry, { OperationOptions } from "retry"; import { Semaphore } from "@shopify/semaphore"; import { getLogger } from "../logger/index.js"; @@ -11,9 +11,14 @@ type QueryResponse = { errors?: unknown[]; }; -type RunOptions = { +type TokenType = "Bearer" | "Basic"; + +export type RunOptions = { errorPolicy?: "all" | "none"; token?: string; + tokenType?: TokenType; + unauthenticated?: boolean; + retry?: OperationOptions; }; const defaultOpts = {} as RunOptions; @@ -54,47 +59,70 @@ async function run( options?: RunOptions, ): Promise> { const token = await semaphore.acquire(); - const operation = retry.operation({ minTimeout: 10 * 1000 }); + const operation = retry.operation( + options?.retry ?? { minTimeout: 10 * 1000 }, + ); return new Promise((resolve, reject) => { operation.attempt(async (attempt: number) => { logger.debug(`Attempt ${attempt}`); try { const response = await request(document, variables, options); - token.release(); resolve(response); } catch (e) { - if (operation.retry(e as Error)) return; + if (e instanceof TokenError) { + operation.stop(); + reject(e); + } else if (operation.retry(e as Error)) { + // Retry operation + } else { + reject(operation.mainError()); + } + } finally { token.release(); - reject(operation.mainError()); } }); }); } +class TokenError extends Error { + constructor(message: string) { + super(message); + this.name = "TokenError"; + } +} + async function request( document: string, variables: A, options?: RunOptions, ): Promise> { + const opts = options ?? defaultOpts; + if (!process.env.SOUNDTRACK_API_URL) { throw new Error("Environment variable SOUNDTRACK_API_URL is not set"); } - if (!process.env.SOUNDTRACK_API_TOKEN) { - throw new Error("Environment variable SOUNDTRACK_API_TOKEN is not set"); - } - const opts = options ?? defaultOpts; + const token = opts.token ?? process.env.SOUNDTRACK_API_TOKEN; + if (!token && !opts.unauthenticated) { + throw new TokenError( + "No token provided for authenticated request, either set the SOUNDTRACK_API_TOKEN environment variable or pass it as an option", + ); + } const body = JSON.stringify({ query: document, variables }); logger.trace("GraphQL request body: " + body); + const headers: Record = { + "Content-Type": "application/json", + "User-Agent": "scheduler-example-app/0.0.0", + }; + if (token) { + headers["Authorization"] = (opts.tokenType ?? "Basic") + " " + token; + } + const res = await fetch(process.env.SOUNDTRACK_API_URL, { method: "POST", - headers: { - Authorization: "Basic " + process.env.SOUNDTRACK_API_TOKEN, - "Content-Type": "application/json", - "User-Agent": "scheduler-example-app/0.0.0", - }, + headers, body, }); diff --git a/lib/soundtrack-api/index.ts b/lib/soundtrack-api/index.ts index 032c912..ea778bd 100644 --- a/lib/soundtrack-api/index.ts +++ b/lib/soundtrack-api/index.ts @@ -1,9 +1,11 @@ -import { runMutation, runQuery } from "./client.js"; +import { runMutation, RunOptions, runQuery } from "./client.js"; +import { Semaphore } from "@shopify/semaphore"; import { Account, AccountLibrary, AccountZone, Assignable, + LoginResponse, Zone, } from "./types.js"; import { Cache } from "../cache/index.js"; @@ -11,10 +13,20 @@ import { getLogger } from "../logger/index.js"; const logger = getLogger("lib/soundtrack-api/index"); +export type TokenSource = { + getToken: () => Promise; + getRefreshToken: () => Promise; + updateToken(loginResponse: LoginResponse): Promise; + logout: () => Promise; +}; + type ApiOptions = { cache?: Cache; + tokenSource?: TokenSource; }; +type ApiMode = "token" | "user"; + function deserialize(value: string | undefined): T | undefined { if (value === undefined) return; return JSON.parse(value); @@ -22,9 +34,54 @@ function deserialize(value: string | undefined): T | undefined { export class Api { cache: Cache | undefined; + tokenSemaphore: Semaphore; + tokenSource: TokenSource | undefined; + mode: ApiMode; constructor(opts?: ApiOptions) { this.cache = opts?.cache; + this.tokenSemaphore = new Semaphore(1); + this.tokenSource = opts?.tokenSource; + this.mode = process.env.SOUNDTRACK_API_TOKEN ? "token" : "user"; + + if (this.mode === "user" && !this.tokenSource) { + throw new Error("Token source is required in user mode"); + } + + logger.info("Creating Soundtrack API client in mode: " + this.mode); + } + + private async getUserToken(): Promise { + if (this.mode === "token") return null; + if (!this.tokenSource) { + throw new Error("Token source is required in user mode"); + } + + const t = await this.tokenSemaphore.acquire(); + try { + const token = await this.tokenSource.getToken(); + if (token) { + return token; + } + const refreshToken = await this.tokenSource.getRefreshToken(); + if (!refreshToken) { + return null; + } + const loginResponse = await this.refreshAccessToken(refreshToken); + await this.tokenSource.updateToken(loginResponse); + return loginResponse.token; + } finally { + t.release(); + } + } + + private async runOptions(options: RunOptions = {}): Promise { + const userToken = await this.getUserToken(); + const opts: RunOptions = { ...options, token: userToken ?? undefined }; + if (userToken) { + opts.tokenType = "Bearer"; + } + return opts; } async cached(key: string, skipCache: boolean): Promise { @@ -32,6 +89,35 @@ export class Api { return await this.cache.get(key).then(deserialize); } + async login(email: string, password: string): Promise { + logger.info(`Logging in user ${email}`); + const res = await runMutation( + loginMutation, + { email, password }, + { unauthenticated: true, retry: { retries: 0 } }, + ); + return { + ...res.data.loginUser, + expiresAt: new Date(res.data.loginUser.expiresAt), + }; + } + + async refreshAccessToken(refreshToken: string): Promise { + logger.info(`Refreshing access token`); + const res = await runMutation< + RefreshAccessTokenMutation, + RefreshAccessTokenMutationArgs + >( + refreshAccessTokenMutation, + { refreshToken }, + { unauthenticated: true, retry: { retries: 0 } }, + ); + return { + ...res.data.refreshLogin, + expiresAt: new Date(res.data.refreshLogin.expiresAt), + }; + } + async getAccounts(skipCache: boolean = false): Promise { logger.info("Getting accounts"); const key = "accounts"; @@ -51,6 +137,7 @@ export class Api { const res = await runQuery( accountsQuery, { cursor }, + await this.runOptions(), ); const accounts = acc.concat( res.data.me.accounts.edges.map(({ node }) => node), @@ -65,9 +152,11 @@ export class Api { async getAccount(accountId: string): Promise { logger.info(`Getting account ${accountId}`); - const res = await runQuery(accountQuery, { - id: accountId, - }); + const res = await runQuery( + accountQuery, + { id: accountId }, + await this.runOptions(), + ); return res.data.account; } @@ -95,10 +184,8 @@ export class Api { ); const res = await runQuery( accountZonesQuery, - { - id: accountId, - cursor, - }, + { id: accountId, cursor }, + await this.runOptions(), ); const zoneFn = toZoneFn(accountId); const zones: Zone[] = acc.concat( @@ -114,9 +201,11 @@ export class Api { async getZone(zoneId: string): Promise { logger.info(`Getting zone ${zoneId}`); - const res = await runQuery(zoneQuery, { - id: zoneId, - }); + const res = await runQuery( + zoneQuery, + { id: zoneId }, + await this.runOptions(), + ); return res.data.soundZone; } @@ -131,10 +220,11 @@ export class Api { async assignMusic(zoneId: string, playFromId: string): Promise { logger.info(`Assigning ${playFromId} to ${zoneId}`); - await runMutation(assignMutation, { - zoneId, - playFromId, - }); + await runMutation( + assignMutation, + { zoneId, playFromId }, + await this.runOptions(), + ); } async getAssignable(assignableId: string): Promise { @@ -142,7 +232,7 @@ export class Api { const res = await runQuery( assignableQuery, { assignableId }, - { errorPolicy: "all" }, + await this.runOptions({ errorPolicy: "all" }), ); if (!res.data) { logger.info("Failed to get assignable: " + res.errors); @@ -176,13 +266,17 @@ export class Api { opts: LibraryPageOpts, acc: AccountLibrary, ): Promise { - const res = await runQuery(libraryQuery, { - accountId, - playlists: opts.playlists !== false, - playlistCursor: libraryOptToCursor(opts.playlists), - schedules: opts.schedules !== false, - scheduleCursor: libraryOptToCursor(opts.schedules), - }); + const res = await runQuery( + libraryQuery, + { + accountId, + playlists: opts.playlists !== false, + playlistCursor: libraryOptToCursor(opts.playlists), + schedules: opts.schedules !== false, + scheduleCursor: libraryOptToCursor(opts.schedules), + }, + await this.runOptions(), + ); const musicLibrary = res.data.account.musicLibrary; const playlists: Assignable[] = @@ -253,6 +347,49 @@ function toAssignable(item: LibraryItem): Assignable { }; } +type LoginMutation = { + loginUser: { + token: string; + expiresAt: string; + refreshToken: string; + }; +}; + +type LoginMutationArgs = { + email: string; + password: string; +}; + +const loginMutation = ` +mutation SchedulerLogin($email: String!, $password: String!) { + loginUser(input: { email: $email, password: $password }) { + token + expiresAt + refreshToken + } +}`; + +type RefreshAccessTokenMutation = { + refreshLogin: { + token: string; + expiresAt: string; + refreshToken: string; + }; +}; + +type RefreshAccessTokenMutationArgs = { + refreshToken: string; +}; + +const refreshAccessTokenMutation = ` +mutation SchedulerRefreshLogin($refreshToken: String!) { + refreshLogin(input: { refreshToken: $refreshToken }) { + token + expiresAt + refreshToken + } +}`; + type AccountsQuery = { me: { accounts: { @@ -268,22 +405,29 @@ type AccountsQueryArgs = { cursor: string | null; }; +const meAccounts = ` +accounts(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + businessName + } + } +} +`; + const accountsQuery = ` query SchedulerAccounts($cursor: String) { me { + ... on User { + ${meAccounts} + } ... on PublicAPIClient { - accounts(first: 100, after: $cursor) { - pageInfo { - hasNextPage - endCursor - } - edges { - node { - id - businessName - } - } - } + ${meAccounts} } } } diff --git a/lib/soundtrack-api/types.ts b/lib/soundtrack-api/types.ts index 51b45fb..26058d4 100644 --- a/lib/soundtrack-api/types.ts +++ b/lib/soundtrack-api/types.ts @@ -34,3 +34,9 @@ export type Assignable = { createdAt: string; updatedAt: string; }; + +export type LoginResponse = { + token: string; + expiresAt: Date; + refreshToken: string; +}; diff --git a/lib/token/index.ts b/lib/token/index.ts new file mode 100644 index 0000000..552b7b2 --- /dev/null +++ b/lib/token/index.ts @@ -0,0 +1,57 @@ +import { TokenSource } from "lib/soundtrack-api/index.js"; +import { LoginResponse } from "lib/soundtrack-api/types.js"; +import { getLogger } from "lib/logger/index.js"; +import { User } from "lib/db/index.js"; + +const logger = getLogger("lib/token"); +const oneMinute = 60 * 1000; + +class Source { + async getToken(): Promise { + try { + const user = await User.findByPk(0); + if (!user) return null; + + const expiresAt = user.get("expiresAt") as Date; + const now = new Date(); + const remaining = expiresAt.getTime() - now.getTime(); + if (remaining < oneMinute) { + logger.info( + `Token is expired. Expires at: ${expiresAt.toISOString()}, now: ${now.toISOString()}`, + ); + return null; + } + return user.get("token") as string; + } catch (e) { + logger.error("Failed to get token", e); + throw e; + } + } + + async getRefreshToken() { + try { + const user = await User.findByPk(0); + if (!user) return null; + + return user.get("refreshToken") as string; + } catch (e) { + logger.error("Failed to get refresh token", e); + throw e; + } + } + + async updateToken(loginResponse: LoginResponse) { + logger.info( + "Updating token, expires at " + loginResponse.expiresAt.toISOString(), + ); + await User.upsert({ key: 0, ...loginResponse }); + } + + async logout() { + logger.info("Logging out"); + await User.destroy({ where: { key: 0 } }); + } +} +const tokenSource: TokenSource = new Source(); + +export default tokenSource; diff --git a/lib/worker/index.ts b/lib/worker/index.ts index 5dd0374..526f16a 100644 --- a/lib/worker/index.ts +++ b/lib/worker/index.ts @@ -10,6 +10,7 @@ import { } from "../db/index.js"; import { Api } from "../soundtrack-api/index.js"; import { getLogger } from "../logger/index.js"; +import tokenSource from "lib/token/index.js"; const logger = getLogger("lib/worker/index"); @@ -17,7 +18,7 @@ type WorkerOptions = { interval: number; }; -const api = new Api(); +const api = new Api({ tokenSource }); // eslint-disable-next-line @typescript-eslint/no-explicit-any async function executeEvent(runId: number, event: Model) {