adding authentik oauth implementation
This commit is contained in:
parent
d238e24f62
commit
4f7e53fa86
14 changed files with 158 additions and 4 deletions
|
@ -0,0 +1,2 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "OauthProviders" ADD VALUE 'AUTHENTIK';
|
|
@ -127,6 +127,7 @@ enum OauthProviders {
|
|||
DISCORD
|
||||
GITHUB
|
||||
GOOGLE
|
||||
AUTHENTIK
|
||||
}
|
||||
|
||||
model IncompleteFile {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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'),
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
76
src/pages/api/auth/oauth/authentik.ts
Normal file
76
src/pages/api/auth/oauth/authentik.ts
Normal 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));
|
|
@ -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
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Add table
Reference in a new issue