Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Authentication scaffold #824

Merged
merged 13 commits into from
Nov 7, 2022
Merged
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,42 @@ Those charts configurations are kept in the repository.

At the moment, the screenshots are made from charts using data from int.lindas.admin.ch as for some functionalities, we do not
yet have production data.

## Authentication

Authentication by eIAM through a Keycloak instance.
We use Next-auth to integrate our application with Keycloak.
See https://next-auth.js.org/providers/keycloak for documentation.

### Locally

The easiest way is to run Keycloak via Docker.

```
docker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:19.0.3 start-dev
```

⚠️ After creating the container via the above command, if you stop it, restart the container via the docker UI so that you re-use the
same storage, otherwise you'll have to reconfigure Keycloak.

To configure Keycloak:

- Access the [Keycloak admin][keycloak-admin] (login, password: "admin", "admin")
- Create client application
- Via import: [Keycloak][keycloak-admin] > Clients > Import client
- Use the exported client `keycloak-visualize-client-dev.json`
- Manually: [Keycloak][keycloak-admin] > Clients > Create client
- id: "visualize"
- Choose OpenIDConnect
- In next slide, toggle "Client Authentication" on
- Configure redirect URI on client
- Root URL: `http://localhost:3000`
- Redirect URI: `/api/auth/callback/keycloak`
- Create a user
- Set a password to the user (in Credentials tab)
- Set environment variables in `.env.local`
- KEYCLOAK_ID: "visualize"
- KEYCLOAK_SECRET: From [Keycloak][keycloak-admin] > Clients > visualize > Credentials > Client secret
- KEYCLOAK_ISSUER: http://localhost:8080/realms/master

[keycloak-admin]: http://localhost:8080/admin/master/console/#/
3 changes: 3 additions & 0 deletions app/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import Flex from "@/components/flex";
import { LanguageMenu } from "@/components/language-menu";
import { SOURCE_OPTIONS } from "@/domain/datasource/constants";

import LoginMenu from "./login-menu";

const DEFAULT_HEADER_PROGRESS = 100;

export const useHeaderProgressContext = () => {
Expand Down Expand Up @@ -137,6 +139,7 @@ const MetadataMenu = ({ contentId }: { contentId?: string }) => {
}}
>
<LanguageMenu contentId={contentId} />
<LoginMenu />
</Flex>
);
};
Expand Down
72 changes: 72 additions & 0 deletions app/components/login-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Typography, Button, Box } from "@mui/material";
import { getProviders, signIn, signOut, useSession } from "next-auth/react";
import Link from "next/link";
import { useEffect, useState } from "react";

import { Awaited } from "@/domain/types";

type Providers = Awaited<ReturnType<typeof getProviders>>;

const useProviders = () => {
const [state, setState] = useState({
status: "loading",
data: undefined as Providers | undefined,
});
useEffect(() => {
const run = async () => {
const providers = await getProviders();
setState({ status: "loaded", data: providers });
};
run();
}, []);
return state;
};

function LoginMenu() {
const { data: session, status: sessionStatus } = useSession();
const { data: providers, status: providersStatus } = useProviders();
if (sessionStatus === "loading" || providersStatus === "loading") {
return null;
}
if (!providers || !Object.keys(providers).length) {
return null;
}
return (
<Box sx={{ alignItems: "center", display: "flex" }}>
{session ? (
<>
<Typography variant="body2">
Signed in as <Link href="/profile">{session.user?.name}</Link>{" "}
{session.user?.id}
{" - "}
</Typography>
<Button
variant="text"
color="primary"
size="small"
onClick={async () => await signOut()}
>
Sign out
</Button>
</>
) : (
<>
<Typography variant="body2">
Not signed in
{" - "}
</Typography>
<Button
variant="text"
color="primary"
size="small"
onClick={() => signIn("keycloak")}
>
Sign in
</Button>
</>
)}
</Box>
);
}

export default LoginMenu;
2 changes: 1 addition & 1 deletion app/configurator/config-types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable no-redeclare */
import { fold } from "fp-ts/lib/Either";
import { pipe } from "fp-ts/lib/pipeable";
import { pipe } from "fp-ts/lib/function";
import * as t from "io-ts";

import { DataCubeMetadata } from "@/graphql/types";
Expand Down
5 changes: 5 additions & 0 deletions app/db/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export default prisma;
113 changes: 66 additions & 47 deletions app/db/config.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
/**
* Server side methods to connect to the database
*/

import { Prisma, Config, User } from "@prisma/client";

import { Config as ConfigType } from "@/configurator";
import { migrateChartConfig } from "@/utils/chart-config/versioning";

import { createChartId } from "../utils/create-chart-id";

import { pool } from "./pg-pool";
import prisma from "./client";

type PublishedConfig = Omit<ConfigType, "activeField">;

/**
* Store data in the DB.
* Do not try to use on client-side! Use /api/config instead.
* If the user is logged, the chart is linked to the user.
*
* @param data Data to be stored as configuration
*/
export const createConfig = async (
data: $Unexpressable
data: Prisma.ConfigCreateInput["data"],
userId?: User["id"] | undefined
): Promise<{ key: string }> => {
const result = await pool.query<{ key: string }>(
`INSERT INTO config(key, data) VALUES ($1, $2) RETURNING key`,
[createChartId(), data]
);
const result = await prisma.config.create({
data: {
key: createChartId(),
data: data,
user_id: userId,
},
});

if (result.rows.length < 1) {
throw Error("No result after insert!");
}

return result.rows[0];
return result;
};

const migrateDataSet = (dataSet: string): string => {
Expand All @@ -33,51 +42,61 @@ const migrateDataSet = (dataSet: string): string => {
return dataSet;
};

type ChartJsonConfig = {
dataSet: string;
chartConfig: Prisma.JsonObject;
};

const parseDbConfig = (conf: Config) => {
const data = conf.data as ChartJsonConfig;
const migratedData = {
...data,
dataSet: migrateDataSet(data.dataSet),
chartConfig: migrateChartConfig(data.chartConfig),
} as PublishedConfig;
return {
...conf,
data: migratedData,
};
};

/**
* Get data from DB.
* Do not try to use on client-side! Use /api/config instead.
*
* @param key Get data from DB with this key
*/
export const getConfig = async (
key: string
): Promise<undefined | { key: string; data: $Unexpressable }> => {
const result = await pool.query<{ key: string; data: $Unexpressable }>(
`SELECT key, data FROM config WHERE key = $1 LIMIT 1`,
[key]
);

const config = result.rows[0];

if (config && config.data) {
return {
...config,
data: {
...config.data,
dataSet: migrateDataSet(config.data.dataSet),
chartConfig: migrateChartConfig(config.data.chartConfig),
},
};
export const getConfig = async (key: string) => {
const config = await prisma.config.findFirst({
where: {
key: key,
},
});

if (!config) {
return;
}

return config;
return parseDbConfig(config);
};

/**
* Get all keys from DB.
* Do not try to use on client-side! Use /api/config instead.
*
* @param key Get data from DB with this key
*/
export const getAllConfigs = async (): Promise<
{
key: string;
data: $Unexpressable;
}[]
> => {
const result = await pool.query<{ key: string; data: $Unexpressable }>(
`SELECT key,data FROM config ORDER BY created_at DESC`
);

return result.rows;
export const getAllConfigs = async () => {
const configs = await prisma.config.findMany();
return configs.map((c) => parseDbConfig(c));
};

/**
* Get config from a user.
*/
export const getUserConfigs = async (userId: number) => {
const configs = await prisma.config.findMany({
where: {
user_id: userId,
},
});
return configs.map((c) => parseDbConfig(c));
};

export type ParsedConfig = ReturnType<typeof parseDbConfig>;
25 changes: 25 additions & 0 deletions app/db/serialize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import SuperJSON from "superjson";
import { SuperJSONResult } from "superjson/dist/types";

export type Serialized<P> = P & {
_superjson?: ReturnType<typeof SuperJSON.serialize>["meta"];
};
Comment on lines +1 to +6
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL about SuperJSON 😍

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not know it before, it's part of a framework called Blitz. I think it would be interesting to check if it could be interesting for us.

I actually took most of the code for serialize from the babel plugin for superjson, that automatically adds superjson to your getServerSideProps : https://github.com/blitz-js/babel-plugin-superjson-next/blob/main/src/tools.tsx. I thought it was a little too magical (and based on babel) so I preferred to have it called explicitly.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks interesting, especially the part about getting rid of GraphQL 😮


export const serializeProps = <T extends unknown>(props: T) => {
const { json: sprops, meta } = SuperJSON.serialize(props);
if (meta) {
// @ts-ignore
sprops._superjson = meta;
}
return sprops as Serialized<typeof props>;
};

type Deserialized<T> = T extends Serialized<infer S> ? S : never;

export const deserializeProps = <T extends Serialized<unknown>>(sprops: T) => {
const { _superjson, ...props } = sprops;
return SuperJSON.deserialize({
json: props,
meta: _superjson,
} as unknown as SuperJSONResult) as Deserialized<T>;
};
49 changes: 49 additions & 0 deletions app/db/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import prisma from "./client";

export const findBySub = async (sub: string) => {
return prisma.user.findFirstOrThrow({
where: {
sub,
},
});
};

/**
* Ensures an authenticated user has an account
* on our side.
*
* - Uses the "sub" field from the JWT token to ensure
* uniqueness.
* - Updates the user name with what is found in the JWT token
*/
export const ensureUserFromSub = async (
sub: string,
name: string | undefined | null
) => {
const user = await prisma.user.findFirst({
where: {
sub,
},
});
if (user) {
if (user.name !== name) {
console.log(`Updating user name from auth provider info`);
await prisma.user.update({
where: {
id: user.id,
},
data: {
name: name,
},
});
}
return user;
} else {
const newUser = await prisma.user.create({
data: {
sub: sub,
},
});
return newUser;
}
};
3 changes: 3 additions & 0 deletions app/domain/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export const GA_TRACKING_ID =
*/

export const DATABASE_URL = process.env.DATABASE_URL;
export const KEYCLOAK_ID = process.env.KEYCLOAK_ID;
export const KEYCLOAK_SECRET = process.env.KEYCLOAK_SECRET;
export const KEYCLOAK_ISSUER = process.env.KEYCLOAK_ISSUER;

/**
* Variables set at **BUILD TIME** through `NEXT_PUBLIC_*` variables. Available on the client and server.
Expand Down
Loading