From 31fd018cdc2891e8211f5cb915ccafe491616d1d Mon Sep 17 00:00:00 2001 From: Fabio Craviolatti Date: Fri, 6 Mar 2026 19:34:17 +0100 Subject: [PATCH] feat: add optional OIDC/SSO authentication support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for any OpenID Connect compliant identity provider (Keycloak, Auth0, Okta, etc.) alongside the existing credentials login. When OIDC is configured, a "Sign in via OIDC" button appears on the login page. OIDC-authenticated users share a HawkBit service account for API access (configured via HAWKBIT_SERVICE_* env vars). New environment variables: OIDC_ISSUER_URL – OIDC issuer (e.g. https://keycloak.example.com/realms/myrealm) OIDC_CLIENT_ID – OAuth2 client ID OIDC_CLIENT_SECRET – OAuth2 client secret OIDC_PROVIDER_NAME – optional label for the sign-in button (default: "OIDC") HAWKBIT_SERVICE_USERNAME – HawkBit username for OIDC-authenticated users HAWKBIT_SERVICE_PASSWORD – HawkBit password for OIDC-authenticated users Implementation details: - Generic OIDC provider in auth-options.ts (wellKnown discovery, PKCE) - API proxy falls back to service account credentials when the per-user auth cookie is absent but a valid OIDC session exists - Login page passes oidcEnabled flag (server-side env read) to the client container to conditionally render the OIDC button - Existing credentials login is unchanged; both methods coexist --- .env.example | 22 ++- src/app/api/hawkbit/[...path]/route.ts | 107 ++++++------ .../containers/login-form-container/index.tsx | 27 ++- src/app/login/page.tsx | 8 +- src/config/env.ts | 13 ++ src/lib/auth-options.ts | 163 +++++++++++------- 6 files changed, 220 insertions(+), 120 deletions(-) diff --git a/.env.example b/.env.example index 2cd4d20..6f7ec9f 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file + +# 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 diff --git a/src/app/api/hawkbit/[...path]/route.ts b/src/app/api/hawkbit/[...path]/route.ts index ca66d90..e6a001d 100644 --- a/src/app/api/hawkbit/[...path]/route.ts +++ b/src/app/api/hawkbit/[...path]/route.ts @@ -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) { @@ -34,29 +36,59 @@ 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 { 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 = {}; url.searchParams.forEach((value, key) => { @@ -64,8 +96,10 @@ export async function GET(request: NextRequest, context: { params: Promise<{ pat }); 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: { @@ -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 { @@ -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'; @@ -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 { @@ -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}`, diff --git a/src/app/login/containers/login-form-container/index.tsx b/src/app/login/containers/login-form-container/index.tsx index 47aa310..130ad71 100644 --- a/src/app/login/containers/login-form-container/index.tsx +++ b/src/app/login/containers/login-form-container/index.tsx @@ -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(); @@ -53,5 +56,25 @@ export default function LoginFormContainer({ className }: LoginFormContainerProp } }; - return ; + const onOidcSignIn = () => { + signIn('oidc', { callbackUrl: AppRoutes.deployment }); + }; + + return ( +
+ + {oidcEnabled && ( + <> +
+
+ or +
+
+ + + )} +
+ ); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 21f8619..9d4e7d1 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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 (
@@ -28,7 +34,7 @@ export default function Login() {
- +
About Us diff --git a/src/config/env.ts b/src/config/env.ts index 8a54704..975069b 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -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, }; diff --git a/src/lib/auth-options.ts b/src/lib/auth-options.ts index 85c8f8e..003ec88 100644 --- a/src/lib/auth-options.ts +++ b/src/lib/auth-options.ts @@ -4,76 +4,109 @@ import axios from 'axios'; import { environment } from '@/config/env'; import { cookies } from 'next/headers'; +/** + * Generic OIDC provider configuration. + * Works with any OpenID Connect compliant provider (Keycloak, Auth0, Okta, etc.) + * by reading the provider metadata from the issuer's well-known endpoint. + */ +function createOidcProvider() { + return { + id: 'oidc', + name: process.env.OIDC_PROVIDER_NAME ?? 'OIDC', + type: 'oauth' as const, + wellKnown: `${environment.oidcIssuerUrl}/.well-known/openid-configuration`, + authorization: { params: { scope: 'openid email profile' } }, + idToken: true, + checks: ['pkce', 'state'] as ['pkce', 'state'], + profile(profile: Record) { + return { + id: profile.sub as string, + name: (profile.name ?? profile.preferred_username ?? profile.sub) as string, + email: (profile.email ?? '') as string, + }; + }, + clientId: environment.oidcClientId!, + clientSecret: environment.oidcClientSecret!, + }; +} + +const oidcEnabled = + !!environment.oidcIssuerUrl && + !!environment.oidcClientId && + !!environment.oidcClientSecret; + export const authOptions: AuthOptions = { - providers: [ - CredentialsProvider({ - name: 'Credentials', - credentials: { - username: { label: 'Username', type: 'text' }, - password: { label: 'Password', type: 'password' }, - }, - async authorize(credentials) { - try { - const response = await axios.get(`${environment.hawkbitApiUrl}/rest/v1/userinfo`, { - headers: { - Authorization: `Basic ${Buffer.from(`${credentials?.username}:${credentials?.password}`).toString('base64')}`, - Accept: 'application/json, application/hal+json', - 'X-Requested-With': 'XMLHttpRequest', + providers: [ + CredentialsProvider({ + name: 'Credentials', + credentials: { + username: { label: 'Username', type: 'text' }, + password: { label: 'Password', type: 'password' }, }, - }); + async authorize(credentials) { + try { + const response = await axios.get(`${environment.hawkbitApiUrl}/rest/v1/userinfo`, { + headers: { + Authorization: `Basic ${Buffer.from(`${credentials?.username}:${credentials?.password}`).toString('base64')}`, + Accept: 'application/json, application/hal+json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); - if (!response.data) { - return null; - } + if (!response.data) { + return null; + } - const auth = `${credentials?.username}:${credentials?.password}`; - const cookieStore = await cookies(); - cookieStore.set('auth', Buffer.from(auth).toString('base64'), { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - maxAge: 3600, // 1 hour - sameSite: 'strict', - path: '/', - }); + const auth = `${credentials?.username}:${credentials?.password}`; + const cookieStore = await cookies(); + cookieStore.set('auth', Buffer.from(auth).toString('base64'), { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 3600, // 1 hour + sameSite: 'strict', + path: '/', + }); - return { - id: response.data.tenant + response.data.username, - tenant: response.data.tenant, - username: response.data.username, - }; - } catch (error) { - console.log(error); - return null; - } - }, - }), - ], - session: { - strategy: 'jwt', - maxAge: 3600, // 1 hour - updateAge: 300, // 5 minutes - }, - jwt: { - maxAge: 3600, // 1 hour - }, - callbacks: { - async jwt({ token, user }) { - if (user) { - token.tenant = user.tenant; - token.username = user.username; - } - return token; + return { + id: response.data.tenant + response.data.username, + tenant: response.data.tenant, + username: response.data.username, + }; + } catch (error) { + console.log(error); + return null; + } + }, + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(oidcEnabled ? [createOidcProvider() as any] : []), + ], + session: { + strategy: 'jwt', + maxAge: 3600, // 1 hour + updateAge: 300, // 5 minutes + }, + jwt: { + maxAge: 3600, // 1 hour + }, + callbacks: { + async jwt({ token, user }) { + if (user) { + token.tenant = user.tenant; + token.username = user.username; + } + return token; + }, + async session({ session, token }) { + if (session.user) { + session.user.tenant = token.tenant; + session.user.username = token.username; + } + return session; + }, }, - async session({ session, token }) { - if (session.user) { - session.user.tenant = token.tenant; - session.user.username = token.username; - } - return session; + pages: { + signIn: '/login', }, - }, - pages: { - signIn: '/login', - }, - secret: process.env.NEXTAUTH_SECRET, + secret: process.env.NEXTAUTH_SECRET, };