Skip to content

Commit

Permalink
feat: User authentication (#15)
Browse files Browse the repository at this point in the history
* fix: Allow unauthenticated requests

Also, adds tokenType to request opts to be able to pass a token that
should be sent with Bearer <token>.

* 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<string, string>

* fix: Update README.md
  • Loading branch information
pettermachado authored Nov 8, 2024
1 parent c66d77c commit 03071a3
Show file tree
Hide file tree
Showing 11 changed files with 529 additions and 57 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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. |

Expand Down
46 changes: 45 additions & 1 deletion api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions app/fetchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Account,
AccountLibrary,
Assignable,
AuthMode,
CacheMetadata,
Event,
EventAction,
Expand Down Expand Up @@ -82,6 +83,7 @@ export const assignableFetcher: Fetcher<Assignable, string> = (url) =>
defaultFetcher(url).then(toAssignable);

export const cacheFetcher: Fetcher<CacheMetadata, string> = defaultFetcher;
export const authModeFetcher: Fetcher<AuthMode, string> = defaultFetcher;

export const errorHandler = async (res: Response) => {
if (!res.ok) {
Expand Down
179 changes: 177 additions & 2 deletions app/routes/settings.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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";
Expand All @@ -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") }];
Expand All @@ -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<string[]>([]);
const { libraryId, setLibraryId } = useMusicLibrary();
const selectedAccount = libraryId
Expand Down Expand Up @@ -216,6 +220,177 @@ export default function Settings() {
Refresh count
</Button>
</div>
<div className="border-t border-slate-200 my-4"></div>
<h1>Authentication</h1>
<p className="text-sm text-slate-400 mb-3">
This app is using {authMode?.mode} authentication.
</p>
<div className="max-w-screen-sm">
{authMode?.mode === "token" && (
<p>
With token authentication this app is using a Soundtrack API token
to make requests to the Soundtrack API.
</p>
)}
{authMode?.mode === "user" && (
<>
<LoggedInAlert loggedIn={authMode.loggedIn} className="my-4" />
{!authMode.loggedIn && <UserLogin />}
{authMode.loggedIn && <UserLogout />}
</>
)}
</div>
</Page>
);
}

function LoggedInAlert({
loggedIn,
className,
}: {
loggedIn: boolean;
className?: string;
}) {
return (
<Alert className={className} variant={loggedIn ? "default" : "destructive"}>
<AlertTitle>
{loggedIn ? "Logged in, all set!" : "Not logged in"}
</AlertTitle>
{!loggedIn && (
<AlertDescription>
Until you are logged in you will not be able to access the Soundtrack
API.
</AlertDescription>
)}
</Alert>
);
}

type LoginData = {
email: string;
password: string;
};

async function userLogin(
url: string,
{ arg }: { arg: LoginData },
): Promise<void> {
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<boolean>(false);
const [data, setData] = useState<LoginData>({ email: "", password: "" });
const [error, setError] = useState<string | null>(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 (
<>
<h1>Log in</h1>
<form
onSubmit={(e) => {
e.preventDefault();
handleLogin(data);
}}
className="max-w-80"
>
<Label htmlFor="email-input">Email</Label>
<Input
id="email-input"
value={data.email}
onChange={(e) => setData((d) => ({ ...d, email: e.target.value }))}
/>
<Label htmlFor="password-input">Password</Label>
<Input
id="password-input"
type="password"
value={data.password}
onChange={(e) => setData((d) => ({ ...d, password: e.target.value }))}
/>
<div className="mt-4">
<Button type="submit" disabled={loading}>
Log in
</Button>
</div>
</form>
{error && (
<Alert variant="destructive" className="mt-4">
<AlertTitle>Login failed</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</>
);
}

async function userLogout(url: string): Promise<void> {
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<boolean>(false);
const [error, setError] = useState<string | null>(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 (
<>
<Button onClick={handleLogout} disabled={loading}>
Log out
</Button>
{error && (
<Alert variant="destructive" className="mt-4">
<AlertTitle>Login failed</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</>
);
}
5 changes: 5 additions & 0 deletions app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,8 @@ export type AccountLibrary = {
export type CacheMetadata = {
count: number;
};

export type AuthMode = {
mode: "user" | "token";
loggedIn: boolean;
};
12 changes: 11 additions & 1 deletion lib/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// =========

Expand Down Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 03071a3

Please sign in to comment.