Remove the docs auth module

689 lines under radix-ui/src/auth: an OIDC login/callback/logout handler
set, a session cookie, JWT middleware and a page-tree access filter. It
imports jose, which the package does not depend on, so it has never
compiled. tsdown has no entry matching it, dist has never contained it,
the exports map does not name it, and the only importer is its own test.

It is also the wrong shape and the wrong home: sessions, tokens and gates
belong to IAM, and a documentation component library is not where an auth
middleware lives. Nothing consumes it, so nothing breaks.

Recover with: git checkout 955dd5989 -- packages/radix-ui/src/auth
This commit is contained in:
zooqueen
2026-08-27 17:05:18 -07:00
parent be06951f9c
commit 9bd4887a6a
9 changed files with 0 additions and 864 deletions
-92
View File
@@ -1,92 +0,0 @@
import type * as PageTree from '@hanzo/docs-core/page-tree';
/**
* Filter a page tree to remove nodes that require auth the current user doesn't have.
*
* This is a pure function — it returns a new tree, leaving the original unchanged.
*/
export function filterPageTreeByAccess(
tree: PageTree.Root,
options: {
isAuthenticated: boolean;
org?: string;
roles?: string[];
},
): PageTree.Root {
return {
...tree,
children: filterNodes(tree.children, options),
fallback: tree.fallback
? filterPageTreeByAccess(tree.fallback, options)
: undefined,
};
}
function filterNodes(
nodes: PageTree.Node[],
options: {
isAuthenticated: boolean;
org?: string;
roles?: string[];
},
): PageTree.Node[] {
const result: PageTree.Node[] = [];
for (const node of nodes) {
if (node.type === 'page') {
if (isNodeVisible(node, options)) {
result.push(node);
}
continue;
}
if (node.type === 'separator') {
result.push(node);
continue;
}
// Folder: check index page access, then recurse children
if (node.type === 'folder') {
if (node.index && !isNodeVisible(node.index, options)) {
// If the folder's index page is gated, hide the entire folder
continue;
}
const filteredChildren = filterNodes(node.children, options);
// Keep folder if it has visible children or a visible index
if (filteredChildren.length > 0 || node.index) {
result.push({
...node,
children: filteredChildren,
});
}
}
}
return result;
}
function isNodeVisible(
node: PageTree.Item,
options: {
isAuthenticated: boolean;
org?: string;
roles?: string[];
},
): boolean {
const access = node.access;
if (!access || access === 'public') return true;
if (!options.isAuthenticated) return false;
// Check org restriction
if (node.accessOrgs && node.accessOrgs.length > 0) {
if (!options.org || !node.accessOrgs.includes(options.org)) return false;
}
// Check role-based access
const roles = options.roles ?? [];
if (access === 'admin' && !roles.includes('admin')) return false;
if (access === 'team' && !roles.includes('team') && !roles.includes('admin')) return false;
return true;
}
-171
View File
@@ -1,171 +0,0 @@
import type { DocsAuthConfig } from './types';
import { AUTH_DEFAULTS } from './types';
interface RouteContext {
config: DocsAuthConfig;
}
function resolveRedirectUri(config: DocsAuthConfig, requestUrl: string): string {
if (config.redirectUri) return config.redirectUri;
const url = new URL(config.callbackPath ?? AUTH_DEFAULTS.callbackPath, requestUrl);
return url.toString();
}
/**
* Handles the login route — redirects to OIDC authorize endpoint.
*/
export function createLoginHandler({ config }: RouteContext) {
return function handleLogin(request: Request): Response {
if (!config.enabled) {
return new Response('Auth not enabled', { status: 404 });
}
const url = new URL(request.url);
const redirectTo = url.searchParams.get('redirect') ?? '/';
const scope = config.scope ?? AUTH_DEFAULTS.scope;
const redirectUri = resolveRedirectUri(config, request.url);
// Encode redirect path in state
const state = btoa(JSON.stringify({ redirect: redirectTo }));
const authorizeUrl = new URL('/oauth/authorize', config.issuer);
authorizeUrl.searchParams.set('client_id', config.clientId);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
authorizeUrl.searchParams.set('scope', scope);
authorizeUrl.searchParams.set('state', state);
return Response.redirect(authorizeUrl.toString(), 302);
};
}
/**
* Handles the OIDC callback — exchanges code for tokens, sets cookie, redirects.
*/
export function createCallbackHandler({ config }: RouteContext) {
return async function handleCallback(request: Request): Promise<Response> {
if (!config.enabled) {
return new Response('Auth not enabled', { status: 404 });
}
const url = new URL(request.url);
const code = url.searchParams.get('code');
const stateParam = url.searchParams.get('state');
if (!code) {
return new Response('Missing authorization code', { status: 400 });
}
// Decode redirect from state
let redirectTo = '/';
if (stateParam) {
try {
const state = JSON.parse(atob(stateParam));
if (typeof state.redirect === 'string') redirectTo = state.redirect;
} catch {
// Ignore malformed state
}
}
const redirectUri = resolveRedirectUri(config, request.url);
const tokenUrl = new URL('/oauth/token', config.issuer);
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: config.clientId,
});
if (config.clientSecret) {
body.set('client_secret', config.clientSecret);
}
const tokenResponse = await fetch(tokenUrl.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!tokenResponse.ok) {
const text = await tokenResponse.text();
return new Response(`Token exchange failed: ${text}`, { status: 502 });
}
const tokens = (await tokenResponse.json()) as {
access_token?: string;
id_token?: string;
};
// Prefer id_token (contains user claims), fall back to access_token
const jwt = tokens.id_token ?? tokens.access_token;
if (!jwt) {
return new Response('No token in response', { status: 502 });
}
const cookieName = config.cookieName ?? AUTH_DEFAULTS.cookieName;
const cookieParts = [
`${cookieName}=${jwt}`,
'Path=/',
'HttpOnly',
'Secure',
'SameSite=Lax',
'Max-Age=86400', // 24 hours
];
if (config.cookieDomain) {
cookieParts.push(`Domain=${config.cookieDomain}`);
}
return new Response(null, {
status: 302,
headers: {
Location: redirectTo,
'Set-Cookie': cookieParts.join('; '),
},
});
};
}
/**
* Handles the logout route — clears cookie and redirects to home.
*/
export function createLogoutHandler({ config }: RouteContext) {
return function handleLogout(_request: Request): Response {
const cookieName = config.cookieName ?? AUTH_DEFAULTS.cookieName;
const cookieParts = [
`${cookieName}=`,
'Path=/',
'HttpOnly',
'Secure',
'SameSite=Lax',
'Max-Age=0',
];
if (config.cookieDomain) {
cookieParts.push(`Domain=${config.cookieDomain}`);
}
// Optionally redirect to OIDC end-session endpoint
const location = config.issuer
? new URL('/oauth/logout', config.issuer).toString()
: '/';
return new Response(null, {
status: 302,
headers: {
Location: location,
'Set-Cookie': cookieParts.join('; '),
},
});
};
}
/**
* Create all three route handlers at once.
*/
export function createAuthHandlers(config: DocsAuthConfig) {
const ctx = { config };
return {
login: createLoginHandler(ctx),
callback: createCallbackHandler(ctx),
logout: createLogoutHandler(ctx),
};
}
-8
View File
@@ -1,8 +0,0 @@
export { DocsAuthProvider, useDocsAuth, type DocsAuthProviderProps } from './provider';
export { DocsLoginButton, type DocsLoginButtonProps } from './login-button';
export { Protected, type ProtectedProps } from './protected';
export { createDocsAuthMiddleware, type PageAccessInfo } from './middleware';
export { createAuthHandlers, createLoginHandler, createCallbackHandler, createLogoutHandler } from './handlers';
export { filterPageTreeByAccess } from './filter-tree';
export type { DocsAuthConfig, DocsAuthUser } from './types';
export { AUTH_DEFAULTS } from './types';
@@ -1,59 +0,0 @@
'use client';
import { type ComponentProps } from 'react';
import { useDocsAuth } from './provider';
import { cn } from '@/utils/cn';
export interface DocsLoginButtonProps extends Omit<ComponentProps<'button'>, 'onClick'> {
/** Text shown when not authenticated. @default "Sign in" */
signInText?: string;
/** Text shown when authenticated. @default "Sign out" */
signOutText?: string;
}
export function DocsLoginButton({
signInText = 'Sign in',
signOutText = 'Sign out',
className,
...props
}: DocsLoginButtonProps) {
const { user, isAuthenticated, login, logout } = useDocsAuth();
if (!isAuthenticated) {
return (
<button
type="button"
onClick={() => login(typeof window !== 'undefined' ? window.location.pathname : undefined)}
className={cn(
'inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground',
className,
)}
{...props}
>
{signInText}
</button>
);
}
return (
<button
type="button"
onClick={() => logout()}
className={cn(
'inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground',
className,
)}
{...props}
>
{user?.picture && (
<img
src={user.picture}
alt=""
className="size-5 rounded-full"
/>
)}
<span>{user?.email ?? user?.name ?? 'User'}</span>
<span className="text-fd-muted-foreground/60">{signOutText}</span>
</button>
);
}
-120
View File
@@ -1,120 +0,0 @@
import { createRemoteJWKSSet, jwtVerify, type JWTPayload } from 'jose';
import type { DocsAuthConfig, DocsAuthUser } from './types';
import { AUTH_DEFAULTS } from './types';
interface MiddlewareRequest {
nextUrl: { pathname: string };
cookies: { get(name: string): { value: string } | undefined };
url: string;
}
interface MiddlewareResponse {
redirect(url: URL): MiddlewareResponse;
next(): MiddlewareResponse;
}
/**
* Page metadata used by the middleware to determine access requirements.
*/
export interface PageAccessInfo {
access?: 'public' | 'authenticated' | 'team' | 'admin';
accessOrgs?: string[];
}
/**
* Creates a Next.js middleware function for auth-gated docs.
*
* @param config - OIDC auth configuration
* @param getPageAccess - Given a pathname, returns the page's access metadata.
* Return undefined for non-docs paths or pages without access restrictions.
*/
export function createDocsAuthMiddleware(
config: DocsAuthConfig,
getPageAccess: (pathname: string) => PageAccessInfo | undefined,
) {
if (!config.enabled) {
return function noopMiddleware() {
return undefined;
};
}
const cookieName = config.cookieName ?? AUTH_DEFAULTS.cookieName;
const loginPath = config.loginPath ?? AUTH_DEFAULTS.loginPath;
const jwksUri = new URL('/.well-known/jwks.json', config.issuer);
const JWKS = createRemoteJWKSSet(jwksUri);
async function verifyToken(token: string): Promise<(JWTPayload & DocsAuthUser) | null> {
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: config.issuer,
});
return payload as JWTPayload & DocsAuthUser;
} catch {
return null;
}
}
function hasAccess(
pageAccess: PageAccessInfo,
claims: (JWTPayload & DocsAuthUser) | null,
): boolean {
const level = pageAccess.access ?? 'public';
if (level === 'public') return true;
if (!claims) return false;
// Check org restriction
if (pageAccess.accessOrgs && pageAccess.accessOrgs.length > 0) {
const userOrg = claims.org;
if (!userOrg || !pageAccess.accessOrgs.includes(userOrg)) return false;
}
// Check email domain restriction
if (config.emailDomains && config.emailDomains.length > 0 && claims.email) {
const domain = claims.email.split('@')[1];
if (!domain || !config.emailDomains.includes(domain)) return false;
}
// Check role-based access
const roles = claims.roles ?? [];
if (level === 'admin' && !roles.includes('admin')) return false;
if (level === 'team' && !roles.includes('team') && !roles.includes('admin')) return false;
return true;
}
return async function docsAuthMiddleware(
request: MiddlewareRequest,
NextResponse: MiddlewareResponse,
) {
const { pathname } = request.nextUrl;
// Skip auth routes themselves
if (
pathname === loginPath ||
pathname === (config.callbackPath ?? AUTH_DEFAULTS.callbackPath) ||
pathname === (config.logoutPath ?? AUTH_DEFAULTS.logoutPath)
) {
return NextResponse.next();
}
const pageAccess = getPageAccess(pathname);
// Not a docs page or no access restrictions
if (!pageAccess || (pageAccess.access ?? 'public') === 'public') {
return NextResponse.next();
}
// Check JWT
const cookie = request.cookies.get(cookieName);
const claims = cookie ? await verifyToken(cookie.value) : null;
if (hasAccess(pageAccess, claims)) {
return NextResponse.next();
}
// Redirect to login
const loginUrl = new URL(loginPath, request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
};
}
-85
View File
@@ -1,85 +0,0 @@
'use client';
import { type ReactNode } from 'react';
import { useDocsAuth } from './provider';
import { cn } from '@/utils/cn';
import type { AccessLevel } from '@hanzo/docs-core/source/schema';
export interface ProtectedProps {
/** Minimum access level required. @default "authenticated" */
access?: AccessLevel;
/** Required org membership */
orgs?: string[];
/** Content to display when authorized */
children: ReactNode;
/** Custom placeholder when unauthorized */
fallback?: ReactNode;
/** Additional className for the wrapper */
className?: string;
}
function hasAccess(
access: AccessLevel,
isAuthenticated: boolean,
roles: string[],
org: string | undefined,
orgs?: string[],
): boolean {
if (access === 'public') return true;
if (!isAuthenticated) return false;
if (orgs && orgs.length > 0 && (!org || !orgs.includes(org))) {
return false;
}
if (access === 'admin' && !roles.includes('admin')) return false;
if (access === 'team' && !roles.includes('team') && !roles.includes('admin')) return false;
return true;
}
export function Protected({
access = 'authenticated',
orgs,
children,
fallback,
className,
}: ProtectedProps) {
const { isAuthenticated, roles, org, login } = useDocsAuth();
if (hasAccess(access, isAuthenticated, roles, org, orgs)) {
return <>{children}</>;
}
if (fallback) return <>{fallback}</>;
return (
<div
className={cn(
'relative my-4 rounded-xl border border-fd-border bg-fd-muted/30 p-6',
className,
)}
>
<div className="select-none blur-sm" aria-hidden="true">
<div className="h-4 w-3/4 rounded bg-fd-muted-foreground/10 mb-3" />
<div className="h-4 w-full rounded bg-fd-muted-foreground/10 mb-3" />
<div className="h-4 w-2/3 rounded bg-fd-muted-foreground/10 mb-3" />
<div className="h-4 w-5/6 rounded bg-fd-muted-foreground/10" />
</div>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
<p className="text-sm font-medium text-fd-muted-foreground">
Sign in to view this content
</p>
<button
type="button"
onClick={() =>
login(typeof window !== 'undefined' ? window.location.pathname : undefined)
}
className="inline-flex items-center rounded-lg bg-fd-primary px-4 py-2 text-sm font-medium text-fd-primary-foreground transition-colors hover:bg-fd-primary/90"
>
Sign in
</button>
</div>
</div>
);
}
-93
View File
@@ -1,93 +0,0 @@
'use client';
import {
createContext,
use,
useCallback,
useMemo,
type ReactNode,
} from 'react';
import type { DocsAuthConfig, DocsAuthUser } from './types';
import { AUTH_DEFAULTS } from './types';
interface DocsAuthContextValue {
user: DocsAuthUser | null;
isAuthenticated: boolean;
org: string | undefined;
roles: string[];
login: (redirectTo?: string) => void;
logout: () => void;
}
const DocsAuthContext = createContext<DocsAuthContextValue | null>(null);
function decodeJwtPayload(token: string): DocsAuthUser | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payload = parts[1];
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(json) as DocsAuthUser;
} catch {
return null;
}
}
function getCookie(name: string): string | undefined {
if (typeof document === 'undefined') return undefined;
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : undefined;
}
export interface DocsAuthProviderProps {
config: DocsAuthConfig;
children: ReactNode;
}
export function DocsAuthProvider({ config, children }: DocsAuthProviderProps) {
const cookieName = config.cookieName ?? AUTH_DEFAULTS.cookieName;
const loginPath = config.loginPath ?? AUTH_DEFAULTS.loginPath;
const logoutPath = config.logoutPath ?? AUTH_DEFAULTS.logoutPath;
const token = config.enabled ? getCookie(cookieName) : undefined;
const user = token ? decodeJwtPayload(token) : null;
const login = useCallback(
(redirectTo?: string) => {
const params = new URLSearchParams();
if (redirectTo) params.set('redirect', redirectTo);
const qs = params.toString();
window.location.href = qs ? `${loginPath}?${qs}` : loginPath;
},
[loginPath],
);
const logout = useCallback(() => {
window.location.href = logoutPath;
}, [logoutPath]);
const value = useMemo<DocsAuthContextValue>(
() => ({
user,
isAuthenticated: user !== null,
org: user?.org,
roles: user?.roles ?? [],
login,
logout,
}),
[user, login, logout],
);
return <DocsAuthContext value={value}>{children}</DocsAuthContext>;
}
export function useDocsAuth(): DocsAuthContextValue {
const ctx = use(DocsAuthContext);
if (!ctx) {
throw new Error(
'useDocsAuth must be used within a <DocsAuthProvider>. ' +
'Wrap your app with DocsAuthProvider and pass a DocsAuthConfig.',
);
}
return ctx;
}
-61
View File
@@ -1,61 +0,0 @@
/**
* Configuration for OIDC-based docs authentication.
*
* Pass this to `DocsAuthProvider` and `createDocsAuthMiddleware` to enable
* auth-gated docs pages.
*/
export interface DocsAuthConfig {
/** Enable auth features. When false, all pages are public. */
enabled: boolean;
/** OIDC issuer URL, e.g. "https://hanzo.id" */
issuer: string;
/** OIDC client ID */
clientId: string;
/** OIDC client secret (server-side only, for token exchange) */
clientSecret?: string;
/** Cookie name for the JWT. @default "_docs_auth" */
cookieName?: string;
/** Cookie domain, e.g. ".hanzo.ai" */
cookieDomain?: string;
/** Callback URL for OIDC redirect. @default "/auth/callback" */
redirectUri?: string;
/** OIDC scopes. @default "openid email profile" */
scope?: string;
/** Restrict login to specific email domains */
emailDomains?: string[];
/** Login route path. @default "/auth/login" */
loginPath?: string;
/** Callback route path. @default "/auth/callback" */
callbackPath?: string;
/** Logout route path. @default "/auth/logout" */
logoutPath?: string;
}
/** Claims decoded from the JWT (display-only on client, verified on server). */
export interface DocsAuthUser {
sub: string;
email?: string;
name?: string;
picture?: string;
org?: string;
roles?: string[];
}
export const AUTH_DEFAULTS = {
cookieName: '_docs_auth',
scope: 'openid email profile',
loginPath: '/auth/login',
callbackPath: '/auth/callback',
logoutPath: '/auth/logout',
} as const;
@@ -1,175 +0,0 @@
import { expect, test, describe } from 'vitest';
import { filterPageTreeByAccess } from '../src/auth/filter-tree';
import type * as PageTree from '@hanzo/docs-core/page-tree';
function item(name: string, access?: 'public' | 'authenticated' | 'team' | 'admin', accessOrgs?: string[]): PageTree.Item {
return {
type: 'page',
name,
url: `/${name.toLowerCase().replace(/\s/g, '-')}`,
access,
accessOrgs,
};
}
function folder(name: string, children: PageTree.Node[], index?: PageTree.Item): PageTree.Folder {
return {
type: 'folder',
name,
children,
index,
};
}
function sep(name: string): PageTree.Separator {
return { type: 'separator', name };
}
function root(children: PageTree.Node[]): PageTree.Root {
return { name: 'Docs', children };
}
describe('filterPageTreeByAccess', () => {
test('public pages pass through for anonymous users', () => {
const tree = root([
item('Getting Started'),
item('API Reference', 'public'),
]);
const filtered = filterPageTreeByAccess(tree, { isAuthenticated: false });
expect(filtered.children).toHaveLength(2);
});
test('authenticated pages hidden from anonymous users', () => {
const tree = root([
item('Getting Started'),
item('Partner Guide', 'authenticated'),
]);
const filtered = filterPageTreeByAccess(tree, { isAuthenticated: false });
expect(filtered.children).toHaveLength(1);
expect((filtered.children[0] as PageTree.Item).name).toBe('Getting Started');
});
test('authenticated pages visible to logged-in users', () => {
const tree = root([
item('Getting Started'),
item('Partner Guide', 'authenticated'),
]);
const filtered = filterPageTreeByAccess(tree, { isAuthenticated: true });
expect(filtered.children).toHaveLength(2);
});
test('admin pages hidden from non-admin users', () => {
const tree = root([
item('Public', 'public'),
item('Admin Only', 'admin'),
]);
const filtered = filterPageTreeByAccess(tree, {
isAuthenticated: true,
roles: ['team'],
});
expect(filtered.children).toHaveLength(1);
});
test('admin pages visible to admin users', () => {
const tree = root([
item('Public'),
item('Admin Only', 'admin'),
]);
const filtered = filterPageTreeByAccess(tree, {
isAuthenticated: true,
roles: ['admin'],
});
expect(filtered.children).toHaveLength(2);
});
test('team pages visible to admin users (admin > team)', () => {
const tree = root([
item('Team Content', 'team'),
]);
const filtered = filterPageTreeByAccess(tree, {
isAuthenticated: true,
roles: ['admin'],
});
expect(filtered.children).toHaveLength(1);
});
test('org-restricted pages hidden from wrong org', () => {
const tree = root([
item('Public'),
item('Acme Only', 'authenticated', ['acme']),
]);
const filtered = filterPageTreeByAccess(tree, {
isAuthenticated: true,
org: 'other-corp',
});
expect(filtered.children).toHaveLength(1);
});
test('org-restricted pages visible to matching org', () => {
const tree = root([
item('Public'),
item('Acme Only', 'authenticated', ['acme']),
]);
const filtered = filterPageTreeByAccess(tree, {
isAuthenticated: true,
org: 'acme',
});
expect(filtered.children).toHaveLength(2);
});
test('folders with gated index are hidden for anonymous users', () => {
const tree = root([
folder(
'Partner Docs',
[item('Setup Guide', 'authenticated')],
item('Overview', 'authenticated'),
),
item('Public Page'),
]);
const filtered = filterPageTreeByAccess(tree, { isAuthenticated: false });
expect(filtered.children).toHaveLength(1);
expect((filtered.children[0] as PageTree.Item).name).toBe('Public Page');
});
test('folders with public index but gated children', () => {
const tree = root([
folder(
'Getting Started',
[
item('Basics'),
item('Advanced', 'authenticated'),
],
item('Intro'),
),
]);
const filtered = filterPageTreeByAccess(tree, { isAuthenticated: false });
expect(filtered.children).toHaveLength(1);
const f = filtered.children[0] as PageTree.Folder;
expect(f.children).toHaveLength(1);
expect((f.children[0] as PageTree.Item).name).toBe('Basics');
});
test('separators pass through', () => {
const tree = root([
sep('Section 1'),
item('Page A'),
sep('Section 2'),
item('Page B', 'authenticated'),
]);
const filtered = filterPageTreeByAccess(tree, { isAuthenticated: false });
expect(filtered.children).toHaveLength(3);
expect(filtered.children[0].type).toBe('separator');
expect(filtered.children[1].type).toBe('page');
expect(filtered.children[2].type).toBe('separator');
});
test('original tree is not mutated', () => {
const tree = root([
item('Public'),
item('Gated', 'authenticated'),
]);
const originalLength = tree.children.length;
filterPageTreeByAccess(tree, { isAuthenticated: false });
expect(tree.children).toHaveLength(originalLength);
});
});