From 4f7e53fa865f07901d8a634deb1526ca4747a30c Mon Sep 17 00:00:00 2001 From: danejur Date: Thu, 13 Apr 2023 13:05:29 -0400 Subject: [PATCH 1/6] adding authentik oauth implementation --- .../migration.sql | 2 + prisma/schema.prisma | 1 + src/components/Layout.tsx | 2 + src/components/pages/Manage/index.tsx | 2 + src/lib/config/Config.ts | 6 ++ src/lib/config/readConfig.ts | 6 ++ src/lib/config/validateConfig.ts | 6 ++ src/lib/middleware/getServerSideProps.ts | 16 +++- src/lib/middleware/withOAuth.ts | 2 +- src/lib/oauth.ts | 17 +++++ src/lib/util.ts | 4 + src/pages/api/auth/oauth/authentik.ts | 76 +++++++++++++++++++ src/pages/api/user/index.ts | 19 ++++- src/pages/auth/login.tsx | 3 +- 14 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/20230413142451_oauth_add_authentik/migration.sql create mode 100644 src/pages/api/auth/oauth/authentik.ts diff --git a/prisma/migrations/20230413142451_oauth_add_authentik/migration.sql b/prisma/migrations/20230413142451_oauth_add_authentik/migration.sql new file mode 100644 index 000000000..e326866ee --- /dev/null +++ b/prisma/migrations/20230413142451_oauth_add_authentik/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "OauthProviders" ADD VALUE 'AUTHENTIK'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 49789b5e5..b8f6eb912 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -127,6 +127,7 @@ enum OauthProviders { DISCORD GITHUB GOOGLE + AUTHENTIK } model IncompleteFile { diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 66859f623..3bd916b32 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -38,6 +38,7 @@ import { IconFolders, IconGraph, IconHome, + IconKey, IconLink, IconLogout, IconReload, @@ -139,6 +140,7 @@ export default function Layout({ children, props }) { GitHub: IconBrandGithubFilled, Discord: IconBrandDiscordFilled, Google: IconBrandGoogle, + Authentik: IconKey, }; for (const provider of oauth_providers) { diff --git a/src/components/pages/Manage/index.tsx b/src/components/pages/Manage/index.tsx index 5e2555b84..112857e42 100644 --- a/src/components/pages/Manage/index.tsx +++ b/src/components/pages/Manage/index.tsx @@ -29,6 +29,7 @@ import { IconFileZip, IconGraph, IconGraphOff, + IconKey, IconPhotoMinus, IconReload, IconTrash, @@ -72,6 +73,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_ Discord: IconBrandDiscordFilled, GitHub: IconBrandGithubFilled, Google: IconBrandGoogle, + Authentik: IconKey, }; for (const provider of oauth_providers) { diff --git a/src/lib/config/Config.ts b/src/lib/config/Config.ts index ac9bc6d08..2a9961597 100644 --- a/src/lib/config/Config.ts +++ b/src/lib/config/Config.ts @@ -131,6 +131,12 @@ export interface ConfigOAuth { google_client_id?: string; google_client_secret?: string; + + authentik_client_id?: string; + authentik_client_secret?: string; + authentik_authorize_url?: string; + authentik_userinfo_url?: string; + authentik_token_url?: string; } export interface ConfigChunks { diff --git a/src/lib/config/readConfig.ts b/src/lib/config/readConfig.ts index 7a81d10b1..fbe3c3fe6 100644 --- a/src/lib/config/readConfig.ts +++ b/src/lib/config/readConfig.ts @@ -145,6 +145,12 @@ export default function readConfig() { map('OAUTH_GOOGLE_CLIENT_ID', 'string', 'oauth.google_client_id'), map('OAUTH_GOOGLE_CLIENT_SECRET', 'string', 'oauth.google_client_secret'), + map('OAUTH_AUTHENTIK_CLIENT_ID', 'string', 'oauth.authentik_client_id'), + map('OAUTH_AUTHENTIK_CLIENT_SECRET', 'string', 'oauth.authentik_client_secret'), + map('OAUTH_AUTHENTIK_AUTHORIZE_URL', 'string', 'oauth.authentik_authorize_url'), + map('OAUTH_AUTHENTIK_USERINFO_URL', 'string', 'oauth.authentik_userinfo_url'), + map('OAUTH_AUTHENTIK_TOKEN_URL', 'string', 'oauth.authentik_token_url'), + map('FEATURES_INVITES', 'boolean', 'features.invites'), map('FEATURES_INVITES_LENGTH', 'number', 'features.invites_length'), diff --git a/src/lib/config/validateConfig.ts b/src/lib/config/validateConfig.ts index 272d47e73..e8e14bd5a 100644 --- a/src/lib/config/validateConfig.ts +++ b/src/lib/config/validateConfig.ts @@ -176,6 +176,12 @@ const validator = s.object({ google_client_id: s.string.nullable.default(null), google_client_secret: s.string.nullable.default(null), + + authentik_client_id: s.string.nullable.default(null), + authentik_client_secret: s.string.nullable.default(null), + authentik_authorize_url: s.string.nullable.default(null), + authentik_userinfo_url: s.string.nullable.default(null), + authentik_token_url: s.string.nullable.default(null), }) .nullish.default(null), features: s diff --git a/src/lib/middleware/getServerSideProps.ts b/src/lib/middleware/getServerSideProps.ts index 9f2353f25..6672cc1b2 100644 --- a/src/lib/middleware/getServerSideProps.ts +++ b/src/lib/middleware/getServerSideProps.ts @@ -1,5 +1,5 @@ import config from 'lib/config'; -import { notNull } from 'lib/util'; +import { notNull, notNullArray } from 'lib/util'; import { GetServerSideProps } from 'next'; export type OauthProvider = { @@ -28,6 +28,13 @@ export const getServerSideProps: GetServerSideProps = async (ct const ghEnabled = notNull(config.oauth?.github_client_id, config.oauth?.github_client_secret); const discEnabled = notNull(config.oauth?.discord_client_id, config.oauth?.discord_client_secret); const googleEnabled = notNull(config.oauth?.google_client_id, config.oauth?.google_client_secret); + const authentikEnabled = notNullArray([ + config.oauth?.authentik_client_id, + config.oauth?.authentik_client_secret, + config.oauth?.authentik_authorize_url, + config.oauth?.authentik_userinfo_url, + config.oauth?.authentik_token_url, + ]); const oauth_providers: OauthProvider[] = []; @@ -51,6 +58,13 @@ export const getServerSideProps: GetServerSideProps = async (ct link_url: '/api/auth/oauth/google?state=link', }); + if (authentikEnabled) + oauth_providers.push({ + name: 'Authentik', + url: '/api/auth/oauth/authentik', + link_url: '/api/auth/oauth/authentik?state=link', + }); + const obj = { props: { title: config.website.title, diff --git a/src/lib/middleware/withOAuth.ts b/src/lib/middleware/withOAuth.ts index 11a131b55..d4ba440b3 100644 --- a/src/lib/middleware/withOAuth.ts +++ b/src/lib/middleware/withOAuth.ts @@ -25,7 +25,7 @@ export interface OAuthResponse { export const withOAuth = ( - provider: 'discord' | 'github' | 'google', + provider: 'discord' | 'github' | 'google' | 'authentik', oauth: (query: OAuthQuery, logger: Logger) => Promise ) => async (req: NextApiReq, res: NextApiRes) => { diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 4e30d6818..a9f937c93 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -48,3 +48,20 @@ export const google_auth = { return res.json(); }, }; + +export const authentik_auth = { + oauth_url: (clientId: string, origin: string, authorize_url: string, state?: string) => + `${authorize_url}?client_id=${clientId}&redirect_uri=${encodeURIComponent( + `${origin}/api/auth/oauth/authentik` + )}&response_type=code&scope=openid+email+profile${state ? `&state=${state}` : ''}`, + oauth_user: async (access_token: string, user_info_url: string) => { + const res = await fetch(user_info_url, { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }); + if (!res.ok) return null; + + return res.json(); + }, +}; diff --git a/src/lib/util.ts b/src/lib/util.ts index aad854863..79fec7f7d 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -123,3 +123,7 @@ export async function getBase64URLFromURL(url: string) { export function notNull(a: unknown, b: unknown) { return a !== null && b !== null; } + +export function notNullArray(arr: unknown[]) { + return !arr.some((x) => x === null); +} diff --git a/src/pages/api/auth/oauth/authentik.ts b/src/pages/api/auth/oauth/authentik.ts new file mode 100644 index 000000000..49ea8c157 --- /dev/null +++ b/src/pages/api/auth/oauth/authentik.ts @@ -0,0 +1,76 @@ +import config from 'lib/config'; +import Logger from 'lib/logger'; +import { OAuthQuery, OAuthResponse, withOAuth } from 'lib/middleware/withOAuth'; +import { withZipline } from 'lib/middleware/withZipline'; +import { authentik_auth } from 'lib/oauth'; +import { notNullArray } from 'lib/util'; + +async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promise { + if (!config.features.oauth_registration) + return { + error_code: 403, + error: 'oauth registration is disabled', + }; + + if ( + !notNullArray([ + config.oauth?.authentik_client_id, + config.oauth?.authentik_client_secret, + config.oauth?.authentik_authorize_url, + config.oauth?.authentik_userinfo_url, + ]) + ) { + logger.error('Authentik OAuth is not configured'); + return { + error_code: 401, + error: 'Authentik OAuth is not configured', + }; + } + + if (!code) + return { + redirect: authentik_auth.oauth_url( + config.oauth.authentik_client_id, + `${config.core.return_https ? 'https' : 'http'}://${host}`, + config.oauth.authentik_authorize_url, + state + ), + }; + + const body = new URLSearchParams({ + code, + client_id: config.oauth.authentik_client_id, + client_secret: config.oauth.authentik_client_secret, + redirect_uri: `${config.core.return_https ? 'https' : 'http'}://${host}/api/auth/oauth/authentik`, + grant_type: 'authorization_code', + }); + + const resp = await fetch(config.oauth.authentik_token_url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }); + + const text = await resp.text(); + logger.debug(`oauth ${config.oauth.authentik_token_url} -> body(${body}) resp(${text})`); + + if (!resp.ok) return { error: 'invalid request' }; + + const json = JSON.parse(text); + + if (!json.access_token) return { error: 'no access_token in response' }; + + const userJson = await authentik_auth.oauth_user(json.access_token, config.oauth.authentik_userinfo_url); + if (!userJson) return { error: 'invalid user request' }; + + return { + username: userJson.preferred_username, + user_id: userJson.sub, + access_token: json.access_token, + refresh_token: json.refresh_token, + }; +} + +export default withZipline(withOAuth('authentik', handler)); diff --git a/src/pages/api/user/index.ts b/src/pages/api/user/index.ts index 200b62716..cc768ac92 100644 --- a/src/pages/api/user/index.ts +++ b/src/pages/api/user/index.ts @@ -1,6 +1,6 @@ import config from 'lib/config'; import Logger from 'lib/logger'; -import { discord_auth, github_auth, google_auth } from 'lib/oauth'; +import { authentik_auth, discord_auth, github_auth, google_auth } from 'lib/oauth'; import prisma from 'lib/prisma'; import { hashPassword } from 'lib/util'; import { jsonUserReplacer } from 'lib/utils/client'; @@ -131,6 +131,23 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) { }, }); } + } else if (user.oauth.find((o) => o.provider === 'AUTHENTIK')) { + const resp = await authentik_auth.oauth_user( + user.oauth.find((o) => o.provider === 'AUTHENTIK').token, + config.oauth.authentik_userinfo_url + ); + if (!resp) { + logger.debug(`oauth expired for ${JSON.stringify(user, jsonUserReplacer)}`); + + return res.json({ + error: 'oauth token expired', + redirect_uri: authentik_auth.oauth_url( + config.oauth.authentik_client_id, + `${config.core.return_https ? 'https' : 'http'}://${req.headers.host}`, + config.oauth.authentik_authorize_url + ), + }); + } } } diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx index 7106ab2c1..f50e02117 100644 --- a/src/pages/auth/login.tsx +++ b/src/pages/auth/login.tsx @@ -14,7 +14,7 @@ import { Title, } from '@mantine/core'; import { useForm } from '@mantine/form'; -import { IconBrandDiscordFilled, IconBrandGithub, IconBrandGoogle } from '@tabler/icons-react'; +import { IconBrandDiscordFilled, IconBrandGithub, IconBrandGoogle, IconKey } from '@tabler/icons-react'; import useFetch from 'hooks/useFetch'; import Head from 'next/head'; import Link from 'next/link'; @@ -38,6 +38,7 @@ export default function Login({ title, user_registration, oauth_registration, oa GitHub: IconBrandGithub, Discord: IconBrandDiscordFilled, Google: IconBrandGoogle, + Authentik: IconKey, }; for (const provider of oauth_providers) { From 321fa7c39c3275c1bab52d015b2f61b7156cf689 Mon Sep 17 00:00:00 2001 From: dicedtomato <35403473+diced@users.noreply.github.com> Date: Wed, 10 May 2023 21:58:14 -0700 Subject: [PATCH 2/6] Update src/pages/api/user/index.ts --- src/pages/api/user/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/api/user/index.ts b/src/pages/api/user/index.ts index 233a4001d..7815e691e 100644 --- a/src/pages/api/user/index.ts +++ b/src/pages/api/user/index.ts @@ -134,7 +134,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) { } else if (user.oauth.find((o) => o.provider === 'AUTHENTIK')) { const resp = await authentik_auth.oauth_user( user.oauth.find((o) => o.provider === 'AUTHENTIK').token, - config.oauth.authentik_userinfo_url + zconfig.oauth.authentik_userinfo_url ); if (!resp) { logger.debug(`oauth expired for ${JSON.stringify(user, jsonUserReplacer)}`); From e3b65ae15c25a643262dd0abcb640cfb4e1d83a1 Mon Sep 17 00:00:00 2001 From: dicedtomato <35403473+diced@users.noreply.github.com> Date: Wed, 10 May 2023 21:58:27 -0700 Subject: [PATCH 3/6] Update src/pages/api/user/index.ts --- src/pages/api/user/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/api/user/index.ts b/src/pages/api/user/index.ts index 7815e691e..c1a8218f7 100644 --- a/src/pages/api/user/index.ts +++ b/src/pages/api/user/index.ts @@ -142,7 +142,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) { return res.json({ error: 'oauth token expired', redirect_uri: authentik_auth.oauth_url( - config.oauth.authentik_client_id, + zconfig.oauth.authentik_client_id, `${config.core.return_https ? 'https' : 'http'}://${req.headers.host}`, config.oauth.authentik_authorize_url ), From 2f313057969803fc30f0fb88673236b0c9968012 Mon Sep 17 00:00:00 2001 From: dicedtomato <35403473+diced@users.noreply.github.com> Date: Wed, 10 May 2023 21:58:32 -0700 Subject: [PATCH 4/6] Update src/pages/api/user/index.ts --- src/pages/api/user/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/api/user/index.ts b/src/pages/api/user/index.ts index c1a8218f7..afa6fd103 100644 --- a/src/pages/api/user/index.ts +++ b/src/pages/api/user/index.ts @@ -143,7 +143,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) { error: 'oauth token expired', redirect_uri: authentik_auth.oauth_url( zconfig.oauth.authentik_client_id, - `${config.core.return_https ? 'https' : 'http'}://${req.headers.host}`, + `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`, config.oauth.authentik_authorize_url ), }); From 9c17b4e48deb6a1bfc69e39df2c8ff65a04a83ca Mon Sep 17 00:00:00 2001 From: dicedtomato <35403473+diced@users.noreply.github.com> Date: Wed, 10 May 2023 21:58:36 -0700 Subject: [PATCH 5/6] Update src/pages/api/user/index.ts --- src/pages/api/user/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/api/user/index.ts b/src/pages/api/user/index.ts index afa6fd103..572e70d93 100644 --- a/src/pages/api/user/index.ts +++ b/src/pages/api/user/index.ts @@ -144,7 +144,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) { redirect_uri: authentik_auth.oauth_url( zconfig.oauth.authentik_client_id, `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`, - config.oauth.authentik_authorize_url + zconfig.oauth.authentik_authorize_url ), }); } From 5790a0c4497b69c2d817d3588a895537db946133 Mon Sep 17 00:00:00 2001 From: dicedtomato <35403473+diced@users.noreply.github.com> Date: Wed, 10 May 2023 22:01:49 -0700 Subject: [PATCH 6/6] Update index.ts --- src/pages/api/user/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/api/user/index.ts b/src/pages/api/user/index.ts index 572e70d93..010baaacf 100644 --- a/src/pages/api/user/index.ts +++ b/src/pages/api/user/index.ts @@ -144,7 +144,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) { redirect_uri: authentik_auth.oauth_url( zconfig.oauth.authentik_client_id, `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`, - zconfig.oauth.authentik_authorize_url + zconfig.oauth.authentik_authorize_url ), }); }