chore(demo): init oidc client demo (#6178)

This commit is contained in:
Alex Yang
2025-11-22 11:32:23 -08:00
committed by GitHub
parent 335138acd0
commit 5691699c15
24 changed files with 1801 additions and 86 deletions

View File

@@ -0,0 +1,2 @@
VITE_OIDC_ISSUER=http://localhost:3000
VITE_OIDC_CLIENT_ID=your-client-id

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Better Auth OIDC Client Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,38 @@
{
"name": "@better-auth/oidc-client-demo",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3",
"better-auth": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.4.2",
"lucide-react": "^0.542.0",
"next-themes": "^0.4.6",
"oauth4webapi": "^2.10.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"wouter": "^3.7.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.13",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react-swc": "^4.0.0",
"tailwindcss": "^4.1.13",
"typescript": "^5.9.3",
"vite": "^7.1.2"
}
}

View File

@@ -0,0 +1,60 @@
import { Toaster } from "sonner";
import { Route, Router, Switch } from "wouter";
import { Logo } from "@/components/logo";
import { ThemeProvider } from "@/components/theme-provider";
import { ThemeToggle } from "@/components/theme-toggle";
import { AuthProvider } from "@/lib/auth/AuthProvider";
import { Dashboard } from "@/pages/Dashboard";
import { Home } from "@/pages/Home";
function App() {
const issuer = import.meta.env.VITE_OIDC_ISSUER;
const clientId = import.meta.env.VITE_OIDC_CLIENT_ID;
if (!issuer || !clientId) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="text-center space-y-4">
<h1 className="text-2xl font-bold text-destructive">
Configuration Error
</h1>
<p className="text-muted-foreground">
Please set VITE_OIDC_ISSUER and VITE_OIDC_CLIENT_ID environment
variables.
</p>
<p className="text-sm text-muted-foreground">
Copy .env.example to .env and configure your OIDC provider settings.
</p>
</div>
</div>
);
}
return (
<ThemeProvider attribute="class" defaultTheme="dark">
<AuthProvider issuer={issuer} clientId={clientId}>
<div className="min-h-screen bg-background text-foreground">
{/* Header */}
<header className="border-b">
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<Logo />
<ThemeToggle />
</div>
</header>
{/* Main Content */}
<Router>
<Switch>
<Route path="/" component={Home} />
<Route path="/dashboard" component={Dashboard} />
</Switch>
</Router>
<Toaster richColors closeButton />
</div>
</AuthProvider>
</ThemeProvider>
);
}
export default App;

View File

@@ -0,0 +1,22 @@
import type { SVGProps } from "react";
export const Logo = (props: SVGProps<SVGSVGElement>) => {
return (
<svg
width="60"
height="45"
viewBox="0 0 60 45"
fill="none"
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 0H15V15H30V30H15V45H0V30V15V0ZM45 30V15H30V0H45H60V15V30V45H45H30V30H45Z"
className="fill-black dark:fill-white"
/>
</svg>
);
};

View File

@@ -0,0 +1,8 @@
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -0,0 +1,19 @@
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
export function ThemeToggle() {
const { setTheme, theme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
>
<Sun className="h-[1.5rem] w-[1.3rem] dark:hidden" />
<Moon className="hidden h-5 w-5 dark:block" />
<span className="sr-only">Toggle theme</span>
</Button>
);
}

View File

@@ -0,0 +1,48 @@
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as React from "react";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,57 @@
import { Slot } from "@radix-ui/react-slot";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,86 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,97 @@
@import "tailwindcss";
@config "../tailwind.config.ts";
@custom-variant dark (&:is(.dark *));
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(20 14.3% 4.1%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(20 14.3% 4.1%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(20 14.3% 4.1%);
--primary: hsl(24 9.8% 10%);
--primary-foreground: hsl(60 9.1% 97.8%);
--secondary: hsl(60 4.8% 95.9%);
--secondary-foreground: hsl(24 9.8% 10%);
--muted: hsl(60 4.8% 95.9%);
--muted-foreground: hsl(25 5.3% 44.7%);
--accent: hsl(60 4.8% 95.9%);
--accent-foreground: hsl(24 9.8% 10%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(60 9.1% 97.8%);
--border: hsl(20 5.9% 90%);
--input: hsl(20 5.9% 90%);
--ring: hsl(20 14.3% 4.1%);
--radius: 0rem;
}
.dark {
--background: hsl(20 14.3% 4.1%);
--foreground: hsl(60 9.1% 97.8%);
--card: hsl(20 14.3% 4.1%);
--card-foreground: hsl(60 9.1% 97.8%);
--popover: hsl(20 14.3% 4.1%);
--popover-foreground: hsl(60 9.1% 97.8%);
--primary: hsl(60 9.1% 97.8%);
--primary-foreground: hsl(24 9.8% 10%);
--secondary: hsl(12 6.5% 15.1%);
--secondary-foreground: hsl(60 9.1% 97.8%);
--muted: hsl(12 6.5% 15.1%);
--muted-foreground: hsl(24 5.4% 63.9%);
--accent: hsl(12 6.5% 15.1%);
--accent-foreground: hsl(60 9.1% 97.8%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(60 9.1% 97.8%);
--border: hsl(12 6.5% 15.1%);
--input: hsl(12 6.5% 15.1%);
--ring: hsl(24 5.7% 82.9%);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family:
"Geist Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, sans-serif;
}
}
.no-visible-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-overflow-scrolling: touch;
}
.no-visible-scrollbar::-webkit-scrollbar {
display: none;
}

View File

@@ -0,0 +1,117 @@
import type { Client } from "oauth4webapi";
import { discoveryRequest, processDiscoveryResponse } from "oauth4webapi";
import { useEffect, useState } from "react";
import type { AuthContextType } from "./context";
import { AuthContext } from "./context";
type AuthProviderProps = {
issuer: string;
clientId: string;
children: React.ReactNode;
};
const STORAGE_KEY = "oidc:state";
export const AuthProvider: React.FC<AuthProviderProps> = ({
children,
issuer,
clientId,
}) => {
const client: Client = {
client_id: clientId,
token_endpoint_auth_method: "none",
redirect_uris: [window.location.origin],
};
const [as, setAs] = useState<AuthContextType["as"]>();
const [accessToken, setAccessTokenState] =
useState<AuthContextType["accessToken"]>();
const [idToken, setIdTokenState] = useState<AuthContextType["idToken"]>();
const [user, setUserState] = useState<AuthContextType["user"]>();
// Wrapper functions to persist to localStorage
const setAccessToken = (token?: string) => {
setAccessTokenState(token);
if (token) {
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ ...stored, accessToken: token }),
);
}
};
const setIdToken = (token?: string) => {
setIdTokenState(token);
if (token) {
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ ...stored, idToken: token }),
);
}
};
const setUser = (userData?: Record<string, unknown>) => {
setUserState(userData);
if (userData) {
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ ...stored, user: userData }),
);
} else {
localStorage.removeItem(STORAGE_KEY);
}
};
// Load from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const { accessToken, idToken, user } = JSON.parse(stored);
if (accessToken) setAccessTokenState(accessToken);
if (idToken) setIdTokenState(idToken);
if (user) setUserState(user);
}
} catch (error) {
console.error("Failed to load auth state from localStorage", error);
}
}, []);
useEffect(() => {
if (!issuer || as) {
return;
}
try {
const issuerUrl = new URL(issuer);
discoveryRequest(issuerUrl, { algorithm: "oidc" })
.then((response) => processDiscoveryResponse(issuerUrl, response))
.then((as) => setAs(as))
.catch((error) =>
console.error("Failed to fetch issuer metadata", error),
);
} catch (error) {
console.error("Failed to fetch issuer metadata", error);
}
}, [issuer]);
return (
<AuthContext.Provider
value={{
as,
client,
accessToken,
setAccessToken,
idToken,
setIdToken,
user,
setUser,
}}
>
{children}
</AuthContext.Provider>
);
};

View File

@@ -0,0 +1,20 @@
import type { AuthorizationServer, Client } from "oauth4webapi";
import { createContext } from "react";
export type AuthContextType = {
as?: AuthorizationServer;
client: Client;
accessToken?: string;
setAccessToken: (token?: string) => void;
idToken?: string;
setIdToken: (token?: string) => void;
user?: Record<string, unknown>;
setUser: (user?: Record<string, unknown>) => void;
};
export const AuthContext = createContext<AuthContextType>({
client: { client_id: "", token_endpoint_auth_method: "none" },
setAccessToken: () => {},
setIdToken: () => {},
setUser: () => {},
});

View File

@@ -0,0 +1,230 @@
// This code is heavily based on the following example: https://github.com/panva/oauth4webapi/blob/HEAD/examples/oidc.ts
import * as oauth from "oauth4webapi";
import { useContext, useEffect, useState } from "react";
import { AuthContext } from "./context";
const webStorageKey = "oidc:auth";
type LoginParams = {
scope?: string;
redirectUri?: string;
};
export const useAuth = () => {
const {
accessToken,
setAccessToken,
idToken,
setIdToken,
setUser,
client,
user,
as,
} = useContext(AuthContext);
const [isHandlingRedirect, setHandlingRedirect] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const login = async (params?: LoginParams) => {
if (!as) {
return;
}
if (!client) {
throw new Error("Client is not available");
}
const scope = params?.scope || "openid profile email";
let redirectUri = params?.redirectUri;
if (
!redirectUri &&
Array.isArray(client.redirect_uris) &&
client.redirect_uris.length > 1
) {
redirectUri = client.redirect_uris[0]?.toString();
}
redirectUri = redirectUri || window.location.origin;
const code_challenge_method = "S256";
/**
* The following MUST be generated for every redirect to the authorization_endpoint. You must store
* the code_verifier and nonce in the end-user session such that it can be recovered as the user
* gets redirected from the authorization server back to your application.
*/
const code_verifier = oauth.generateRandomCodeVerifier();
const code_challenge =
await oauth.calculatePKCECodeChallenge(code_verifier);
let state: string | undefined;
let nonce: string | undefined;
const authorizationUrl = new URL(as.authorization_endpoint!);
authorizationUrl.searchParams.set("client_id", client.client_id);
authorizationUrl.searchParams.set("redirect_uri", redirectUri);
authorizationUrl.searchParams.set("response_type", "code");
authorizationUrl.searchParams.set("scope", scope);
authorizationUrl.searchParams.set("code_challenge", code_challenge);
authorizationUrl.searchParams.set(
"code_challenge_method",
code_challenge_method,
);
state = oauth.generateRandomState();
authorizationUrl.searchParams.set("state", state);
nonce = oauth.generateRandomNonce();
authorizationUrl.searchParams.set("nonce", nonce);
console.log("store code_verifier and nonce in the end-user session");
sessionStorage.setItem(
webStorageKey,
JSON.stringify({ code_verifier, state, nonce, redirectUri }),
);
console.log(
"Redirect to Authorization Server",
authorizationUrl.toString(),
);
window.location.assign(authorizationUrl.toString());
};
const handleLoginRedirect = async () => {
if (!as || !client || isHandlingRedirect) {
return;
}
setHandlingRedirect(true);
const storage = sessionStorage.getItem(webStorageKey);
if (!storage) {
console.error("No stored code_verifier and nonce found");
return;
}
sessionStorage.removeItem(webStorageKey);
const { code_verifier, state, nonce, redirectUri } = JSON.parse(storage);
let sub: string;
let accessToken: string;
// @ts-expect-error
const currentUrl: URL = new URL(window.location);
const params = oauth.validateAuthResponse(as, client, currentUrl, state);
if (oauth.isOAuth2Error(params)) {
console.error("Error Response", params);
setHandlingRedirect(false);
return;
}
const authorizationResponse = await oauth.authorizationCodeGrantRequest(
as,
client,
params,
redirectUri,
code_verifier,
);
let challenges: oauth.WWWAuthenticateChallenge[] | undefined;
if (
(challenges = oauth.parseWwwAuthenticateChallenges(authorizationResponse))
) {
for (const challenge of challenges) {
console.error("WWW-Authenticate Challenge", challenge);
}
setHandlingRedirect(false);
return;
}
const authorizationCodeResult =
await oauth.processAuthorizationCodeOpenIDResponse(
as,
client,
authorizationResponse,
nonce,
);
if (oauth.isOAuth2Error(authorizationCodeResult)) {
console.error("Error Response", authorizationCodeResult);
setHandlingRedirect(false);
return;
}
console.log("Access Token Response", authorizationCodeResult);
accessToken = authorizationCodeResult.access_token;
setAccessToken(accessToken);
setIdToken(authorizationCodeResult.id_token);
const claims = oauth.getValidatedIdTokenClaims(authorizationCodeResult);
console.log("ID Token Claims", claims);
sub = claims.sub;
// UserInfo Request
const response = await oauth.userInfoRequest(as, client, accessToken);
if ((challenges = oauth.parseWwwAuthenticateChallenges(response))) {
for (const challenge of challenges) {
console.error("WWW-Authenticate Challenge", challenge);
}
setHandlingRedirect(false);
return;
}
const user = await oauth.processUserInfoResponse(as, client, sub, response);
console.log("UserInfo Response", user);
setUser(user);
setHandlingRedirect(false);
window.history.replaceState(
{},
document.title,
redirectUri || window.location.origin,
);
};
const logout = () => {
if (!as || !idToken) {
return;
}
const endSessionUrl = new URL(as.end_session_endpoint!);
endSessionUrl.searchParams.set(
"post_logout_redirect_uri",
window.location.origin,
);
endSessionUrl.searchParams.set("id_token_hint", idToken);
console.log("Redirect to End Session Endpoint", endSessionUrl.toString());
// Clear state and localStorage
setAccessToken(undefined);
setIdToken(undefined);
setUser(undefined);
localStorage.removeItem("oidc:state");
window.location.assign(endSessionUrl.toString());
};
useEffect(() => {
const handleAuth = () => {
if (window.location.search.includes("code=")) {
void handleLoginRedirect()
.catch((error) => {
console.error("Failed to handle login redirect", error);
})
.finally(() => {
setIsLoading(false);
});
} else {
setIsLoading(false);
}
};
if (as && client) {
handleAuth();
}
}, [window.location.search, as, client]);
return {
user,
isAuthenticated: !!user,
isLoading,
accessToken,
login,
handleLoginRedirect,
logout,
};
};

View File

@@ -0,0 +1,7 @@
import type { ClassValue } from "clsx";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,163 @@
import { Key, LogOut, Shield, User } from "lucide-react";
import { useLocation } from "wouter";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useAuth } from "@/lib/auth/useAuth";
export function Dashboard() {
const { user, logout, isAuthenticated, isLoading, accessToken } = useAuth();
const [, setLocation] = useLocation();
// Show loading state while checking authentication
if (isLoading) {
return (
<div className="w-full max-w-4xl mx-auto px-4 py-8">
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="text-muted-foreground">Loading...</div>
</div>
</div>
</div>
);
}
// Redirect to home if not authenticated after loading
if (!isAuthenticated || !user) {
setLocation("/");
return null;
}
const getUserInitials = () => {
const name = (user.name as string) || (user.email as string) || "U";
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
return (
<div className="w-full max-w-4xl mx-auto px-4 py-8">
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-muted-foreground">Welcome back!</p>
</div>
<Button onClick={() => logout()} variant="outline">
<LogOut className="w-4 h-4 mr-2" />
Sign Out
</Button>
</div>
{/* User Profile Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="w-5 h-5" />
User Profile
</CardTitle>
<CardDescription>
Your account information from the OIDC provider
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-start gap-4">
<Avatar className="w-16 h-16">
<AvatarImage src={user.picture as string} />
<AvatarFallback>{getUserInitials()}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-3">
{!!user.name && (
<div>
<div className="text-sm font-medium text-muted-foreground">
Name
</div>
<div className="text-base">{String(user.name)}</div>
</div>
)}
{!!user.email && (
<div>
<div className="text-sm font-medium text-muted-foreground">
Email
</div>
<div className="text-base">{String(user.email)}</div>
</div>
)}
{!!user.sub && (
<div>
<div className="text-sm font-medium text-muted-foreground">
Subject (sub)
</div>
<div className="text-base font-mono text-sm">
{String(user.sub)}
</div>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Session Info Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
Session Information
</CardTitle>
<CardDescription>Your current OIDC session details</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">
Access Token
</div>
<div className="text-xs font-mono bg-muted p-3 rounded-md break-all">
{accessToken
? `${accessToken.slice(0, 50)}...`
: "Not available"}
</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">
Status
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-sm">Authenticated</span>
</div>
</div>
</CardContent>
</Card>
{/* All User Claims Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="w-5 h-5" />
All Claims
</CardTitle>
<CardDescription>
Complete user information returned by the UserInfo endpoint
</CardDescription>
</CardHeader>
<CardContent>
<pre className="text-xs font-mono bg-muted p-4 rounded-md overflow-x-auto">
{JSON.stringify(user, null, 2)}
</pre>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { useLocation } from "wouter";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/lib/auth/useAuth";
const features = [
{
name: "Authorization Code Flow with PKCE",
description: "Secure OAuth 2.0 authorization flow with PKCE extension",
},
{
name: "OpenID Connect",
description: "Full OIDC support with ID tokens and user info",
},
{
name: "Session Management",
description: "Secure session handling with proper state management",
},
{
name: "Single Sign-On",
description: "SSO capabilities with Better Auth OIDC provider",
},
];
export function Home() {
const { login, isAuthenticated, isLoading } = useAuth();
const [, setLocation] = useLocation();
if (isLoading) {
return (
<div className="min-h-[80vh] flex items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
);
}
return (
<div className="min-h-[80vh] flex items-center justify-center overflow-hidden no-visible-scrollbar px-6 md:px-0">
<main className="flex flex-col gap-4 items-center justify-center">
<div className="flex flex-col gap-1">
<h3 className="font-bold text-4xl text-black dark:text-white text-center">
Better Auth OIDC Client
</h3>
<p className="text-center break-words text-sm md:text-base text-muted-foreground">
Official demo showcasing{" "}
<a
href="https://better-auth.com"
target="_blank"
rel="noopener noreferrer"
className="italic underline"
>
Better Auth
</a>{" "}
as an OIDC provider with a client application.
</p>
</div>
<div className="md:w-10/12 w-full flex flex-col gap-4">
<div className="flex flex-col gap-3 pt-2 flex-wrap">
<div className="border-y py-2 border-dotted bg-secondary/60 opacity-80">
<div className="text-xs flex items-center gap-2 justify-center text-muted-foreground">
<span className="text-center">
This demo uses Better Auth as an OIDC provider and implements
a compliant OIDC client
</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
{features.map((feature) => (
<div
key={feature.name}
className="border rounded-md p-4 hover:border-foreground transition-colors"
>
<div className="font-medium mb-1">{feature.name}</div>
<div className="text-muted-foreground text-xs">
{feature.description}
</div>
</div>
))}
</div>
</div>
<div className="flex justify-center pt-4">
{isAuthenticated ? (
<Button
onClick={() => setLocation("/dashboard")}
size="lg"
className="gap-2 min-w-[200px]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M2 3h20v18H2zm18 16V7H4v12z"
></path>
</svg>
<span>Dashboard</span>
</Button>
) : (
<Button
onClick={() => login()}
size="lg"
className="gap-2 min-w-[200px]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M5 3H3v4h2V5h14v14H5v-2H3v4h18V3zm12 8h-2V9h-2V7h-2v2h2v2H3v2h10v2h-2v2h2v-2h2v-2h2z"
></path>
</svg>
<span>Sign In with Better Auth</span>
</Button>
)}
</div>
</div>
</main>
</div>
);
}

10
demo/oidc-client/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_OIDC_ISSUER: string;
readonly VITE_OIDC_CLIENT_ID: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,8 @@
import type { Config } from "tailwindcss";
const config = {
darkMode: "class",
content: ["./index.html", "./src/**/*.{ts,tsx}"],
} satisfies Config;
export default config;

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,14 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

698
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff