diff --git a/.env.example b/.env.example index c63ce1324..3c8ebde2c 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # Configuration reference: http://docs.postiz.com/configuration/reference -# === Required Settings +# === Required Settings DATABASE_URL="postgresql://postiz-user:postiz-password@localhost:5432/postiz-db-local" REDIS_URL="redis://localhost:6379" JWT_SECRET="random string for your JWT secret, make it long" @@ -20,7 +20,6 @@ CLOUDFLARE_BUCKETNAME="your-bucket-name" CLOUDFLARE_BUCKET_URL="https://your-bucket-url.r2.cloudflarestorage.com/" CLOUDFLARE_REGION="auto" - # === Common optional Settings ## This is a dummy key, you must create your own from Resend. @@ -32,7 +31,7 @@ CLOUDFLARE_REGION="auto" #DISABLE_REGISTRATION=false # Where will social media icons be saved - local or cloudflare. -STORAGE_PROVIDER="local" +STORAGE_PROVIDER="local" # Your upload directory path if you host your files locally, otherwise Cloudflare will be used. #UPLOAD_DIRECTORY="" @@ -40,7 +39,6 @@ STORAGE_PROVIDER="local" # Your upload directory path if you host your files locally, otherwise Cloudflare will be used. #NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY="" - # Social Media API Settings X_API_KEY="" X_API_SECRET="" @@ -92,3 +90,13 @@ STRIPE_SIGNING_KEY_CONNECT="" # Developer Settings NX_ADD_PLUGINS=false IS_GENERAL="true" # required for now +NEXT_PUBLIC_POSTIZ_OAUTH_DISPLAY_NAME="Authentik" +NEXT_PUBLIC_POSTIZ_OAUTH_LOGO_URL="https://raw.githubusercontent.com/walkxcode/dashboard-icons/master/png/authentik.png" +POSTIZ_GENERIC_OAUTH="false" +POSTIZ_OAUTH_URL="https://auth.example.com" +POSTIZ_OAUTH_AUTH_URL="https://auth.example.com/application/o/authorize" +POSTIZ_OAUTH_TOKEN_URL="https://auth.example.com/application/o/token" +POSTIZ_OAUTH_USERINFO_URL="https://authentik.example.com/application/o/userinfo" +POSTIZ_OAUTH_CLIENT_ID="" +POSTIZ_OAUTH_CLIENT_SECRET="" +# POSTIZ_OAUTH_SCOPE="openid profile email" # default values diff --git a/apps/backend/src/services/auth/providers/oauth.provider.ts b/apps/backend/src/services/auth/providers/oauth.provider.ts new file mode 100644 index 000000000..9ce6f2785 --- /dev/null +++ b/apps/backend/src/services/auth/providers/oauth.provider.ts @@ -0,0 +1,103 @@ +import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface'; + +export class OauthProvider implements ProvidersInterface { + private readonly authUrl: string; + private readonly baseUrl: string; + private readonly clientId: string; + private readonly clientSecret: string; + private readonly frontendUrl: string; + private readonly tokenUrl: string; + private readonly userInfoUrl: string; + + constructor() { + const { + POSTIZ_OAUTH_AUTH_URL, + POSTIZ_OAUTH_CLIENT_ID, + POSTIZ_OAUTH_CLIENT_SECRET, + POSTIZ_OAUTH_TOKEN_URL, + POSTIZ_OAUTH_URL, + POSTIZ_OAUTH_USERINFO_URL, + FRONTEND_URL, + } = process.env; + + if (!POSTIZ_OAUTH_USERINFO_URL) + throw new Error( + 'POSTIZ_OAUTH_USERINFO_URL environment variable is not set' + ); + if (!POSTIZ_OAUTH_URL) + throw new Error('POSTIZ_OAUTH_URL environment variable is not set'); + if (!POSTIZ_OAUTH_TOKEN_URL) + throw new Error('POSTIZ_OAUTH_TOKEN_URL environment variable is not set'); + if (!POSTIZ_OAUTH_CLIENT_ID) + throw new Error('POSTIZ_OAUTH_CLIENT_ID environment variable is not set'); + if (!POSTIZ_OAUTH_CLIENT_SECRET) + throw new Error( + 'POSTIZ_OAUTH_CLIENT_SECRET environment variable is not set' + ); + if (!POSTIZ_OAUTH_AUTH_URL) + throw new Error('POSTIZ_OAUTH_AUTH_URL environment variable is not set'); + if (!FRONTEND_URL) + throw new Error('FRONTEND_URL environment variable is not set'); + + this.authUrl = POSTIZ_OAUTH_AUTH_URL; + this.baseUrl = POSTIZ_OAUTH_URL; + this.clientId = POSTIZ_OAUTH_CLIENT_ID; + this.clientSecret = POSTIZ_OAUTH_CLIENT_SECRET; + this.frontendUrl = FRONTEND_URL; + this.tokenUrl = POSTIZ_OAUTH_TOKEN_URL; + this.userInfoUrl = POSTIZ_OAUTH_USERINFO_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.authUrl}/?${params.toString()}`; + } + + async getToken(code: string): Promise { + const response = await fetch(`${this.tokenUrl}/`, { + 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.userInfoUrl}/`, { + 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 0b6b8ddb8..2815bc3e2 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 { OauthProvider } from '@gitroom/backend/services/auth/providers/oauth.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.GENERIC: + return new OauthProvider(); } } } diff --git a/apps/frontend/public/icons/generic-oauth.svg b/apps/frontend/public/icons/generic-oauth.svg new file mode 100644 index 000000000..b06d14984 --- /dev/null +++ b/apps/frontend/public/icons/generic-oauth.svg @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index 4ad10d90b..68d464027 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -39,6 +39,9 @@ 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} + genericOauth={!!process.env.POSTIZ_GENERIC_OAUTH} + oauthLogoUrl={process.env.NEXT_PUBLIC_POSTIZ_OAUTH_LOGO_URL!} + oauthDisplayName={process.env.NEXT_PUBLIC_POSTIZ_OAUTH_DISPLAY_NAME!} 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 b4024b504..00a1ed88b 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 { OauthProvider } from '@gitroom/frontend/components/auth/providers/oauth.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'; @@ -24,7 +25,8 @@ type Inputs = { export function Login() { const [loading, setLoading] = useState(false); - const { isGeneral, neynarClientId, billingEnabled } = useVariables(); + const { isGeneral, neynarClientId, billingEnabled, genericOauth } = + useVariables(); const resolver = useMemo(() => { return classValidatorResolver(LoginUserDto); }, []); @@ -63,8 +65,9 @@ export function Login() { Sign In - - {!isGeneral ? ( + {isGeneral && genericOauth ? ( + + ) : !isGeneral ? ( ) : (
diff --git a/apps/frontend/src/components/auth/providers/oauth.provider.tsx b/apps/frontend/src/components/auth/providers/oauth.provider.tsx new file mode 100644 index 000000000..69b152103 --- /dev/null +++ b/apps/frontend/src/components/auth/providers/oauth.provider.tsx @@ -0,0 +1,42 @@ +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'; +import { useVariables } from '@gitroom/react/helpers/variable.context'; + +export const OauthProvider = () => { + const fetch = useFetch(); + const { oauthLogoUrl, oauthDisplayName } = useVariables(); + + const gotoLogin = useCallback(async () => { + try { + const response = await fetch('/auth/oauth/GENERIC'); + 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 generic oauth login link:', error); + } + }, []); + + return ( +
+
+ genericOauth +
+
Sign in with {oauthDisplayName || 'OAuth'}
+
+ ); +}; diff --git a/apps/frontend/src/middleware.ts b/apps/frontend/src/middleware.ts index 800e3d8cb..a7f7076d0 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.POSTIZ_GENERIC_OAUTH + ? 'generic' + : '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 9969893a6..3b5efbcff 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 + GENERIC } 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 48cb85f5b..a6716c2e2 100644 --- a/libraries/react-shared-libraries/src/helpers/variable.context.tsx +++ b/libraries/react-shared-libraries/src/helpers/variable.context.tsx @@ -5,9 +5,12 @@ import { createContext, FC, ReactNode, useContext, useEffect } from 'react'; interface VariableContextInterface { billingEnabled: boolean; isGeneral: boolean; + genericOauth: boolean; + oauthLogoUrl: string; + oauthDisplayName: string; frontEndUrl: string; plontoKey: string; - storageProvider: 'local' | 'cloudflare', + storageProvider: 'local' | 'cloudflare'; backendUrl: string; discordUrl: string; uploadDirectory: string; @@ -20,6 +23,9 @@ interface VariableContextInterface { const VariableContext = createContext({ billingEnabled: false, isGeneral: true, + genericOauth: false, + oauthLogoUrl: '', + oauthDisplayName: '', frontEndUrl: '', storageProvider: 'local', plontoKey: '', @@ -52,9 +58,9 @@ export const VariableContextComponent: FC< export const useVariables = () => { return useContext(VariableContext); -} +}; export const loadVars = () => { // @ts-ignore return window.vars as VariableContextInterface; -} +};