Skip to content
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
22 changes: 20 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
NEXTAUTH_SECRET=secret
# NextAuth
NEXTAUTH_SECRET=your-secret-here
NEXTAUTH_URL=http://localhost:3000
NEXT_PUBLIC_HAWKBIT_API_URL=http://localhost:8080

# HawkBit backend URL (used server-side for API proxying)
NEXT_PUBLIC_HAWKBIT_API_URL=http://localhost:8080

# ── Optional OIDC / SSO ──────────────────────────────────────────────────────
# When set, a "Sign in via OIDC" button is shown on the login page.
# Any OpenID Connect compliant provider is supported (Keycloak, Auth0, Okta…).
#
# OIDC_ISSUER_URL=https://keycloak.example.com/realms/myrealm
# OIDC_CLIENT_ID=hawkbitgui
# OIDC_CLIENT_SECRET=your-client-secret
# OIDC_PROVIDER_NAME=Keycloak # optional label for the sign-in button
#
# HawkBit service account for OIDC users.
# OIDC-authenticated users share these credentials for HawkBit API calls.
# Required when OIDC_ISSUER_URL is configured.
# HAWKBIT_SERVICE_USERNAME=admin
# HAWKBIT_SERVICE_PASSWORD=your-hawkbit-admin-password
107 changes: 57 additions & 50 deletions src/app/api/hawkbit/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { environment } from '@/config/env';
import axios, { AxiosError } from 'axios';
import { cookies } from 'next/headers';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';

const handleApiError = (error: unknown) => {
if (error instanceof AxiosError) {
Expand Down Expand Up @@ -34,38 +36,70 @@ const handleApiError = (error: unknown) => {
);
};

export async function GET(request: NextRequest, context: { params: Promise<{ path: string[] }> }) {
const { params } = context;
/**
* Returns the Basic Auth credential string for HawkBit API calls.
*
* Priority:
* 1. `auth` cookie — set on login with HawkBit credentials (Credentials provider)
* 2. OIDC service account — when the user authenticated via an OIDC provider and
* HAWKBIT_SERVICE_USERNAME / HAWKBIT_SERVICE_PASSWORD are configured.
*
* Returns null if neither is available (unauthenticated request).
*/
async function getAuth(): Promise<string | null> {
const cookieStore = await cookies();
const auth = cookieStore.get('auth')?.value;
const cookieAuth = cookieStore.get('auth')?.value;
if (cookieAuth) {
return cookieAuth;
}

// OIDC path: user has a valid NextAuth session but no per-user HawkBit credentials.
// Fall back to the configured service account.
const serviceUser = environment.hawkbitServiceUsername;
const servicePass = environment.hawkbitServicePassword;
if (serviceUser && servicePass) {
const session = await getServerSession(authOptions);
if (session?.user) {
return Buffer.from(`${serviceUser}:${servicePass}`).toString('base64');
}
}

return null;
}

const unauthorizedResponse = () =>
NextResponse.json(
{
exceptionClass: 'UnauthorizedError',
errorCode: 'UNAUTHORIZED',
message: 'Unauthorized',
info: {},
},
{ status: 401 }
);

console.log('auth', auth);
export async function GET(request: NextRequest, context: { params: Promise<{ path: string[] }> }) {
const { params } = context;
const auth = await getAuth();

if (!auth) {
return NextResponse.json(
{
exceptionClass: 'UnauthorizedError',
errorCode: 'UNAUTHORIZED',
message: 'Unauthorized',
info: {},
},
{ status: 401 }
);
return unauthorizedResponse();
}

try {
const path = (await params).path.join('/');

// Extract query params from the request URL
const url = new URL(request.url);
const queryParams: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
queryParams[key] = value;
});

const acceptHeader = request.headers.get('accept') ?? 'application/json, application/hal+json';

const isBinaryExpected = acceptHeader.includes('application/octet-stream') || acceptHeader.includes('image') || acceptHeader === '*/*';
const isBinaryExpected =
acceptHeader.includes('application/octet-stream') ||
acceptHeader.includes('image') ||
acceptHeader === '*/*';

const axiosResponse = await axios.get(`${environment.hawkbitApiUrl}/rest/v1/${path}`, {
headers: {
Expand Down Expand Up @@ -94,19 +128,10 @@ export async function GET(request: NextRequest, context: { params: Promise<{ pat

export async function POST(request: NextRequest, context: { params: Promise<{ path: string[] }> }) {
const { params } = context;
const cookieStore = await cookies();
const auth = cookieStore.get('auth')?.value;
const auth = await getAuth();

if (!auth) {
return NextResponse.json(
{
exceptionClass: 'UnauthorizedError',
errorCode: 'UNAUTHORIZED',
message: 'Unauthorized',
info: {},
},
{ status: 401 }
);
return unauthorizedResponse();
}

try {
Expand All @@ -123,7 +148,6 @@ export async function POST(request: NextRequest, context: { params: Promise<{ pa
const formData = await request.formData();
body = formData;
} else {
// Check if request has a body before parsing
const text = await request.text();
body = text ? JSON.parse(text) : {};
headers['Content-Type'] = 'application/json';
Expand All @@ -141,19 +165,10 @@ export async function POST(request: NextRequest, context: { params: Promise<{ pa

export async function PUT(request: NextRequest, context: { params: Promise<{ path: string[] }> }) {
const { params } = context;
const cookieStore = await cookies();
const auth = cookieStore.get('auth')?.value;
const auth = await getAuth();

if (!auth) {
return NextResponse.json(
{
exceptionClass: 'UnauthorizedError',
errorCode: 'UNAUTHORIZED',
message: 'Unauthorized',
info: {},
},
{ status: 401 }
);
return unauthorizedResponse();
}

try {
Expand All @@ -176,23 +191,15 @@ export async function PUT(request: NextRequest, context: { params: Promise<{ pat

export async function DELETE(request: NextRequest, context: { params: Promise<{ path: string[] }> }) {
const { params } = context;
const cookieStore = await cookies();
const auth = cookieStore.get('auth')?.value;
const auth = await getAuth();

if (!auth) {
return NextResponse.json(
{
exceptionClass: 'UnauthorizedError',
errorCode: 'UNAUTHORIZED',
message: 'Unauthorized',
info: {},
},
{ status: 401 }
);
return unauthorizedResponse();
}

try {
const path = (await params).path.join('/');

const response = await axios.delete(`${environment.hawkbitApiUrl}/rest/v1/${path}`, {
headers: {
Authorization: `Basic ${auth}`,
Expand Down
27 changes: 25 additions & 2 deletions src/app/login/containers/login-form-container/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ import LoginForm from '@/app/login/components/login-form';
import toast from 'react-hot-toast';
import { handleErrorWithToast } from '@/utils/handle-error-with-toast';
import { useEffect } from 'react';
import Button from '@/app/components/button';
import Text from '@/app/components/text';

export type LoginFormContainerProps = {
className?: string;
oidcEnabled?: boolean;
};

export default function LoginFormContainer({ className }: LoginFormContainerProps) {
export default function LoginFormContainer({ className, oidcEnabled = false }: LoginFormContainerProps) {
const router = useRouter();
const searchParams = useSearchParams();

Expand Down Expand Up @@ -53,5 +56,25 @@ export default function LoginFormContainer({ className }: LoginFormContainerProp
}
};

return <LoginForm onSubmit={onSubmit} className={className}></LoginForm>;
const onOidcSignIn = () => {
signIn('oidc', { callbackUrl: AppRoutes.deployment });
};

return (
<div className={className} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<LoginForm onSubmit={onSubmit} />
{oidcEnabled && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<hr style={{ flex: 1 }} />
<Text variant='caption' color='text-secondary'>or</Text>
<hr style={{ flex: 1 }} />
</div>
<Button type='button' onClick={onOidcSignIn} variant='outline'>
Sign in via OIDC
</Button>
</>
)}
</div>
);
}
8 changes: 7 additions & 1 deletion src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import Image from 'next/image';
import LoginFormContainer from '@/app/login/containers/login-form-container';

export default function Login() {
// Read server-side env vars to pass OIDC availability to the client container.
const oidcEnabled =
!!process.env.OIDC_ISSUER_URL &&
!!process.env.OIDC_CLIENT_ID &&
!!process.env.OIDC_CLIENT_SECRET;

return (
<div className={`${styles.page}`}>
<div className={`${styles.logoSection}`}>
Expand All @@ -28,7 +34,7 @@ export default function Login() {
</div>
</div>
<div className={`${styles.loginSection}`}>
<LoginFormContainer className={styles.form} />
<LoginFormContainer className={styles.form} oidcEnabled={oidcEnabled} />
<div className={styles.policiesSection}>
<div>
<a href={'https://www.eclipse.org/org/'}>About Us</a>
Expand Down
13 changes: 13 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,17 @@ export const environment = {
nextAuthSecret: process.env.NEXTAUTH_SECRET,
nextAuthUrl: process.env.NEXTAUTH_URL,
hawkbitApiUrl: process.env.NEXT_PUBLIC_HAWKBIT_API_URL,

// Optional OIDC provider (e.g. Keycloak, Auth0, Okta — any standard OIDC issuer).
// When set, a "Sign in via OIDC" button appears on the login page.
oidcIssuerUrl: process.env.OIDC_ISSUER_URL,
oidcClientId: process.env.OIDC_CLIENT_ID,
oidcClientSecret: process.env.OIDC_CLIENT_SECRET,

// HawkBit service account used when authenticating via OIDC.
// OIDC users cannot supply per-user HawkBit credentials, so the proxy
// falls back to these credentials for all HawkBit API calls.
// Required when OIDC_ISSUER_URL is set.
hawkbitServiceUsername: process.env.HAWKBIT_SERVICE_USERNAME,
hawkbitServicePassword: process.env.HAWKBIT_SERVICE_PASSWORD,
};
Loading