From aa6ec1a45239970cd7decbf9d0c1d1d5de40274a Mon Sep 17 00:00:00 2001 From: Riccardo Forina Date: Tue, 6 Aug 2024 21:07:51 +0200 Subject: [PATCH] Experimental multi-cluster support --- ui/api/kafka/actions.ts | 1 + ui/api/kafka/schema.ts | 18 +++-- ui/app/[locale]/cluster/ClusterSelector.tsx | 20 +++--- .../kafka/[kafkaId]/login/SignInPage.tsx | 34 ++++++++-- .../[locale]/kafka/[kafkaId]/login/page.tsx | 28 +++++++- ui/app/api/auth/[...nextauth]/anonymous.ts | 8 +-- ui/app/api/auth/[...nextauth]/keycloak.ts | 67 ++++++++++--------- ui/app/api/auth/[...nextauth]/oauth-token.ts | 52 ++++++++++++++ ui/app/api/auth/[...nextauth]/route.ts | 59 +++++++++------- ui/app/api/auth/[...nextauth]/scram.ts | 46 ++++++------- ui/messages/en.json | 2 + ui/middleware.ts | 33 +++++---- 12 files changed, 240 insertions(+), 128 deletions(-) create mode 100644 ui/app/api/auth/[...nextauth]/oauth-token.ts diff --git a/ui/api/kafka/actions.ts b/ui/api/kafka/actions.ts index 8809129c1..becfa6eca 100644 --- a/ui/api/kafka/actions.ts +++ b/ui/api/kafka/actions.ts @@ -41,6 +41,7 @@ export async function getKafkaClusters(): Promise { Accept: "application/json", "Content-Type": "application/json", }, + cache: "reload", }); const rawData = await res.json(); log.trace(rawData, "getKafkaClusters response"); diff --git a/ui/api/kafka/schema.ts b/ui/api/kafka/schema.ts index 10cc37db2..6e63bbd87 100644 --- a/ui/api/kafka/schema.ts +++ b/ui/api/kafka/schema.ts @@ -12,29 +12,27 @@ export const ClusterListSchema = z.object({ type: z.literal("kafkas"), meta: z.object({ configured: z.boolean(), - }), - attributes: z.object({ - name: z.string(), - namespace: z.string().nullable().optional(), - kafkaVersion: z.string().nullable().optional(), - authMethod: z + authentication: z .union([ z.object({ method: z.literal("anonymous"), }), z.object({ - method: z.literal("scram-sha"), + method: z.literal("basic"), }), z.object({ method: z.literal("oauth"), - clientId: z.string(), - clientSecret: z.string(), - issuer: z.string(), + tokenUrl: z.string().nullable().optional(), }), ]) .nullable() .optional(), }), + attributes: z.object({ + name: z.string(), + namespace: z.string().nullable().optional(), + kafkaVersion: z.string().nullable().optional(), + }), }); export const ClustersResponseSchema = z.object({ data: z.array(ClusterListSchema), diff --git a/ui/app/[locale]/cluster/ClusterSelector.tsx b/ui/app/[locale]/cluster/ClusterSelector.tsx index fbbd1d3ed..6abd53536 100644 --- a/ui/app/[locale]/cluster/ClusterSelector.tsx +++ b/ui/app/[locale]/cluster/ClusterSelector.tsx @@ -8,7 +8,9 @@ import { Menu, MenuItem, } from "@patternfly/react-core"; +import { signOut } from "next-auth/react"; import { useTranslations } from "next-intl"; +import { useEffect } from "react"; export function ClusterSelector({ clusters }: { clusters: ClusterList[] }) { const t = useTranslations(); @@ -34,16 +36,14 @@ export function ClusterSelector({ clusters }: { clusters: ClusterList[] }) { brandImgSrc={"/StreamsLogo.svg"} socialMediaLoginContent={learnMoreResource} > - - {clusters.map((c) => ( - ( - {c.attributes.name} - )} - /> - ))} - + {clusters.map((c) => ( + ( + {c.attributes.name} + )} + /> + ))} ); } diff --git a/ui/app/[locale]/kafka/[kafkaId]/login/SignInPage.tsx b/ui/app/[locale]/kafka/[kafkaId]/login/SignInPage.tsx index b91f8952d..bd169c6d5 100644 --- a/ui/app/[locale]/kafka/[kafkaId]/login/SignInPage.tsx +++ b/ui/app/[locale]/kafka/[kafkaId]/login/SignInPage.tsx @@ -1,5 +1,6 @@ "use client"; import { ExternalLink } from "@/components/Navigation/ExternalLink"; +import { Link } from "@/navigation"; import { Alert, LoginForm, @@ -40,7 +41,15 @@ const signinErrors: Record = SessionRequired: "Please sign in to access this page.", }; -export function SignInPage({ callbackUrl }: { callbackUrl: string }) { +export function SignInPage({ + provider, + callbackUrl, + hasMultipleClusters, +}: { + provider: "credentials" | "oauth-token"; + callbackUrl: string; + hasMultipleClusters: boolean; +}) { const t = useTranslations(); const productName = t("common.product"); @@ -79,20 +88,30 @@ export function SignInPage({ callbackUrl }: { callbackUrl: string }) { e.stopPropagation(); setIsSubmitting(true); setError(undefined); - const res = await signIn("credentials", { + const res = await signIn(provider, { username, password, redirect: false, }); + console.log(res); if (res?.error) { const error = signinErrors[res.error] ?? signinErrors.default; setError(error); } else if (res?.ok) { - window.location.href = callbackUrl ?? "/"; + window.location.href = callbackUrl; } setIsSubmitting(false); }; + const usernameLabel = + provider === "credentials" + ? t("login-in-page.username") + : t("login-in-page.clientId"); + const passwordLabel = + provider === "credentials" + ? t("login-in-page.password") + : t("login-in-page.clientSecret"); + return ( Log in to another cluster + ) + } > {error && isSubmitting === false && ( )} ; + const providers = await getProviders(); + const clusters = await getKafkaClusters(); + const cluster = clusters.find((c) => c.id === params.kafkaId); + if (cluster) { + const authMethod = cluster.meta.authentication; + console.log("???", authMethod); + if (authMethod && authMethod.method !== "anonymous") { + return ( + 1} + /> + ); + } else { + return <>TODO; + } + } + return redirect("/"); } diff --git a/ui/app/api/auth/[...nextauth]/anonymous.ts b/ui/app/api/auth/[...nextauth]/anonymous.ts index a6743f003..6226a971b 100644 --- a/ui/app/api/auth/[...nextauth]/anonymous.ts +++ b/ui/app/api/auth/[...nextauth]/anonymous.ts @@ -1,7 +1,8 @@ import { AuthOptions } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; +import { Provider } from "next-auth/providers/index"; -export function makeAnonymous(): AuthOptions { +export function makeAnonymous(): Provider { const provider = CredentialsProvider({ name: "Unauthenticated", credentials: {}, @@ -10,8 +11,5 @@ export function makeAnonymous(): AuthOptions { }, }); - return { - providers: [provider], - callbacks: {}, - }; + return provider; } diff --git a/ui/app/api/auth/[...nextauth]/keycloak.ts b/ui/app/api/auth/[...nextauth]/keycloak.ts index 23ceace17..957868765 100644 --- a/ui/app/api/auth/[...nextauth]/keycloak.ts +++ b/ui/app/api/auth/[...nextauth]/keycloak.ts @@ -1,6 +1,7 @@ import { logger } from "@/utils/logger"; import { AuthOptions, Session, TokenSet } from "next-auth"; import { JWT } from "next-auth/jwt"; +import { Provider } from "next-auth/providers/index"; import KeycloakProvider from "next-auth/providers/keycloak"; const log = logger.child({ module: "keycloak" }); @@ -9,7 +10,7 @@ export function makeOauthProvider( clientId: string, clientSecret: string, issuer: string, -): AuthOptions { +): Provider { const provider = KeycloakProvider({ clientId, clientSecret, @@ -106,36 +107,38 @@ export function makeOauthProvider( } } - return { - providers: [provider], - callbacks: { - async jwt({ token, account }: { token: JWT; account: any }) { - // Persist the OAuth access_token and or the user id to the token right after signin - if (account) { - log.trace("account present, saving new token"); - // Save the access token and refresh token in the JWT on the initial login - return { - access_token: account.access_token, - expires_at: account.expires_at, - refresh_token: account.refresh_token, - email: token.email, - name: token.name, - picture: token.picture, - sub: token.sub, - }; - } + return provider; - return refreshToken(token); - }, - async session({ session, token }: { session: Session; token: JWT }) { - // Send properties to the client, like an access_token from a provider. - log.trace(token, "Creating session from token"); - return { - ...session, - error: token.error, - accessToken: token.access_token, - }; - }, - }, - }; + // return { + // providers: [provider], + // callbacks: { + // async jwt({ token, account }: { token: JWT; account: any }) { + // // Persist the OAuth access_token and or the user id to the token right after signin + // if (account) { + // log.trace("account present, saving new token"); + // // Save the access token and refresh token in the JWT on the initial login + // return { + // access_token: account.access_token, + // expires_at: account.expires_at, + // refresh_token: account.refresh_token, + // email: token.email, + // name: token.name, + // picture: token.picture, + // sub: token.sub, + // }; + // } + // + // return refreshToken(token); + // }, + // async session({ session, token }: { session: Session; token: JWT }) { + // // Send properties to the client, like an access_token from a provider. + // log.trace(token, "Creating session from token"); + // return { + // ...session, + // error: token.error, + // accessToken: token.access_token, + // }; + // }, + // }, + // }; } diff --git a/ui/app/api/auth/[...nextauth]/oauth-token.ts b/ui/app/api/auth/[...nextauth]/oauth-token.ts new file mode 100644 index 000000000..5dd352c9f --- /dev/null +++ b/ui/app/api/auth/[...nextauth]/oauth-token.ts @@ -0,0 +1,52 @@ +import { getKafkaClusters } from "@/api/kafka/actions"; +import { AuthOptions } from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; +import { Provider } from "next-auth/providers/index"; + +export function makeOauthTokenProvider(tokenUrl: string): Provider { + const provider = CredentialsProvider({ + // The name to display on the sign in form (e.g. 'Sign in with...') + name: "OAuth token", + id: "oauth-token", + + credentials: { + clientId: { label: "Client ID", type: "text" }, + secret: { label: "Client Secret", type: "password" }, + }, + + async authorize(credentials) { + console.log({ credentials }); + // try the username/password combo against the getKafkaCluster API call + // if we get a response, then we can assume the credentials are correct + const { clientId, secret } = credentials ?? {}; + try { + if (clientId && secret) { + const params = new URLSearchParams(); + params.append("client_id", clientId); + params.append("client_secret", secret); + params.append("grant_type", "client_credentials"); + + const res = await fetch(tokenUrl, { + cache: "no-cache", + body: params, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + const data = await res.json(); + const accessToken = data.access_token as string; + + if (res.status === 200 && accessToken) { + return { id: "1", name: clientId, accessToken }; + } + } + } catch {} + // store the credentials in the session + // if we didn't get a successful response, the credentials are wrong + return null; + }, + }); + + return provider; +} diff --git a/ui/app/api/auth/[...nextauth]/route.ts b/ui/app/api/auth/[...nextauth]/route.ts index bbb2bf07a..b456f4d3e 100644 --- a/ui/app/api/auth/[...nextauth]/route.ts +++ b/ui/app/api/auth/[...nextauth]/route.ts @@ -1,7 +1,9 @@ import { getKafkaClusters } from "@/api/kafka/actions"; import { ClusterList } from "@/api/kafka/schema"; +import { makeOauthTokenProvider } from "@/app/api/auth/[...nextauth]/oauth-token"; import { logger } from "@/utils/logger"; import NextAuth, { AuthOptions } from "next-auth"; +import { Provider } from "next-auth/providers/index"; import { NextRequest, NextResponse } from "next/server"; import { makeAnonymous } from "./anonymous"; import { makeOauthProvider } from "./keycloak"; @@ -9,28 +11,39 @@ import { makeScramShaProvider } from "./scram"; const log = logger.child({ module: "auth" }); -export async function getAuthOptions( - kafkaId?: string, -): Promise { - if (kafkaId) { - // retrieve the authentication method required by the default Kafka cluster - const clusters = await getKafkaClusters(); - const cluster = clusters.find((c) => c.id === kafkaId); - if (cluster) { - return makeAuthOption(cluster); - } - } - return null; +export async function getAuthOptions(): Promise { + // retrieve the authentication method required by the default Kafka cluster + const clusters = await getKafkaClusters(); + const providers = clusters.map(makeAuthOption); + log.trace({ providers }, "getAuthOptions"); + return { + providers, + callbacks: { + async jwt({ token, user }) { + if (user) { + token.basicAuth = user.basicAuth; + } + return token; + }, + async session({ session, token, user }) { + // Send properties to the client, like an access_token and user id from a provider. + session.accessToken = token.accessToken; + session.basicAuth = token.basicAuth; + + return session; + }, + }, + }; } -function makeAuthOption(cluster: ClusterList): AuthOptions { - switch (cluster.attributes.authMethod?.method) { +function makeAuthOption(cluster: ClusterList): Provider { + switch (cluster.meta.authentication?.method) { case "oauth": { - const { clientId, clientSecret, issuer } = cluster.attributes.authMethod; - return makeOauthProvider(clientId, clientSecret, issuer); + const { tokenUrl } = cluster.meta.authentication; + return makeOauthTokenProvider(tokenUrl ?? "TODO"); } - case "scram-sha": - return makeScramShaProvider(); + case "basic": + return makeScramShaProvider(cluster.id); case "anonymous": default: return makeAnonymous(); @@ -38,11 +51,9 @@ function makeAuthOption(cluster: ClusterList): AuthOptions { } // const handler = NextAuth(authOptions); -async function handler( - req: NextRequest, - { params }: { params: { kafkaId?: string } }, -) { +async function handler(req: NextRequest, res: NextResponse) { const authOptions = await getAuthOptions(); + log.trace({ authOptions }, "handler"); if (authOptions) { // set up the auth handler, if undefined there is no authentication required for the cluster const authHandler = NextAuth({ @@ -60,10 +71,10 @@ async function handler( }, }, }); + // handle the request - return authHandler(req, params.kafkaId); + return authHandler(req, res); } - return NextResponse.redirect(new URL("/", req.url)); } export { handler as GET, handler as POST }; diff --git a/ui/app/api/auth/[...nextauth]/scram.ts b/ui/app/api/auth/[...nextauth]/scram.ts index 594fc2e3c..beb252c62 100644 --- a/ui/app/api/auth/[...nextauth]/scram.ts +++ b/ui/app/api/auth/[...nextauth]/scram.ts @@ -1,6 +1,7 @@ import { getKafkaClusters } from "@/api/kafka/actions"; import { AuthOptions } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; +import { Provider } from "next-auth/providers/index"; function bytesToBase64(bytes: Uint8Array): string { const binString = Array.from(bytes, (byte) => @@ -9,7 +10,7 @@ function bytesToBase64(bytes: Uint8Array): string { return btoa(binString); } -export function makeScramShaProvider(): AuthOptions { +export function makeScramShaProvider(kafkaId: string): Provider { const provider = CredentialsProvider({ // The name to display on the sign in form (e.g. 'Sign in with...') name: "Kafka SAML", @@ -24,9 +25,7 @@ export function makeScramShaProvider(): AuthOptions { // try the username/password combo against the getKafkaCluster API call // if we get a response, then we can assume the credentials are correct try { - const clusters = await getKafkaClusters(); - const defaultCluster = clusters[0]; - const url = `${process.env.BACKEND_URL}/api/kafkas/${defaultCluster.id}?$`; + const url = `${process.env.BACKEND_URL}/api/kafkas/${kafkaId}?$`; const basicAuth = bytesToBase64( new TextEncoder().encode( `${credentials?.username}:${credentials?.password}`, @@ -51,25 +50,24 @@ export function makeScramShaProvider(): AuthOptions { }, }); - return { - providers: [provider], - pages: { - signIn: "/auth/signIn", - }, - callbacks: { - async jwt({ token, user }) { - if (user) { - token.basicAuth = user.basicAuth; - } - return token; - }, - async session({ session, token, user }) { - // Send properties to the client, like an access_token and user id from a provider. - session.accessToken = token.accessToken; - session.basicAuth = token.basicAuth; + return provider; - return session; - }, - }, - }; + // return { + // providers: [provider], + // callbacks: { + // async jwt({ token, user }) { + // if (user) { + // token.basicAuth = user.basicAuth; + // } + // return token; + // }, + // async session({ session, token, user }) { + // // Send properties to the client, like an access_token and user id from a provider. + // session.accessToken = token.accessToken; + // session.basicAuth = token.basicAuth; + // + // return session; + // }, + // }, + // }; } diff --git a/ui/messages/en.json b/ui/messages/en.json index cb8e552d0..83d55fd9e 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -31,6 +31,8 @@ "login_sub_title": "Log in to your account", "username": "Username", "password": "Password", + "clientId": "Client ID", + "clientSecret": "Client Secret", "login_button": "Log in", "text_content": "The {product} console delivers real-time insights for managing and optimizing your Kafka clusters. Connect and monitor your clusters effortlessly.", "footer_text": "To log in, you need to enter username and password. etc", diff --git a/ui/middleware.ts b/ui/middleware.ts index 47f98f34c..ced982b69 100644 --- a/ui/middleware.ts +++ b/ui/middleware.ts @@ -4,7 +4,7 @@ import createIntlMiddleware from "next-intl/middleware"; import { NextRequest, NextResponse } from "next/server"; const publicPages = ["/kafka/\\d/login", "/cluster"]; -const protectedPages = ["/kafka/\\d/.?"]; +const protectedPages = ["/kafka/\\d/.*"]; const intlMiddleware = createIntlMiddleware({ // A list of all locales that are supported @@ -15,23 +15,22 @@ const intlMiddleware = createIntlMiddleware({ localePrefix: "never", }); -const authMiddleware = (kafkaId: string) => - withAuth( - // Note that this callback is only invoked if - // the `authorized` callback has returned `true` - // and not for pages listed in `pages`. - function onSuccess(req) { - return intlMiddleware(req); +const authMiddleware = withAuth( + // Note that this callback is only invoked if + // the `authorized` callback has returned `true` + // and not for pages listed in `pages`. + function onSuccess(req) { + return intlMiddleware(req); + }, + { + callbacks: { + authorized: ({ token }) => token != null, }, - { - callbacks: { - authorized: ({ token }) => token != null, - }, - pages: { - signIn: `/kafka/${kafkaId}/login`, - }, + pages: { + signIn: `/kafka/1/login`, }, - ) as any; + }, +) as any; const publicPathnameRegex = RegExp( `^(/(${locales.join("|")}))?(${publicPages @@ -54,7 +53,7 @@ export default async function middleware(req: NextRequest) { if (isPublicPage) { return intlMiddleware(req); } else if (isProtectedPage) { - return authMiddleware("1")(req); + return (authMiddleware as any)(req); } else { return NextResponse.redirect(new URL("/cluster", req.url)); }