Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(oidc): add generic oidc capabilities #550

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -21,7 +21,6 @@ CLOUDFLARE_BUCKETNAME="postiz"
CLOUDFLARE_BUCKET_URL="https://QhcMSXQyPuMCRpSQcSYdEuTYgHeCXHbu.r2.cloudflarestorage.com/"
CLOUDFLARE_REGION="auto"


# === Common optional Settings

## This is a dummy key, you must create your own from Resend.
Expand All @@ -32,15 +31,14 @@ CLOUDFLARE_REGION="auto"
#EMAIL_FROM_NAME=""

# 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=""

# 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=""
Expand Down Expand Up @@ -91,3 +89,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
103 changes: 103 additions & 0 deletions apps/backend/src/services/auth/providers/oauth.provider.ts
Original file line number Diff line number Diff line change
@@ -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');
Comment on lines +23 to +40
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add URL format validation for URL-type environment variables.

While the presence of environment variables is checked, their format should also be validated to ensure they are valid URLs.

Add URL validation like this:

 if (!POSTIZ_OAUTH_USERINFO_URL)
   throw new Error(
     'POSTIZ_OAUTH_USERINFO_URL environment variable is not set'
   );
+try {
+  new URL(POSTIZ_OAUTH_USERINFO_URL);
+} catch (e) {
+  throw new Error('POSTIZ_OAUTH_USERINFO_URL must be a valid URL');
+}


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()}`;
}
Comment on lines +51 to +60
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add CSRF protection and PKCE support to OAuth flow.

The current implementation is missing critical security features:

  1. State parameter for CSRF protection
  2. PKCE (Proof Key for Code Exchange) support for enhanced security

Here's how to implement these security features:

+private generateCodeVerifier(): string {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
+  const length = 128;
+  return Array.from(crypto.getRandomValues(new Uint8Array(length)))
+    .map(byte => chars[byte % chars.length])
+    .join('');
+}
+
+private async generateCodeChallenge(verifier: string): Promise<string> {
+  const encoder = new TextEncoder();
+  const data = encoder.encode(verifier);
+  const hash = await crypto.subtle.digest('SHA-256', data);
+  return btoa(String.fromCharCode(...new Uint8Array(hash)))
+    .replace(/\+/g, '-')
+    .replace(/\//g, '_')
+    .replace(/=+$/, '');
+}
+
 generateLink(): string {
+  const state = crypto.randomUUID();
+  const codeVerifier = this.generateCodeVerifier();
+  // Store state and codeVerifier in session or database
+
   const params = new URLSearchParams({
     client_id: this.clientId,
     scope: 'openid profile email',
     response_type: 'code',
     redirect_uri: `${this.frontendUrl}/settings`,
+    state,
+    code_challenge: await this.generateCodeChallenge(codeVerifier),
+    code_challenge_method: 'S256',
   });

   return `${this.authUrl}/?${params.toString()}`;
 }

Committable suggestion skipped: line range outside the PR's diff.


async getToken(code: string): Promise<string> {
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 };
}
}
3 changes: 3 additions & 0 deletions apps/backend/src/services/auth/providers/providers.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { GithubProvider } from '@gitroom/backend/services/auth/providers/github.
import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface';
import { GoogleProvider } from '@gitroom/backend/services/auth/providers/google.provider';
import { FarcasterProvider } from '@gitroom/backend/services/auth/providers/farcaster.provider';
import { OauthProvider } from '@gitroom/backend/services/auth/providers/oauth.provider';

export class ProvidersFactory {
static loadProvider(provider: Provider): ProvidersInterface {
Expand All @@ -13,6 +14,8 @@ export class ProvidersFactory {
return new GoogleProvider();
case Provider.FARCASTER:
return new FarcasterProvider();
case Provider.GENERIC:
return new OauthProvider();
}
}
}
9 changes: 9 additions & 0 deletions apps/frontend/public/icons/generic-oauth.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
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!}
Expand Down
8 changes: 5 additions & 3 deletions apps/frontend/src/components/auth/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,7 +24,7 @@ type Inputs = {

export function Login() {
const [loading, setLoading] = useState(false);
const { isGeneral, neynarClientId } = useVariables();
const { isGeneral, neynarClientId, genericOauth } = useVariables();
const resolver = useMemo(() => {
return classValidatorResolver(LoginUserDto);
}, []);
Expand Down Expand Up @@ -62,8 +63,9 @@ export function Login() {
Sign In
</h1>
</div>

{!isGeneral ? (
{isGeneral && genericOauth ? (
<OauthProvider />
) : !isGeneral ? (
<GithubProvider />
) : (
<div className="gap-[5px] flex flex-col">
Expand Down
42 changes: 42 additions & 0 deletions apps/frontend/src/components/auth/providers/oauth.provider.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<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={oauthLogoUrl || '/icons/generic-oauth.svg'}
alt="genericOauth"
width={40}
height={40}
/>
</div>
<div>Sign in with {oauthDisplayName || 'OAuth'}</div>
</div>
);
};
4 changes: 3 additions & 1 deletion apps/frontend/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion libraries/nestjs-libraries/src/database/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@ enum Provider {
GITHUB
GOOGLE
FARCASTER
GENERIC
}

enum Role {
Expand All @@ -578,4 +579,4 @@ enum APPROVED_SUBMIT_FOR_ORDER {
NO
WAITING_CONFIRMATION
YES
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,6 +23,9 @@ interface VariableContextInterface {
const VariableContext = createContext({
billingEnabled: false,
isGeneral: true,
genericOauth: false,
oauthLogoUrl: '',
oauthDisplayName: '',
frontEndUrl: '',
storageProvider: 'local',
plontoKey: '',
Expand Down Expand Up @@ -52,9 +58,9 @@ export const VariableContextComponent: FC<

export const useVariables = () => {
return useContext(VariableContext);
}
};

export const loadVars = () => {
// @ts-ignore
return window.vars as VariableContextInterface;
}
};
Loading