mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-24 20:09:02 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user