diff --git a/.env.example b/.env.example index e26c1e56..64252ba1 100644 --- a/.env.example +++ b/.env.example @@ -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="" diff --git a/apps/backend/src/services/auth/providers/authentik.provider.ts b/apps/backend/src/services/auth/providers/authentik.provider.ts new file mode 100644 index 00000000..88f1c1d1 --- /dev/null +++ b/apps/backend/src/services/auth/providers/authentik.provider.ts @@ -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 { + 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 }; + } +} diff --git a/apps/backend/src/services/auth/providers/providers.factory.ts b/apps/backend/src/services/auth/providers/providers.factory.ts index 0b6b8ddb..01bfa483 100644 --- a/apps/backend/src/services/auth/providers/providers.factory.ts +++ b/apps/backend/src/services/auth/providers/providers.factory.ts @@ -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(); } } } diff --git a/apps/frontend/public/icons/authentik.svg b/apps/frontend/public/icons/authentik.svg new file mode 100644 index 00000000..c839ddab --- /dev/null +++ b/apps/frontend/public/icons/authentik.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index 4ad10d90..b932d3ac 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -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!} diff --git a/apps/frontend/src/components/auth/login.tsx b/apps/frontend/src/components/auth/login.tsx index b4024b50..9e52e429 100644 --- a/apps/frontend/src/components/auth/login.tsx +++ b/apps/frontend/src/components/auth/login.tsx @@ -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 - - {!isGeneral ? ( + {isGeneral && authentikOIDC ? ( + + ) : !isGeneral ? ( ) : (
diff --git a/apps/frontend/src/components/auth/providers/authentik.provider.tsx b/apps/frontend/src/components/auth/providers/authentik.provider.tsx new file mode 100644 index 00000000..985825ad --- /dev/null +++ b/apps/frontend/src/components/auth/providers/authentik.provider.tsx @@ -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 ( +
+
+ Authentik +
+
Sign in with Authentik
+
+ ); +}; diff --git a/apps/frontend/src/middleware.ts b/apps/frontend/src/middleware.ts index 800e3d8c..01aae290 100644 --- a/apps/frontend/src/middleware.ts +++ b/apps/frontend/src/middleware.ts @@ -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( diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma index 9969893a..ae7fe520 100644 --- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma +++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma @@ -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 -} \ No newline at end of file +} diff --git a/libraries/react-shared-libraries/src/helpers/variable.context.tsx b/libraries/react-shared-libraries/src/helpers/variable.context.tsx index 48cb85f5..bf2ae9a3 100644 --- a/libraries/react-shared-libraries/src/helpers/variable.context.tsx +++ b/libraries/react-shared-libraries/src/helpers/variable.context.tsx @@ -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; -} +};