Skip to content
Merged
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
47 changes: 0 additions & 47 deletions apps/api/src/app/api/cli/create-code/route.ts

This file was deleted.

51 changes: 0 additions & 51 deletions apps/api/src/app/api/cli/exchange/route.ts

This file was deleted.

71 changes: 69 additions & 2 deletions apps/api/src/trpc/context.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,82 @@
import { auth } from "@superset/auth/server";
import { auth, type Session } from "@superset/auth/server";
import { db } from "@superset/db/client";
import * as authSchema from "@superset/db/schema/auth";
import { createTRPCContext } from "@superset/trpc";
import { verifyAccessToken } from "better-auth/oauth2";
import { eq } from "drizzle-orm";
import { env } from "@/env";

const apiUrl = env.NEXT_PUBLIC_API_URL.replace(/\/+$/, "");

function looksLikeJwt(token: string): boolean {
const parts = token.split(".");
return parts.length === 3 && parts.every(Boolean);
}

async function sessionFromOAuthBearer(
headers: Headers,
): Promise<Session | null> {
const authorization = headers.get("authorization");
const match = authorization?.match(/^Bearer\s+(.+)$/i);
const token = match?.[1];
if (!token || !looksLikeJwt(token)) return null;

let payload: Record<string, unknown>;
try {
payload = (await verifyAccessToken(token, {
jwksUrl: `${apiUrl}/api/auth/jwks`,
verifyOptions: {
issuer: apiUrl,
audience: [apiUrl, `${apiUrl}/`],
},
})) as Record<string, unknown>;
} catch {
return null;
}

const userId = typeof payload.sub === "string" ? payload.sub : null;
if (!userId) return null;

const user = await db.query.users.findFirst({
where: eq(authSchema.users.id, userId),
});
if (!user) return null;

const activeOrganizationId =
typeof payload.organizationId === "string" ? payload.organizationId : null;
Comment on lines +45 to +46

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 organizationId claim may never be present — all org-scoped tRPC calls will see null

Better Auth's oauth-provider issues standard JWT claims (sub, iat, exp, iss, aud). The custom organizationId claim is not a standard claim and is not emitted unless the oauth-provider plugin is explicitly configured to include it. As a result activeOrganizationId will almost always be null for CLI bearer sessions, and any tRPC procedure that reads the active org from the session context will silently behave as if no org is selected — even after the user chose one during login.

The config.organizationId written in command.ts is never forwarded back to the API in the JWT; you'd need either (a) a custom JWT "access-token claims" hook in your Better Auth config that embeds organizationId, or (b) the CLI passing the org explicitly on each request (e.g. a tRPC header), or (c) a separate token exchange step. Without one of these, superset auth whoami and every org-scoped command will return wrong/empty data.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/api/src/trpc/context.ts
Line: 45-46

Comment:
**`organizationId` claim may never be present — all org-scoped tRPC calls will see `null`**

Better Auth's oauth-provider issues standard JWT claims (`sub`, `iat`, `exp`, `iss`, `aud`). The custom `organizationId` claim is not a standard claim and is not emitted unless the oauth-provider plugin is explicitly configured to include it. As a result `activeOrganizationId` will almost always be `null` for CLI bearer sessions, and any tRPC procedure that reads the active org from the session context will silently behave as if no org is selected — even after the user chose one during login.

The `config.organizationId` written in `command.ts` is never forwarded back to the API in the JWT; you'd need either (a) a custom JWT "access-token claims" hook in your Better Auth config that embeds `organizationId`, or (b) the CLI passing the org explicitly on each request (e.g. a tRPC header), or (c) a separate token exchange step. Without one of these, `superset auth whoami` and every org-scoped command will return wrong/empty data.

How can I resolve this? If you propose a fix, please make it concise.


const sessionId = typeof payload.sid === "string" ? payload.sid : userId;

return {
user,
session: {
id: sessionId,
userId,
activeOrganizationId,
expiresAt: new Date(((payload.exp as number) ?? 0) * 1000),
token: token,
ipAddress: null,
userAgent: null,
createdAt: new Date(((payload.iat as number) ?? 0) * 1000),
updatedAt: new Date(((payload.iat as number) ?? 0) * 1000),
},
} as unknown as Session;
}

export const createContext = async ({
req,
}: {
req: Request;
resHeaders: Headers;
}) => {
const session = await auth.api.getSession({
let session = await auth.api.getSession({
headers: req.headers,
});

if (!session) {
session = await sessionFromOAuthBearer(req.headers);
}

return createTRPCContext({
session,
auth,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"use client";

import { Button } from "@superset/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip";
import { useEffect, useRef, useState } from "react";
import { LuClipboard, LuClipboardCheck } from "react-icons/lu";

interface CliAuthCodeDisplayProps {
code: string;
state: string;
}

async function copyToClipboard(value: string): Promise<boolean> {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return true;
} catch {
// fall through
}
}
if (typeof document === "undefined") return false;
try {
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}

function useCopiedFlag() {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);

const flash = () => {
setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 2000);
};
return [copied, flash] as const;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

export function CliAuthCodeDisplay({ code, state }: CliAuthCodeDisplayProps) {
const value = `${code}#${state}`;
const [boxCopied, flashBox] = useCopiedFlag();
const [buttonCopied, flashButton] = useCopiedFlag();

const handleCopy = async (flash: () => void) => {
const ok = await copyToClipboard(value);
if (ok) flash();
};

return (
<div className="mx-auto flex w-full max-w-2xl flex-col items-center space-y-6 px-6 text-center">
<h1 className="text-3xl font-semibold tracking-tight">
Authentication Code
</h1>
<p className="text-muted-foreground">Paste this into Superset CLI:</p>

<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleCopy(flashBox)}
className="bg-muted/50 hover:bg-muted/70 focus-visible:ring-ring/50 w-full cursor-pointer overflow-x-auto rounded-lg border px-6 py-4 text-left transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<code className="font-mono text-sm break-all select-all">
{value}
</code>
</button>
</TooltipTrigger>
<TooltipContent>
{boxCopied ? "Copied!" : "Click to copy"}
</TooltipContent>
</Tooltip>

<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => handleCopy(flashButton)}>
{buttonCopied ? (
<>
<LuClipboardCheck /> Copied!
</>
) : (
<>
<LuClipboard /> Copy code
</>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{buttonCopied ? "Copied!" : "Copy to clipboard"}
</TooltipContent>
</Tooltip>

<p className="text-muted-foreground text-xs">
You can close this tab once the code is pasted.
</p>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { CliAuthCodeDisplay } from "./CliAuthCodeDisplay";
51 changes: 51 additions & 0 deletions apps/web/src/app/cli/auth/code/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Image from "next/image";

import { env } from "@/env";
import { CliAuthCodeDisplay } from "./components/CliAuthCodeDisplay";

interface CliAuthCodePageProps {
searchParams: Promise<Record<string, string>>;
}

export default async function CliAuthCodePage({
searchParams,
}: CliAuthCodePageProps) {
const params = await searchParams;
const code = params.code;
const state = params.state;
const oauthError = params.error;

return (
<div className="relative flex min-h-screen flex-col">
<header className="container mx-auto px-6 py-6">
<a href={env.NEXT_PUBLIC_MARKETING_URL}>
<Image
src="/title.svg"
alt="Superset"
width={140}
height={24}
priority
/>
</a>
</header>
<main className="flex flex-1 items-center justify-center">
{oauthError ? (
<div className="mx-auto flex w-full max-w-md flex-col items-center space-y-3 text-center">
<h1 className="text-2xl font-semibold tracking-tight text-destructive">
Authorization failed
</h1>
<p className="text-muted-foreground text-sm">
{params.error_description ?? oauthError}
</p>
</div>
) : code && state ? (
<CliAuthCodeDisplay code={code} state={state} />
) : (
<p className="text-muted-foreground">
Missing authorization code. Re-run <code>superset auth login</code>.
</p>
)}
</main>
</div>
);
}
Loading
Loading