Skip to content

Commit

Permalink
Experimental multi-cluster support
Browse files Browse the repository at this point in the history
  • Loading branch information
riccardo-forina committed Aug 6, 2024
1 parent f523464 commit aa6ec1a
Show file tree
Hide file tree
Showing 12 changed files with 240 additions and 128 deletions.
1 change: 1 addition & 0 deletions ui/api/kafka/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export async function getKafkaClusters(): Promise<ClusterList[]> {
Accept: "application/json",
"Content-Type": "application/json",
},
cache: "reload",
});
const rawData = await res.json();
log.trace(rawData, "getKafkaClusters response");
Expand Down
18 changes: 8 additions & 10 deletions ui/api/kafka/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
20 changes: 10 additions & 10 deletions ui/app/[locale]/cluster/ClusterSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -34,16 +36,14 @@ export function ClusterSelector({ clusters }: { clusters: ClusterList[] }) {
brandImgSrc={"/StreamsLogo.svg"}
socialMediaLoginContent={learnMoreResource}
>
<Menu isPlain={true}>
{clusters.map((c) => (
<MenuItem
key={c.id}
component={(props) => (
<Link href={`/kafka/${c.id}/login`}>{c.attributes.name}</Link>
)}
/>
))}
</Menu>
{clusters.map((c) => (
<MenuItem
key={c.id}
component={(props) => (
<Link href={`/kafka/${c.id}/login`}>{c.attributes.name}</Link>
)}
/>
))}
</LoginPage>
);
}
34 changes: 29 additions & 5 deletions ui/app/[locale]/kafka/[kafkaId]/login/SignInPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";
import { ExternalLink } from "@/components/Navigation/ExternalLink";
import { Link } from "@/navigation";
import {
Alert,
LoginForm,
Expand Down Expand Up @@ -40,7 +41,15 @@ const signinErrors: Record<SignInPageErrorParam | "default" | string, string> =
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");

Expand Down Expand Up @@ -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 (
<LoginPage
backgroundImgSrc="/assets/images/pfbg-icon.svg"
Expand All @@ -102,13 +121,18 @@ export function SignInPage({ callbackUrl }: { callbackUrl: string }) {
brandImgSrc={"/StreamsLogo.png"}
footerListItems={t("login-in-page.footer_text")}
socialMediaLoginContent={learnMoreResource}
signUpForAccountMessage={
hasMultipleClusters && (
<Link href={"/clusters"}>Log in to another cluster</Link>
)
}
>
{error && isSubmitting === false && (
<Alert variant={"danger"} isInline={true} title={error} />
)}
<LoginForm
usernameLabel={t("login-in-page.username")}
passwordLabel={t("login-in-page.password")}
usernameLabel={usernameLabel}
passwordLabel={passwordLabel}
loginButtonLabel={t("login-in-page.login_button")}
usernameValue={username}
passwordValue={password}
Expand Down
28 changes: 27 additions & 1 deletion ui/app/[locale]/kafka/[kafkaId]/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,36 @@
import { getKafkaClusters } from "@/api/kafka/actions";
import { redirect } from "@/navigation";
import { getCsrfToken, getProviders } from "next-auth/react";
import { SignInPage } from "./SignInPage";

export default async function SignIn({
searchParams,
params,
}: {
searchParams?: { callbackUrl?: string };
params: { kafkaId?: string };
}) {
return <SignInPage callbackUrl={searchParams?.callbackUrl ?? "/"} />;
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 (
<SignInPage
provider={
authMethod?.method === "basic" ? "credentials" : "oauth-token"
}
callbackUrl={
searchParams?.callbackUrl ?? `/kafka/${params.kafkaId}/overview`
}
hasMultipleClusters={clusters.length > 1}
/>
);
} else {
return <>TODO</>;
}
}
return redirect("/");
}
8 changes: 3 additions & 5 deletions ui/app/api/auth/[...nextauth]/anonymous.ts
Original file line number Diff line number Diff line change
@@ -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: {},
Expand All @@ -10,8 +11,5 @@ export function makeAnonymous(): AuthOptions {
},
});

return {
providers: [provider],
callbacks: {},
};
return provider;
}
67 changes: 35 additions & 32 deletions ui/app/api/auth/[...nextauth]/keycloak.ts
Original file line number Diff line number Diff line change
@@ -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" });
Expand All @@ -9,7 +10,7 @@ export function makeOauthProvider(
clientId: string,
clientSecret: string,
issuer: string,
): AuthOptions {
): Provider {
const provider = KeycloakProvider({
clientId,
clientSecret,
Expand Down Expand Up @@ -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,
// };
// },
// },
// };
}
52 changes: 52 additions & 0 deletions ui/app/api/auth/[...nextauth]/oauth-token.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit aa6ec1a

Please sign in to comment.