adding authentik oauth implementation

This commit is contained in:
danejur 2023-04-13 13:05:29 -04:00
parent d238e24f62
commit 4f7e53fa86
14 changed files with 158 additions and 4 deletions

View file

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "OauthProviders" ADD VALUE 'AUTHENTIK';

View file

@ -127,6 +127,7 @@ enum OauthProviders {
DISCORD
GITHUB
GOOGLE
AUTHENTIK
}
model IncompleteFile {

View file

@ -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) {

View file

@ -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) {

View file

@ -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 {

View file

@ -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'),

View file

@ -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

View file

@ -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<ServerSideProps> = 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<ServerSideProps> = 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,

View file

@ -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<OAuthResponse>
) =>
async (req: NextApiReq, res: NextApiRes) => {

View file

@ -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();
},
};

View file

@ -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);
}

View file

@ -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<OAuthResponse> {
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));

View file

@ -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
),
});
}
}
}

View file

@ -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) {