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 ed47f0036..b4d4aff28 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -138,6 +138,7 @@ enum OauthProviders { DISCORD GITHUB GOOGLE + AUTHENTIK } model IncompleteFile { diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 1d1603bbc..e33ed7693 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -36,6 +36,7 @@ import { IconFolders, IconGraph, IconHome, + IconKey, IconLink, IconLogout, IconReload, @@ -137,6 +138,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 d58b67a5d..d7f2daa6a 100644 --- a/src/components/pages/Manage/index.tsx +++ b/src/components/pages/Manage/index.tsx @@ -33,6 +33,7 @@ import { IconFileZip, IconGraph, IconGraphOff, + IconKey, IconPhotoMinus, IconReload, IconTrash, @@ -76,6 +77,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 6ecc1db9b..8579b6142 100644 --- a/src/lib/config/Config.ts +++ b/src/lib/config/Config.ts @@ -136,6 +136,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 91310e225..c851c3508 100644 --- a/src/lib/config/readConfig.ts +++ b/src/lib/config/readConfig.ts @@ -150,6 +150,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 be60c06b1..987b7f4ee 100644 --- a/src/lib/config/validateConfig.ts +++ b/src/lib/config/validateConfig.ts @@ -179,6 +179,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 209f01b4a..5872434eb 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 = { @@ -29,6 +29,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[] = []; @@ -52,6 +59,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 e9cc3c123..010baaacf 100644 --- a/src/pages/api/user/index.ts +++ b/src/pages/api/user/index.ts @@ -1,6 +1,6 @@ import zconfig 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, + zconfig.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( + zconfig.oauth.authentik_client_id, + `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`, + zconfig.oauth.authentik_authorize_url + ), + }); + } } } diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx index 1b550d7ab..06051b408 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'; @@ -47,6 +47,7 @@ export default function Login({ GitHub: IconBrandGithub, Discord: IconBrandDiscordFilled, Google: IconBrandGoogle, + Authentik: IconKey, }; for (const provider of oauth_providers) {