feat(oidc): add authentik as oidc

chore(env): missing var in .env.example

chore: typo

chore: add reviewed stuff

fix: rebased stuff

fix(authentik-oidc): redirect corect oidc provider
This commit is contained in:
DrummyFloyd 2025-01-15 15:40:23 +01:00
parent b8a0e1cbf2
commit f9aa278883
No known key found for this signature in database
GPG Key ID: 20AC86EE49130589
10 changed files with 153 additions and 7 deletions

View File

@ -91,3 +91,8 @@ STRIPE_SIGNING_KEY_CONNECT=""
# Developer Settings
NX_ADD_PLUGINS=false
IS_GENERAL="true" # required for now
AUTHENTIK_OIDC="false"
AUTHENTIK_URL=""
AUTHENTIK_CLIENT_ID=""
AUTHENTIK_CLIENT_SECRET=""
AUTHENTIK_SCOPE=""

View File

@ -0,0 +1,88 @@
import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface';
export class AuthentikProvider implements ProvidersInterface {
private readonly baseUrl: string;
private readonly clientId: string;
private readonly clientSecret: string;
private readonly frontendUrl: string;
constructor() {
const {
AUTHENTIK_URL,
AUTHENTIK_CLIENT_ID,
AUTHENTIK_CLIENT_SECRET,
FRONTEND_URL,
} = process.env;
if (!AUTHENTIK_URL)
throw new Error('AUTHENTIK_URL environment variable is not set');
if (!AUTHENTIK_CLIENT_ID)
throw new Error('AUTHENTIK_CLIENT_ID environment variable is not set');
if (!AUTHENTIK_CLIENT_SECRET)
throw new Error(
'AUTHENTIK_CLIENT_SECRET environment variable is not set'
);
if (!FRONTEND_URL)
throw new Error('FRONTEND_URL environment variable is not set');
this.baseUrl = AUTHENTIK_URL.endsWith('/')
? AUTHENTIK_URL.slice(0, -1)
: AUTHENTIK_URL;
this.clientId = AUTHENTIK_CLIENT_ID;
this.clientSecret = AUTHENTIK_CLIENT_SECRET;
this.frontendUrl = FRONTEND_URL;
}
generateLink(): string {
const params = new URLSearchParams({
client_id: this.clientId,
scope: 'openid profile email',
response_type: 'code',
redirect_uri: `${this.frontendUrl}/settings`,
});
return `${this.baseUrl}/application/o/authorize/?${params.toString()}`;
}
async getToken(code: string): Promise<string> {
const response = await fetch(`${this.baseUrl}/application/o/token/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.clientId,
client_secret: this.clientSecret,
code,
redirect_uri: `${this.frontendUrl}/settings`,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token request failed: ${error}`);
}
const { access_token } = await response.json();
return access_token;
}
async getUser(access_token: string): Promise<{ email: string; id: string }> {
const response = await fetch(`${this.baseUrl}/application/o/userinfo/`, {
headers: {
Authorization: `Bearer ${access_token}`,
Accept: 'application/json',
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`User info request failed: ${error}`);
}
const { email, sub: id } = await response.json();
return { email, id };
}
}

View File

@ -4,6 +4,7 @@ import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.int
import { GoogleProvider } from '@gitroom/backend/services/auth/providers/google.provider';
import { FarcasterProvider } from '@gitroom/backend/services/auth/providers/farcaster.provider';
import { WalletProvider } from '@gitroom/backend/services/auth/providers/wallet.provider';
import { AuthentikProvider } from '@gitroom/backend/services/auth/providers/authentik.provider';
export class ProvidersFactory {
static loadProvider(provider: Provider): ProvidersInterface {
@ -16,6 +17,8 @@ export class ProvidersFactory {
return new FarcasterProvider();
case Provider.WALLET:
return new WalletProvider();
case Provider.AUTHENTIK:
return new AuthentikProvider();
}
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -39,6 +39,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
discordUrl={process.env.NEXT_PUBLIC_DISCORD_SUPPORT!}
frontEndUrl={process.env.FRONTEND_URL!}
isGeneral={!!process.env.IS_GENERAL}
authentikOIDC={!!process.env.AUTHENTIK_OIDC}
uploadDirectory={process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY!}
tolt={process.env.NEXT_PUBLIC_TOLT!}
facebookPixel={process.env.NEXT_PUBLIC_FACEBOOK_PIXEL!}

View File

@ -9,6 +9,7 @@ import { useMemo, useState } from 'react';
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import { LoginUserDto } from '@gitroom/nestjs-libraries/dtos/auth/login.user.dto';
import { GithubProvider } from '@gitroom/frontend/components/auth/providers/github.provider';
import { AuthentikProvider } from '@gitroom/frontend/components/auth/providers/authentik.provider';
import interClass from '@gitroom/react/helpers/inter.font';
import { GoogleProvider } from '@gitroom/frontend/components/auth/providers/google.provider';
import { useVariables } from '@gitroom/react/helpers/variable.context';
@ -25,6 +26,7 @@ type Inputs = {
export function Login() {
const [loading, setLoading] = useState(false);
const { isGeneral, neynarClientId, billingEnabled } = useVariables();
const { isGeneral, neynarClientId, authentikOIDC } = useVariables();
const resolver = useMemo(() => {
return classValidatorResolver(LoginUserDto);
}, []);
@ -63,8 +65,9 @@ export function Login() {
Sign In
</h1>
</div>
{!isGeneral ? (
{isGeneral && authentikOIDC ? (
<AuthentikProvider />
) : !isGeneral ? (
<GithubProvider />
) : (
<div className="gap-[5px] flex flex-col">

View File

@ -0,0 +1,40 @@
import { useCallback } from 'react';
import Image from 'next/image';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import interClass from '@gitroom/react/helpers/inter.font';
export const AuthentikProvider = () => {
const fetch = useFetch();
const gotoLogin = useCallback(async () => {
try {
const response = await fetch('/auth/oauth/AUTHENTIK');
if (!response.ok) {
throw new Error(
`Login link request failed with status ${response.status}`
);
}
const link = await response.text();
window.location.href = link;
} catch (error) {
console.error('Failed to get Authentik login link:', error);
}
}, []);
return (
<div
onClick={gotoLogin}
className={`cursor-pointer bg-white h-[44px] rounded-[4px] flex justify-center items-center text-customColor16 ${interClass} gap-[4px]`}
>
<div>
<Image
src="/icons/authentik.svg"
alt="Authentik"
width={40}
height={40}
/>
</div>
<div>Sign in with Authentik </div>
</div>
);
};

View File

@ -44,7 +44,9 @@ export async function middleware(request: NextRequest) {
? ''
: (url.indexOf('?') > -1 ? '&' : '?') +
`provider=${(findIndex === 'settings'
? 'github'
? process.env.AUTHENTIK_OIDC
? 'authentik'
: 'github'
: findIndex
).toUpperCase()}`;
return NextResponse.redirect(

View File

@ -636,6 +636,7 @@ enum Provider {
GOOGLE
FARCASTER
WALLET
AUTHENTIK
}
enum Role {
@ -648,4 +649,4 @@ enum APPROVED_SUBMIT_FOR_ORDER {
NO
WAITING_CONFIRMATION
YES
}
}

View File

@ -5,9 +5,10 @@ import { createContext, FC, ReactNode, useContext, useEffect } from 'react';
interface VariableContextInterface {
billingEnabled: boolean;
isGeneral: boolean;
authentikOIDC: boolean;
frontEndUrl: string;
plontoKey: string;
storageProvider: 'local' | 'cloudflare',
storageProvider: 'local' | 'cloudflare';
backendUrl: string;
discordUrl: string;
uploadDirectory: string;
@ -20,6 +21,7 @@ interface VariableContextInterface {
const VariableContext = createContext({
billingEnabled: false,
isGeneral: true,
authentikOIDC: false,
frontEndUrl: '',
storageProvider: 'local',
plontoKey: '',
@ -52,9 +54,9 @@ export const VariableContextComponent: FC<
export const useVariables = () => {
return useContext(VariableContext);
}
};
export const loadVars = () => {
// @ts-ignore
return window.vars as VariableContextInterface;
}
};