diff --git a/AGENTS.md b/AGENTS.md index 2d09ab6..b7932ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -276,7 +276,8 @@ export async function createServer(formData: FormData) { ### Using Generated API Client **⚠️ IMPORTANT:** -- Never edit files in `src/generated/*`** - they are auto-generated and will be overwritten + +- Never edit files in `src/generated/*`\*\* - they are auto-generated and will be overwritten - **Always use server actions** - Client components should not call the API directly - The API client is server-side only (no `NEXT_PUBLIC_` env vars needed) @@ -325,7 +326,13 @@ pnpm generate-client # Fetch swagger.json and regenerate - OIDC provider agnostic - Stateless JWT authentication -- Environment variables: `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` +- Environment variables: + - `OIDC_ISSUER_URL` - OIDC provider URL + - `OIDC_CLIENT_ID` - OAuth2 client ID + - `OIDC_CLIENT_SECRET` - OAuth2 client secret + - `NEXT_PUBLIC_OIDC_PROVIDER_ID` - Provider identifier (e.g., "okta", "oidc") - **Required**, must use `NEXT_PUBLIC_` prefix. Not sensitive data - it's just an identifier. + - `BETTER_AUTH_URL` - Application base URL + - `BETTER_AUTH_SECRET` - Secret for token encryption ### Development diff --git a/CLAUDE.md b/CLAUDE.md index 91fe034..f834b31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,6 +205,7 @@ pnpm generate-client:nofetch # Regenerate without fetching ### Mocking & Testing - **MSW Auto-Mocker** + - Auto-generates handlers from `swagger.json` and creates fixtures under `src/mocks/fixtures` on first run. - Strict validation with Ajv + ajv-formats; fixtures are type-checked against `@api/types.gen` by default. - Hand-written, non-schema mocks live in `src/mocks/customHandlers` and take precedence over schema-based mocks. @@ -274,7 +275,13 @@ git push origin v0.x.x ### Authentication Not Working - **Development**: Ensure OIDC mock is running (`pnpm oidc`) -- **Production**: Check environment variables (OIDC_ISSUER, CLIENT_ID, etc.) +- **Production**: Check environment variables: + - `OIDC_ISSUER_URL` - OIDC provider URL + - `OIDC_CLIENT_ID` - OAuth2 client ID + - `OIDC_CLIENT_SECRET` - OAuth2 client secret + - `NEXT_PUBLIC_OIDC_PROVIDER_ID` - Provider identifier (e.g., "okta", "oidc") - Required, must use `NEXT_PUBLIC_` prefix. Not sensitive data - it's just an identifier. + - `BETTER_AUTH_URL` - Application base URL + - `BETTER_AUTH_SECRET` - Secret for token encryption ### API Calls Failing diff --git a/biome.json b/biome.json index 9da0d94..89f9b4e 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.5/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.6/schema.json", "vcs": { "enabled": true, "clientKind": "git", @@ -11,10 +11,20 @@ }, "overrides": [ { - "includes": ["src/generated/core/bodySerializer.gen.ts"], + "includes": ["src/generated/**"], "linter": { "enabled": false } + }, + { + "includes": ["src/mocks/**"], + "linter": { + "rules": { + "suspicious": { + "noConsole": "off" + } + } + } } ], "formatter": { diff --git a/dev-auth/README.md b/dev-auth/README.md index d7875c8..2d25bff 100644 --- a/dev-auth/README.md +++ b/dev-auth/README.md @@ -40,6 +40,9 @@ The provider is pre-configured with: Replace this with a real OIDC provider (Okta, Keycloak, Auth0, etc.) by updating the environment variables in `.env.local`: -- `OIDC_ISSUER` -- `OIDC_CLIENT_ID` -- `OIDC_CLIENT_SECRET` +- `OIDC_ISSUER_URL` - OIDC provider URL +- `OIDC_CLIENT_ID` - OAuth2 client ID +- `OIDC_CLIENT_SECRET` - OAuth2 client secret +- `NEXT_PUBLIC_OIDC_PROVIDER_ID` - Provider identifier (e.g., "okta", "oidc") - **Required**, must use `NEXT_PUBLIC_` prefix. Not sensitive data - it's just an identifier. +- `BETTER_AUTH_URL` - Application base URL (e.g., `http://localhost:3000`) +- `BETTER_AUTH_SECRET` - Secret for token encryption diff --git a/dev-auth/oidc-provider.mjs b/dev-auth/oidc-provider.mjs index 49bbce7..d959695 100644 --- a/dev-auth/oidc-provider.mjs +++ b/dev-auth/oidc-provider.mjs @@ -32,6 +32,7 @@ const configuration = { "http://localhost:3001/api/auth/oauth2/callback/oidc", "http://localhost:3002/api/auth/oauth2/callback/oidc", "http://localhost:3003/api/auth/oauth2/callback/oidc", + "http://localhost:3000/api/auth/oauth2/callback/okta", ], response_types: ["code"], grant_types: ["authorization_code", "refresh_token"], diff --git a/package.json b/package.json index d6dd6bf..daa9f38 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "concurrently -n \"OIDC,Mock,Next\" -c \"blue,magenta,green\" \"pnpm oidc\" \"pnpm mock:server\" \"pnpm dev:next\"", "dev:next": "next dev", + "dev:mock-server": "concurrently -n \"Mock,Next\" -c \"magenta,green\" \"pnpm mock:server\" \"pnpm dev:next\"", "build": "next build", "start": "next start", "lint": "biome check", @@ -22,6 +23,7 @@ "dependencies": { "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-slot": "^1.2.4", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "better-auth": "1.4.0-beta.25", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d56daee..6162c10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@19.2.6)(react@19.2.0) ajv: specifier: ^8.17.1 version: 8.17.1 diff --git a/src/app/api/auth/refresh-token/route.ts b/src/app/api/auth/refresh-token/route.ts new file mode 100644 index 0000000..bfe8dac --- /dev/null +++ b/src/app/api/auth/refresh-token/route.ts @@ -0,0 +1,78 @@ +import { cookies } from "next/headers"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { refreshAccessToken } from "@/lib/auth/auth"; +import { BETTER_AUTH_SECRET, COOKIE_NAME } from "@/lib/auth/constants"; +import type { OidcTokenData } from "@/lib/auth/types"; +import { decrypt } from "@/lib/auth/utils"; + +/** + * API Route Handler to refresh OIDC access token. + * + * This Route Handler can modify cookies (unlike Server Actions during render). + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { userId } = body; + + if (!userId) { + return NextResponse.json({ error: "Missing userId" }, { status: 400 }); + } + + const cookieStore = await cookies(); + const encryptedCookie = cookieStore.get(COOKIE_NAME); + + if (!encryptedCookie?.value) { + return NextResponse.json({ error: "No token found" }, { status: 401 }); + } + + let tokenData: OidcTokenData; + try { + tokenData = await decrypt(encryptedCookie.value, BETTER_AUTH_SECRET); + } catch (error) { + console.error("[Refresh API] Token decryption failed:", error); + cookieStore.delete(COOKIE_NAME); + return NextResponse.json({ error: "Invalid token" }, { status: 401 }); + } + + if (tokenData.userId !== userId) { + console.error("[Refresh API] Token userId mismatch"); + cookieStore.delete(COOKIE_NAME); + return NextResponse.json({ error: "Invalid token" }, { status: 401 }); + } + + if (!tokenData.refreshToken) { + console.error("[Refresh API] No refresh token available"); + cookieStore.delete(COOKIE_NAME); + return NextResponse.json({ error: "No refresh token" }, { status: 401 }); + } + + // Call refreshAccessToken which will save the new token in the cookie + const refreshedData = await refreshAccessToken( + tokenData.refreshToken, + userId, + tokenData.refreshTokenExpiresAt, + ); + + if (!refreshedData) { + console.error("[Refresh API] Token refresh failed"); + cookieStore.delete(COOKIE_NAME); + return NextResponse.json( + { error: "[Refresh API] Refresh failed" }, + { status: 401 }, + ); + } + + return NextResponse.json({ + success: true, + accessToken: refreshedData.accessToken, + }); + } catch (error) { + console.error("[Refresh API] Error during token refresh:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/catalog/actions.ts b/src/app/catalog/actions.ts index 0e9e39a..315274b 100644 --- a/src/app/catalog/actions.ts +++ b/src/app/catalog/actions.ts @@ -1,28 +1,54 @@ "use server"; -import { getRegistryV01Servers } from "@/generated/sdk.gen"; +import type { V0ServerJson } from "@/generated/types.gen"; +import { getAuthenticatedClient } from "@/lib/api-client"; + +export async function getServers(): Promise { + const api = await getAuthenticatedClient(); + const resp = await api.getRegistryV01Servers({ + client: api.client, + }); + + if (resp.error) { + console.error("[catalog] Failed to fetch servers:", resp.error); + return []; + } + + if (!resp.data) { + return []; + } + + const data = resp.data; + const items = Array.isArray(data?.servers) ? data.servers : []; + + // Extract the server objects from the response + return items + .map((item) => item?.server) + .filter((server): server is V0ServerJson => server != null); +} export async function getServersSummary() { - try { - const resp = await getRegistryV01Servers(); - const data = resp.data; - const items = Array.isArray(data?.servers) ? data.servers : []; - - const titles = items - .map((it) => it?.server?.title ?? it?.server?.name) - .filter((t): t is string => typeof t === "string") - .slice(0, 5); - - const sample = items.slice(0, 5).map((it) => ({ - title: it?.server?.title ?? it?.server?.name ?? "Unknown", - name: it?.server?.name ?? "unknown", - version: it?.server?.version, - })); - - return { count: items.length, titles, sample }; - } catch (error) { - // Log the error for debugging - console.error("[catalog] Failed to fetch servers:", error); + const api = await getAuthenticatedClient(); + const resp = await api.getRegistryV01Servers({ client: api.client }); + + if (resp.error) { + console.error("[catalog] Failed to fetch servers:", resp.error); + return { count: 0, titles: [], sample: [] }; + } + + if (!resp.data) { return { count: 0, titles: [], sample: [] }; } + + const items = Array.isArray(resp.data?.servers) ? resp.data.servers : []; + + const sample = items.slice(0, 5).map((it) => ({ + title: it?.server?.title ?? it?.server?.name ?? "Unknown", + name: it?.server?.name ?? "unknown", + version: it?.server?.version, + })); + + const titles = sample.map((s) => s.title); + + return { count: items.length, titles, sample }; } diff --git a/src/app/catalogue/page.tsx b/src/app/catalogue/page.tsx deleted file mode 100644 index 18261bd..0000000 --- a/src/app/catalogue/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { signOut, useSession } from "@/lib/auth/auth-client"; - -export default function CataloguePage() { - const router = useRouter(); - const { data: session, isPending } = useSession(); - const [isSigningOut, setIsSigningOut] = useState(false); - - useEffect(() => { - if (!isPending && !session) { - router.push("/signin"); - } - }, [session, isPending, router]); - - const handleSignOut = async () => { - setIsSigningOut(true); - try { - await signOut(); - } catch (error) { - setIsSigningOut(false); - toast.error("Signout failed", { - description: - error instanceof Error - ? error.message - : "An unexpected error occurred", - }); - } - }; - - if (isPending || !session) { - return null; - } - - return ( -
-
-

- Hello World! 🎉 -

- -
-

- You are successfully authenticated! -

- -
-

- User Info: -

-

- Email: {session.user.email || "Not provided"} -

-

- User ID: {session.user.id} -

-
-
- - -
-
- ); -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6ec86b5..2b24d85 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,7 +2,6 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import { Toaster } from "sonner"; import { Navbar } from "@/components/navbar"; -import "@/lib/api-client"; import "./globals.css"; const inter = Inter({ diff --git a/src/components/server-card.test.tsx b/src/components/server-card.test.tsx new file mode 100644 index 0000000..cfced2d --- /dev/null +++ b/src/components/server-card.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { V0ServerJson } from "@/generated/types.gen"; +import { ServerCard } from "./server-card"; + +describe("ServerCard", () => { + const mockServer: V0ServerJson = { + name: "test-org/test-server", + description: "This is a test server for MCP", + repository: { + id: "test-org", + source: "github", + url: "https://github.com/test-org/test-server", + }, + }; + + it("renders server information with name, author and description", () => { + render(); + + expect(screen.getByText("test-org/test-server")).toBeTruthy(); + expect(screen.getByText("test-org")).toBeTruthy(); + expect(screen.getByText("This is a test server for MCP")).toBeTruthy(); + }); + + it("does not render author when repository data is missing", () => { + const minimalServer: V0ServerJson = { + name: undefined, + description: undefined, + repository: undefined, + }; + const { container } = render(); + + const authorElement = container.querySelector( + '[data-slot="card-description"]', + ); + + expect(authorElement?.textContent).toBeFalsy(); + expect(screen.getByText("No description available")).toBeTruthy(); + }); + + it("renders copy URL button", () => { + render(); + + const copyButton = screen.getByRole("button", { name: /copy url/i }); + expect(copyButton).toBeTruthy(); + }); +}); diff --git a/src/components/server-card.tsx b/src/components/server-card.tsx new file mode 100644 index 0000000..a51aae4 --- /dev/null +++ b/src/components/server-card.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Copy } from "lucide-react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type { V0ServerJson } from "@/generated/types.gen"; + +interface ServerCardProps { + server: V0ServerJson; + /** + * The MCP server URL + */ + url?: string; +} + +/** + * Server card component that displays MCP server information + * from the catalog, following the Figma design specifications. + */ +export function ServerCard({ server, url }: ServerCardProps) { + const { name, description, repository, remotes } = server; + const serverName = name; + const author = repository?.id; + const isVirtualMcp = remotes && remotes.length > 0; + + const handleCopyUrl = async () => { + if (!url) { + toast.error("URL not available"); + return; + } + + try { + await navigator.clipboard.writeText(url); + toast.success("URL copied to clipboard"); + } catch { + toast.error("Failed to copy URL"); + } + }; + + return ( + + + + {serverName} + + + {author} + {isVirtualMcp && ( + + Virtual MCP + + )} + + + +

+ {description || "No description available"} +

+ +
+
+ ); +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..ccfa4e7 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..cc1ff8a --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; diff --git a/src/components/user-menu/user-menu.test.tsx b/src/components/user-menu/user-menu.test.tsx index 36d7cef..7563873 100644 --- a/src/components/user-menu/user-menu.test.tsx +++ b/src/components/user-menu/user-menu.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { UserMenu } from "@/components/user-menu"; import { signOut } from "@/lib/auth/auth-client"; diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index e24e5fd..7262989 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,21 +1,80 @@ /** * API Client Configuration * - * Configures the hey-api generated client with the correct base URL. - * This must be imported before any API calls are made. + * Provides authenticated API client for server-side operations. * - * IMPORTANT: The API client should only be used server-side (in server actions, - * server components, or API routes). All client-side data fetching should go - * through server actions to avoid exposing the API URL to the browser. + * IMPORTANT: This file is SERVER-ONLY and uses Next.js server APIs. + * - Use in server actions (with "use server") + * - Use in server components (async functions) + * - DO NOT import or use in client components + * + * Client components should call server actions that use this client. + */ + +"use server"; + +import { headers as nextHeaders } from "next/headers"; +import { redirect } from "next/navigation"; +import { createClient, createConfig } from "@/generated/client"; +import * as apiServices from "@/generated/sdk.gen"; +import { auth } from "./auth/auth"; +import { getValidOidcToken } from "./auth/token"; + +// Validate required environment variables at module load time (fail-fast) +const API_BASE_URL = process.env.API_BASE_URL; + +/** + * Gets an authenticated API client with OIDC access token. + * Automatically refreshes the token if expired. + * + * Creates a new client instance per request to avoid race conditions + * when handling multiple concurrent requests with different tokens. + * + * Use this in server actions and server components to make authenticated API calls. + * + * @param accessToken - Optional access token to use instead of fetching from session + * @returns API services configured with authentication + * + * @example + * ```typescript + * export async function getServersSummary() { + * const api = await getAuthenticatedClient(); + * const resp = await api.getRegistryV01Servers(); + * return resp.data; + * } + * ``` */ +export async function getAuthenticatedClient(accessToken?: string) { + // If no token provided, get it from the session + if (accessToken === undefined) { + const session = await auth.api.getSession({ + headers: await nextHeaders(), + }); + + if (!session?.user?.id) { + console.log("[API Client] user not found, redirecting to signin"); + redirect("/signin"); + } + + const token = await getValidOidcToken(session.user.id); + + if (!token) { + console.log("[API Client] token not found, redirecting to signin"); + redirect("/signin"); + } -import { client } from "@/generated/client.gen"; + accessToken = token; + } -// Configure client with baseUrl for server-side operations -// In development: points to standalone MSW mock server -// In production: points to real backend API -client.setConfig({ - baseUrl: process.env.API_BASE_URL || "", -}); + // Create a new client instance per request to avoid race conditions + const authenticatedClient = createClient( + createConfig({ + baseUrl: API_BASE_URL, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }), + ); -export { client }; + return { ...apiServices, client: authenticatedClient }; +} diff --git a/src/lib/auth/README.md b/src/lib/auth/README.md new file mode 100644 index 0000000..0004af8 --- /dev/null +++ b/src/lib/auth/README.md @@ -0,0 +1,143 @@ +# Authentication & Token Management + +This folder contains the OIDC authentication logic with automatic access token refresh using Better Auth. + +## Architecture + +**Key Components:** + +- **Better Auth**: Handles OIDC flow and session management +- **Custom Token Storage**: Encrypted HTTP-only cookies for OIDC tokens +- **Automatic Refresh**: Transparent token refresh when expired + +## Authentication Flow + +```mermaid +sequenceDiagram + participant User + participant App + participant BetterAuth + participant Okta + participant API + + User->>App: Access /catalog + App->>BetterAuth: Check session + alt No session + BetterAuth-->>App: Redirect to /signin + User->>BetterAuth: Click Sign In + BetterAuth->>Okta: OIDC Auth Code Flow + Okta-->>BetterAuth: Access Token + Refresh Token + BetterAuth->>App: Save tokens in cookie (account.create.after) + App-->>User: Redirect to /catalog + else Session exists + BetterAuth-->>App: Session valid + end + + App->>API: API Request + Note over App,API: Token validation & refresh happens here +``` + +## Token Refresh Flow + +```mermaid +flowchart TD + A[API Request] --> B{Token in cookie?} + B -->|No| C[Redirect to /signin] + B -->|Yes| D{Token expired?} + D -->|No| E[Use existing token] + D -->|Yes| F[POST /api/auth/refresh-token] + F --> G[Endpoint reads refresh token from cookie] + G --> H[Call Okta token endpoint with refresh token] + H --> I{Okta response OK?} + I -->|Yes| J[Save new access token in cookie] + I -->|No| C + J --> E + E --> K[Make API call with valid token] +``` + +## Key Files + +### Core Authentication + +- **`auth.ts`** - Better Auth configuration, token storage, refresh logic +- **`token.ts`** - Token retrieval and refresh orchestration +- **`constants.ts`** - Token lifetimes and configuration + +### API Integration + +- **`/api/auth/refresh-token/route.ts`** - API endpoint for token refresh +- **`api-client.ts`** - Authenticated API client setup + +### Client-side + +- **`auth-client.ts`** - Client-side auth utilities +- **`auth-actions.ts`** - Server actions for auth operations + +## Token Lifetimes examples + +| Token | Lifetime | Storage | Managed By | +| -------------- | -------------- | ---------------------------- | ----------- | +| Access Token | 5 min (Okta) | HTTP-only cookie (encrypted) | Custom | +| Refresh Token | 30 days (Okta) | HTTP-only cookie (encrypted) | Custom | +| Session Cookie | 7 days | HTTP-only cookie | Better Auth | + +## How It Works + +### 1. Initial Login + +When a user signs in via OIDC: + +1. Better Auth redirects to Okta +2. Okta returns access token + refresh token +3. `account.create.after` hook saves tokens in encrypted cookie +4. Session established for 7 days + +### 2. Re-Login (after signout) + +When a user signs in again: + +1. Better Auth updates the account +2. `account.update.after` hook saves new tokens in encrypted cookie +3. Session refreshed for another 7 days + +### 3. API Requests + +When making an API call: + +1. `getValidOidcToken()` retrieves token from cookie +2. If expired, calls `/api/auth/refresh-token` endpoint +3. Endpoint uses refresh token to get new access token from Okta +4. New token saved in cookie and returned +5. API request proceeds with valid token + +### 4. Token Expiration + +- **Access token expires** → Automatic refresh using refresh token +- **Refresh token expires** → User must re-authenticate +- **Session expires** → User must re-authenticate + +## Environment Variables + +```bash +# OIDC Configuration +OIDC_ISSUER_URL=https://your-okta-domain.okta.com +OIDC_CLIENT_ID=your-client-id +OIDC_CLIENT_SECRET=your-client-secret +NEXT_PUBLIC_OIDC_PROVIDER_ID=okta + +# Better Auth +BETTER_AUTH_URL=http://localhost:3000 +BETTER_AUTH_SECRET=your-secret-key + +# API +API_BASE_URL=http://localhost:9090 +``` + +## Security Features + +- ✅ HTTP-only cookies (not accessible via JavaScript) +- ✅ Encrypted token storage (AES-256-GCM) +- ✅ Secure flag in production +- ✅ SameSite protection +- ✅ Automatic token rotation +- ✅ Server-side only API client diff --git a/src/lib/auth/__tests__/auth.test.ts b/src/lib/auth/__tests__/auth.test.ts index 735e314..2752be7 100644 --- a/src/lib/auth/__tests__/auth.test.ts +++ b/src/lib/auth/__tests__/auth.test.ts @@ -1,10 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OidcTokenData } from "../auth"; -import { - clearOidcProviderToken, - encrypt, - getOidcProviderAccessToken, -} from "../auth"; +import { clearOidcProviderToken, getOidcProviderAccessToken } from "../auth"; +import type { OidcTokenData } from "../types"; +import { encrypt } from "../utils"; // Mock jose library to avoid Uint8Array issues in jsdom vi.mock("jose", () => ({ @@ -52,7 +49,7 @@ vi.mock("better-auth/plugins", () => ({ genericOAuth: vi.fn(() => ({})), })); -describe("auth.ts", () => { +describe("auth", () => { let consoleErrorSpy: ReturnType; beforeEach(() => { @@ -83,20 +80,24 @@ describe("auth.ts", () => { expect(token).toBeNull(); }); - it("should return null and delete cookie when token is expired", async () => { + it("should return null when token is expired", async () => { const expiredTokenData: OidcTokenData = { accessToken: "expired-token", userId: "user-123", expiresAt: Date.now() - 1000, // Expired 1 second ago }; - const encryptedPayload = await encrypt(expiredTokenData); + const encryptedPayload = await encrypt( + expiredTokenData, + process.env.BETTER_AUTH_SECRET as string, + ); mockCookies.get.mockReturnValue({ value: encryptedPayload }); const token = await getOidcProviderAccessToken("user-123"); expect(token).toBeNull(); - expect(mockCookies.delete).toHaveBeenCalledWith("oidc_token"); + // Cookie deletion is now handled in the refresh API route, not here + expect(mockCookies.delete).not.toHaveBeenCalled(); }); it("should return null when token belongs to different user", async () => { @@ -106,7 +107,10 @@ describe("auth.ts", () => { expiresAt: Date.now() + 3600000, }; - const encryptedPayload = await encrypt(tokenData); + const encryptedPayload = await encrypt( + tokenData, + process.env.BETTER_AUTH_SECRET as string, + ); mockCookies.get.mockReturnValue({ value: encryptedPayload }); const token = await getOidcProviderAccessToken("user-123"); @@ -121,7 +125,10 @@ describe("auth.ts", () => { expiresAt: Date.now() + 3600000, // Valid for 1 hour }; - const encryptedPayload = await encrypt(tokenData); + const encryptedPayload = await encrypt( + tokenData, + process.env.BETTER_AUTH_SECRET as string, + ); mockCookies.get.mockReturnValue({ value: encryptedPayload }); const token = await getOidcProviderAccessToken("user-123"); @@ -129,17 +136,21 @@ describe("auth.ts", () => { expect(token).toBe("valid-access-token-123"); }); - it("should return null and delete cookie when token data is invalid", async () => { + it("should return null when token data is invalid", async () => { // Create invalid token data (missing required fields) const invalidData = { accessToken: "token" }; // Missing userId and expiresAt - const invalidPayload = await encrypt(invalidData as OidcTokenData); + const invalidPayload = await encrypt( + invalidData as OidcTokenData, + process.env.BETTER_AUTH_SECRET as string, + ); mockCookies.get.mockReturnValue({ value: invalidPayload }); const token = await getOidcProviderAccessToken("user-123"); expect(token).toBeNull(); - expect(mockCookies.delete).toHaveBeenCalledWith("oidc_token"); + // Cookie deletion is now handled in the refresh API route, not here + expect(mockCookies.delete).not.toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalled(); }); @@ -150,9 +161,10 @@ describe("auth.ts", () => { const token = await getOidcProviderAccessToken("user-123"); expect(token).toBeNull(); - expect(mockCookies.delete).toHaveBeenCalledWith("oidc_token"); + // Cookie deletion is now handled in the refresh API route, not here + expect(mockCookies.delete).not.toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith( - "[Auth] Token decryption failed - possible tampering or invalid format:", + "[Auth] Token decryption failed:", expect.any(Error), ); }); @@ -194,14 +206,4 @@ describe("auth.ts", () => { expect(dataWithoutRefresh.refreshToken).toBeUndefined(); }); }); - - describe("Token Expiration Constants", () => { - it("should have correct time constants", () => { - const TOKEN_ONE_HOUR_MS = 60 * 60 * 1000; - const TOKEN_SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60; - - expect(TOKEN_ONE_HOUR_MS).toBe(3600000); - expect(TOKEN_SEVEN_DAYS_SECONDS).toBe(604800); - }); - }); }); diff --git a/src/lib/auth/__tests__/token.test.ts b/src/lib/auth/__tests__/token.test.ts new file mode 100644 index 0000000..6e750b1 --- /dev/null +++ b/src/lib/auth/__tests__/token.test.ts @@ -0,0 +1,337 @@ +import { HttpResponse, http } from "msw"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { server } from "@/mocks/node"; +import { getValidOidcToken } from "../token"; +import type { OidcTokenData } from "../types"; +import { encrypt } from "../utils"; + +const REFRESH_API_URL = "http://localhost:3000/api/auth/refresh-token"; + +// Mock jose library +vi.mock("jose", () => ({ + CompactEncrypt: class CompactEncrypt { + constructor(private plaintext: Uint8Array) {} + setProtectedHeader() { + return this; + } + async encrypt() { + return `mock-jwe-${Buffer.from(this.plaintext).toString("base64")}`; + } + }, + compactDecrypt: vi.fn().mockImplementation(async (jwe: string) => { + const base64 = jwe.replace("mock-jwe-", ""); + const plaintext = Buffer.from(base64, "base64"); + return { plaintext }; + }), + errors: { + JWEDecryptionFailed: class JWEDecryptionFailed extends Error {}, + JWEInvalid: class JWEInvalid extends Error {}, + }, +})); + +// Mock next/headers +const mockCookies = vi.hoisted(() => ({ + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), +})); + +const mockNextHeaders = vi.hoisted(() => vi.fn()); + +vi.mock("next/headers", () => ({ + cookies: vi.fn(() => mockCookies), + headers: mockNextHeaders, +})); + +// Mock better-auth +vi.mock("better-auth", () => ({ + betterAuth: vi.fn(() => ({ + api: { + getSession: vi.fn(), + }, + })), +})); + +vi.mock("better-auth/plugins", () => ({ + genericOAuth: vi.fn(() => ({})), +})); + +describe("token", () => { + let consoleLogSpy: ReturnType; + let consoleWarnSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + mockNextHeaders.mockResolvedValue({ + get: vi.fn().mockReturnValue("cookie=value"), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getValidOidcToken", () => { + it("should return existing token if still valid", async () => { + const userId = "user-123"; + const tokenData: OidcTokenData = { + accessToken: "valid-access-token", + userId, + expiresAt: Date.now() + 3600000, // Valid for 1 hour + }; + + const encryptedPayload = await encrypt( + tokenData, + process.env.BETTER_AUTH_SECRET as string, + ); + mockCookies.get.mockReturnValue({ value: encryptedPayload }); + + const token = await getValidOidcToken(userId); + + expect(token).toBe("valid-access-token"); + // Should not attempt refresh + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + + it("should refresh token if expired", async () => { + const userId = "user-123"; + const expiredTokenData: OidcTokenData = { + accessToken: "expired-access-token", + userId, + expiresAt: Date.now() - 1000, // Expired 1 second ago + }; + + const encryptedPayload = await encrypt( + expiredTokenData, + process.env.BETTER_AUTH_SECRET as string, + ); + mockCookies.get.mockReturnValue({ value: encryptedPayload }); + + // Mock refresh API + server.use( + http.post(REFRESH_API_URL, () => { + return HttpResponse.json({ + success: true, + accessToken: "new-access-token", + }); + }), + ); + + const token = await getValidOidcToken(userId); + + expect(token).toBe("new-access-token"); + }); + + it("should return null if token not found and refresh fails", async () => { + const userId = "user-123"; + mockCookies.get.mockReturnValue(undefined); + + // Mock refresh API failure + server.use( + http.post(REFRESH_API_URL, () => { + return new HttpResponse(null, { status: 401 }); + }), + ); + + const token = await getValidOidcToken(userId); + + expect(token).toBeNull(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + "[Token] Refresh failed:", + 401, + ); + }); + + it("should return null if refresh API returns invalid response", async () => { + const userId = "user-123"; + const expiredTokenData: OidcTokenData = { + accessToken: "expired-token", + userId, + expiresAt: Date.now() - 1000, + }; + + const encryptedPayload = await encrypt( + expiredTokenData, + process.env.BETTER_AUTH_SECRET as string, + ); + mockCookies.get.mockReturnValue({ value: encryptedPayload }); + + // Mock refresh API with invalid response + server.use( + http.post(REFRESH_API_URL, () => { + return HttpResponse.json({ + success: false, // No accessToken + }); + }), + ); + + const token = await getValidOidcToken(userId); + + expect(token).toBeNull(); + }); + + it("should handle network errors during refresh", async () => { + const userId = "user-123"; + const expiredTokenData: OidcTokenData = { + accessToken: "expired-token", + userId, + expiresAt: Date.now() - 1000, + }; + + const encryptedPayload = await encrypt( + expiredTokenData, + process.env.BETTER_AUTH_SECRET as string, + ); + mockCookies.get.mockReturnValue({ value: encryptedPayload }); + + // Mock network error + server.use( + http.post(REFRESH_API_URL, () => { + return HttpResponse.error(); + }), + ); + + const token = await getValidOidcToken(userId); + + expect(token).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Token] Refresh error:", + expect.any(Error), + ); + }); + }); + + describe("refreshOidcAccessToken (internal)", () => { + it("should successfully refresh token via API", async () => { + const userId = "user-123"; + + // Mock refresh API success + server.use( + http.post(REFRESH_API_URL, () => { + return HttpResponse.json({ + success: true, + accessToken: "new-refreshed-token", + }); + }), + ); + + const token = await getValidOidcToken(userId); + + expect(token).toBe("new-refreshed-token"); + }); + + it("should handle 401 errors from refresh API", async () => { + const userId = "user-123"; + mockCookies.get.mockReturnValue(undefined); + + server.use( + http.post(REFRESH_API_URL, () => { + return new HttpResponse(null, { status: 401 }); + }), + ); + + const token = await getValidOidcToken(userId); + + expect(token).toBeNull(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + "[Token] Refresh failed:", + 401, + ); + }); + + it("should handle 500 errors from refresh API", async () => { + const userId = "user-123"; + mockCookies.get.mockReturnValue(undefined); + + server.use( + http.post(REFRESH_API_URL, () => { + return new HttpResponse(null, { status: 500 }); + }), + ); + + const token = await getValidOidcToken(userId); + + expect(token).toBeNull(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + "[Token] Refresh failed:", + 500, + ); + }); + + it("should pass cookies to refresh API", async () => { + const userId = "user-123"; + mockCookies.get.mockReturnValue(undefined); + + let receivedHeaders: Headers | undefined; + + server.use( + http.post(REFRESH_API_URL, ({ request }) => { + receivedHeaders = request.headers; + return HttpResponse.json({ + success: true, + accessToken: "new-token", + }); + }), + ); + + await getValidOidcToken(userId); + + expect(receivedHeaders?.get("cookie")).toBe("cookie=value"); + }); + }); + + describe("Token Refresh Integration", () => { + it("should handle complete refresh flow end-to-end", async () => { + const userId = "user-123"; + const expiredTokenData: OidcTokenData = { + accessToken: "expired-token", + refreshToken: "valid-refresh-token", + userId, + expiresAt: Date.now() - 1000, + refreshTokenExpiresAt: Date.now() + 86400000, + }; + + const encryptedPayload = await encrypt( + expiredTokenData, + process.env.BETTER_AUTH_SECRET as string, + ); + mockCookies.get.mockReturnValue({ value: encryptedPayload }); + + // Mock refresh API + server.use( + http.post(REFRESH_API_URL, () => { + return HttpResponse.json({ + success: true, + accessToken: "brand-new-token", + }); + }), + ); + + const token = await getValidOidcToken(userId); + + expect(token).toBe("brand-new-token"); + }); + + it("should return null for multiple failed refresh attempts", async () => { + const userId = "user-123"; + mockCookies.get.mockReturnValue(undefined); + + server.use( + http.post(REFRESH_API_URL, () => { + return new HttpResponse(null, { status: 401 }); + }), + ); + + const token1 = await getValidOidcToken(userId); + const token2 = await getValidOidcToken(userId); + + expect(token1).toBeNull(); + expect(token2).toBeNull(); + expect(consoleWarnSpy).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index adb8076..9eda8a0 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -1,124 +1,172 @@ -import { createHash } from "node:crypto"; -import type { BetterAuthOptions } from "better-auth"; +import type { Auth, BetterAuthOptions } from "better-auth"; import { betterAuth } from "better-auth"; import { genericOAuth } from "better-auth/plugins"; -import * as jose from "jose"; import { cookies } from "next/headers"; -import { OIDC_PROVIDER_ID } from "./constants"; - -// Environment configuration -const OIDC_ISSUER_URL = process.env.OIDC_ISSUER_URL || ""; -const BASE_URL = process.env.BETTER_AUTH_URL || "http://localhost:3000"; -const IS_PRODUCTION = process.env.NODE_ENV === "production"; +import { + BASE_URL, + BETTER_AUTH_SECRET, + COOKIE_NAME, + IS_PRODUCTION, + OIDC_CLIENT_ID, + OIDC_CLIENT_SECRET, + OIDC_ISSUER_URL, + OIDC_PROVIDER_ID, + TOKEN_SEVEN_DAYS_SECONDS, + TRUSTED_ORIGINS, +} from "./constants"; +import type { OIDCDiscovery, OidcTokenData, TokenResponse } from "./types"; +import { decrypt, encrypt, saveAccountToken } from "./utils"; /** - * Gets the encryption secret for JWE. - * Uses SHA-256 to derive exactly 32 bytes (256 bits) from BETTER_AUTH_SECRET, - * ensuring compatibility with AES-256-GCM regardless of secret length. - * Lazy initialization avoids build-time errors when env vars are not set. + * Cached token endpoint to avoid repeated discovery calls. */ -function getSecret(): Uint8Array { - const secret = process.env.BETTER_AUTH_SECRET; - if (!secret) { - throw new Error( - "BETTER_AUTH_SECRET environment variable is required for encryption", - ); - } - // Hash the secret to get exactly 32 bytes for AES-256-GCM - return new Uint8Array(createHash("sha256").update(secret).digest()); -} - -// Token expiration constants -const TOKEN_ONE_HOUR_MS = 60 * 60 * 1000; // 1 hour in ms -const TOKEN_SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60; // 7 days in seconds - -// Cookie configuration -const COOKIE_NAME = "oidc_token" as const; - -const trustedOrigins = process.env.TRUSTED_ORIGINS - ? process.env.TRUSTED_ORIGINS.split(",").map((s) => s.trim()) - : [BASE_URL, "http://localhost:3002", "http://localhost:3003"]; - -if (!trustedOrigins.includes(BASE_URL)) { - trustedOrigins.push(BASE_URL); -} +let cachedTokenEndpoint: string | null = null; /** - * Represents the data stored in the encrypted OIDC token cookie. + * Saves encrypted token data in HTTP-only cookie. + * Exported for use by saveAccountToken in utils. */ -export interface OidcTokenData { - accessToken: string; - refreshToken?: string; - expiresAt: number; - userId: string; +export async function saveTokenCookie(tokenData: OidcTokenData): Promise { + const encrypted = await encrypt(tokenData, BETTER_AUTH_SECRET); + const cookieStore = await cookies(); + + cookieStore.set(COOKIE_NAME, encrypted, { + httpOnly: true, + secure: IS_PRODUCTION, + sameSite: "lax", + maxAge: TOKEN_SEVEN_DAYS_SECONDS, + path: "/", + }); } /** - * Type guard to validate OidcTokenData structure at runtime. + * Discovers and caches the token endpoint from OIDC provider. */ -function isOidcTokenData(data: unknown): data is OidcTokenData { - if (typeof data !== "object" || data === null) { - return false; +async function getTokenEndpoint(): Promise { + if (cachedTokenEndpoint) { + return cachedTokenEndpoint; } - const obj = data as Record; + try { + const discoveryUrl = `${OIDC_ISSUER_URL}/.well-known/openid-configuration`; + const response = await fetch(discoveryUrl); - return ( - typeof obj.accessToken === "string" && - typeof obj.expiresAt === "number" && - typeof obj.userId === "string" && - (obj.refreshToken === undefined || typeof obj.refreshToken === "string") - ); -} + if (!response.ok) { + console.error( + "[Auth] Failed to fetch OIDC discovery document:", + response.status, + ); + return null; + } -/** - * Encrypts token data using JWE (JSON Web Encryption). - * Uses AES-256-GCM with direct key agreement (alg: 'dir'). - * Exported for testing purposes. - */ -export async function encrypt(data: OidcTokenData): Promise { - const plaintext = new TextEncoder().encode(JSON.stringify(data)); - return await new jose.CompactEncrypt(plaintext) - .setProtectedHeader({ alg: "dir", enc: "A256GCM" }) - .encrypt(getSecret()); + const discovery = (await response.json()) as OIDCDiscovery; + cachedTokenEndpoint = discovery.token_endpoint; + + return cachedTokenEndpoint; + } catch (error) { + console.error("[Auth] Error fetching OIDC discovery document:", error); + return null; + } } /** - * Decrypts JWE token and returns parsed token data. - * Validates data structure after decryption. - * Exported for testing purposes. + * Attempts to refresh the access token using the refresh token. + * Returns new token data if successful, null otherwise. */ -export async function decrypt(jwe: string): Promise { +export async function refreshAccessToken( + refreshToken: string, + userId: string, + refreshTokenExpiresAt?: number, +): Promise { + if (!refreshToken || !userId) { + console.error("[Auth] Missing refresh token or userId"); + return null; + } + + // Check if refresh token is expired before attempting to refresh + if (refreshTokenExpiresAt && refreshTokenExpiresAt <= Date.now()) { + console.error("[Auth] Refresh token expired"); + return null; + } + try { - const { plaintext } = await jose.compactDecrypt(jwe, getSecret()); - const data = JSON.parse(new TextDecoder().decode(plaintext)); + const tokenEndpoint = await getTokenEndpoint(); - if (!isOidcTokenData(data)) { - throw new Error("Invalid token data structure"); + if (!tokenEndpoint) { + console.error("[Auth] Token endpoint not available"); + return null; } - return data; - } catch (error) { - if (error instanceof jose.errors.JWEDecryptionFailed) { - throw new Error("Token decryption failed - possible tampering"); + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: OIDC_CLIENT_ID, + client_secret: OIDC_CLIENT_SECRET, + }); + + const response = await fetch(tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + + if (!response.ok) { + console.error( + "[Auth] Token refresh failed:", + response.status, + response.statusText, + ); + return null; } - if (error instanceof jose.errors.JWEInvalid) { - throw new Error("Invalid JWE format"); + + const tokenResponse = (await response.json()) as TokenResponse; + + const expiresAt = Date.now() + tokenResponse.expires_in * 1000; + const refreshTokenExpiresAt = tokenResponse.refresh_expires_in + ? Date.now() + tokenResponse.refresh_expires_in * 1000 + : undefined; + + const newRefreshToken = tokenResponse.refresh_token || refreshToken; + if (!tokenResponse.refresh_token) { + console.warn( + "[Auth] Provider did not return new refresh token, reusing existing", + ); } - // Wrap unexpected errors to avoid exposing internal details - const message = error instanceof Error ? error.message : "Unknown error"; - throw new Error(`Token decryption error: ${message}`); + + const newTokenData: OidcTokenData = { + accessToken: tokenResponse.access_token, + refreshToken: newRefreshToken, + expiresAt, + refreshTokenExpiresAt, + userId, + }; + + // Save the new token data in the cookie + await saveTokenCookie(newTokenData); + + console.log("[Auth] Token refreshed successfully"); + return newTokenData; + } catch (error) { + console.error("[Auth] Token refresh error:", error); + return null; } } -export const auth = betterAuth({ - secret: process.env.BETTER_AUTH_SECRET || "build-time-better-auth-secret", +export const auth: Auth = betterAuth({ + secret: BETTER_AUTH_SECRET, baseURL: BASE_URL, - trustedOrigins, + trustedOrigins: TRUSTED_ORIGINS, session: { cookieCache: { enabled: true, + maxAge: TOKEN_SEVEN_DAYS_SECONDS, // 7 days - match session duration! }, + // Session duration should match or exceed refresh token lifetime + // This prevents Better Auth from logging out users before OIDC token refresh + expiresIn: TOKEN_SEVEN_DAYS_SECONDS, // 7 days in seconds + updateAge: 60 * 60 * 24, // Update session every 24 hours (in seconds) }, plugins: [ genericOAuth({ @@ -129,50 +177,24 @@ export const auth = betterAuth({ redirectURI: `${BASE_URL}/api/auth/oauth2/callback/${OIDC_PROVIDER_ID}`, clientId: process.env.OIDC_CLIENT_ID || "", clientSecret: process.env.OIDC_CLIENT_SECRET || "", - scopes: ["openid", "email", "profile"], + scopes: ["openid", "email", "profile", "offline_access"], pkce: true, }, ], }), ], - // Use databaseHooks to save tokens in HTTP-only cookie after account creation + // Use databaseHooks to save tokens in HTTP-only cookie after account creation/update databaseHooks: { account: { create: { - after: async (account: { - accessToken?: string; - refreshToken?: string; - accessTokenExpiresAt?: Date | string; - userId: string; - }) => { - if (account.accessToken && account.userId) { - const expiresAt = account.accessTokenExpiresAt - ? new Date(account.accessTokenExpiresAt).getTime() - : Date.now() + TOKEN_ONE_HOUR_MS; - - const tokenData: OidcTokenData = { - accessToken: account.accessToken, - refreshToken: account.refreshToken || undefined, - expiresAt, - userId: account.userId, - }; - - const encrypted = await encrypt(tokenData); - const cookieStore = await cookies(); - - cookieStore.set(COOKIE_NAME, encrypted, { - httpOnly: true, - secure: IS_PRODUCTION, - sameSite: "lax", - maxAge: TOKEN_SEVEN_DAYS_SECONDS, - path: "/", - }); - } - }, + after: saveAccountToken, + }, + update: { + after: saveAccountToken, }, }, }, -} as BetterAuthOptions); +}); /** * Retrieves the OIDC provider access token from HTTP-only cookie. @@ -191,31 +213,25 @@ export async function getOidcProviderAccessToken( let tokenData: OidcTokenData; try { - tokenData = await decrypt(encryptedCookie.value); + tokenData = await decrypt(encryptedCookie.value, BETTER_AUTH_SECRET); } catch (error) { - // Decryption failure indicates tampering, corruption, or wrong secret - console.error( - "[Auth] Token decryption failed - possible tampering or invalid format:", - error, - ); - cookieStore.delete(COOKIE_NAME); + console.error("[Auth] Token decryption failed:", error); return null; } if (tokenData.userId !== userId) { - cookieStore.delete(COOKIE_NAME); + console.error("[Auth] Token userId mismatch"); return null; } const now = Date.now(); + if (tokenData.expiresAt <= now) { - cookieStore.delete(COOKIE_NAME); return null; } return tokenData.accessToken; } catch (error) { - // Unexpected error (e.g., cookie operations failure) console.error("[Auth] Unexpected error reading OIDC token:", error); return null; } diff --git a/src/lib/auth/constants.ts b/src/lib/auth/constants.ts index f6163af..bb8356d 100644 --- a/src/lib/auth/constants.ts +++ b/src/lib/auth/constants.ts @@ -1,6 +1,37 @@ /** - * OIDC Provider ID used throughout the application. - * This value must match the providerId configured in Better Auth - * and the callback URL pattern: /api/auth/oauth2/callback/{OIDC_PROVIDER_ID} + * Authentication constants and configuration. */ -export const OIDC_PROVIDER_ID = "oidc" as const; + +// Environment configuration +/** + * OIDC Provider ID (e.g., "oidc", "okta") + * Must use NEXT_PUBLIC_ prefix as it's needed both server-side (auth.ts) and client-side (signin page). + * Note: This exposes the provider name to the client, which is generally acceptable + * but does reveal infrastructure details. + */ +export const OIDC_PROVIDER_ID = + process.env.NEXT_PUBLIC_OIDC_PROVIDER_ID || "oidc"; +export const OIDC_ISSUER_URL = process.env.OIDC_ISSUER_URL || ""; +export const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || ""; +export const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || ""; +export const BASE_URL = process.env.BETTER_AUTH_URL || "http://localhost:3000"; +export const IS_PRODUCTION = process.env.NODE_ENV === "production"; +export const BETTER_AUTH_SECRET = + process.env.BETTER_AUTH_SECRET || "build-time-better-auth-secret"; + +// Token expiration constants (in milliseconds and seconds) +export const TOKEN_ONE_HOUR_MS = 60 * 60 * 1000; // 3,600,000 ms (1 hour) +export const TOKEN_SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60; // 604,800 seconds (7 days) + +// Cookie configuration +export const COOKIE_NAME = "oidc_token" as const; + +// Trusted origins for Better Auth +const trustedOriginsFromEnv = process.env.TRUSTED_ORIGINS + ? process.env.TRUSTED_ORIGINS.split(",").map((s) => s.trim()) + : [BASE_URL, "http://localhost:3002", "http://localhost:3003"]; + +// Ensure BASE_URL is always included in trusted origins +export const TRUSTED_ORIGINS = trustedOriginsFromEnv.includes(BASE_URL) + ? trustedOriginsFromEnv + : [...trustedOriginsFromEnv, BASE_URL]; diff --git a/src/lib/auth/token.ts b/src/lib/auth/token.ts new file mode 100644 index 0000000..d7a8963 --- /dev/null +++ b/src/lib/auth/token.ts @@ -0,0 +1,66 @@ +/** + * OIDC Token Management + * + * Handles retrieval and refresh of OIDC access tokens. + */ + +"use server"; + +import { headers as nextHeaders } from "next/headers"; +import { getOidcProviderAccessToken } from "./auth"; + +/** + * Refreshes an expired OIDC access token by requesting a new one from the server. + * Returns the new access token if successful, null otherwise. + */ +async function refreshOidcAccessToken(userId: string): Promise { + try { + const baseUrl = process.env.BETTER_AUTH_URL || "http://localhost:3000"; + const headers = await nextHeaders(); + const cookieHeader = headers.get("cookie") || ""; + + const response = await fetch(`${baseUrl}/api/auth/refresh-token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: cookieHeader, + }, + body: JSON.stringify({ userId }), + cache: "no-store", + }); + + if (!response.ok) { + console.warn("[Token] Refresh failed:", response.status); + return null; + } + + const data = await response.json(); + + if (data.success && data.accessToken) { + return data.accessToken; + } + + return null; + } catch (error) { + console.error("[Token] Refresh error:", error); + return null; + } +} + +/** + * Retrieves a valid OIDC access token for the current user. + * Automatically attempts to refresh if the token is expired. + * Returns null if unable to obtain a valid token. + */ +export async function getValidOidcToken( + userId: string, +): Promise { + // Try to get existing token + const existingToken = await getOidcProviderAccessToken(userId); + if (existingToken) { + return existingToken; + } + + // Token expired or not found, try to refresh + return refreshOidcAccessToken(userId); +} diff --git a/src/lib/auth/types.ts b/src/lib/auth/types.ts new file mode 100644 index 0000000..12263d8 --- /dev/null +++ b/src/lib/auth/types.ts @@ -0,0 +1,35 @@ +/** + * Authentication types and interfaces for OIDC token management. + */ + +/** + * Represents the data stored in the encrypted OIDC token cookie. + */ +export interface OidcTokenData { + accessToken: string; + refreshToken?: string; + expiresAt: number; + refreshTokenExpiresAt?: number; + userId: string; +} + +/** + * OIDC Discovery Document structure. + * Retrieved from /.well-known/openid-configuration endpoint. + */ +export interface OIDCDiscovery { + token_endpoint: string; + [key: string]: unknown; +} + +/** + * OIDC Token Response from the provider's token endpoint. + * Returned when exchanging authorization code or refreshing tokens. + */ +export interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + refresh_expires_in?: number; + token_type: string; +} diff --git a/src/lib/auth/utils.ts b/src/lib/auth/utils.ts new file mode 100644 index 0000000..fdaa521 --- /dev/null +++ b/src/lib/auth/utils.ts @@ -0,0 +1,138 @@ +/** + * Utility functions for authentication, token validation, and encryption. + */ + +import { createHash } from "node:crypto"; +import * as jose from "jose"; +import { TOKEN_ONE_HOUR_MS } from "./constants"; +import type { OidcTokenData } from "./types"; + +/** + * Derives encryption key from secret. + * Uses SHA-256 to derive exactly 32 bytes (256 bits) from the provided secret, + * ensuring compatibility with AES-256-GCM regardless of secret length. + */ +function getSecret(secret: string): Uint8Array { + // Hash the secret to get exactly 32 bytes for AES-256-GCM + return new Uint8Array(createHash("sha256").update(secret).digest()); +} + +/** + * Encrypts token data using JWE (JSON Web Encryption). + * Uses AES-256-GCM with direct key agreement (alg: 'dir'). + * Exported for testing purposes. + */ +export async function encrypt( + data: OidcTokenData, + secret: string, +): Promise { + const key = getSecret(secret); + const plaintext = new TextEncoder().encode(JSON.stringify(data)); + return await new jose.CompactEncrypt(plaintext) + .setProtectedHeader({ alg: "dir", enc: "A256GCM" }) + .encrypt(key); +} + +/** + * Decrypts JWE token and returns parsed token data. + * Validates data structure after decryption. + * Exported for testing purposes. + */ +export async function decrypt( + jwe: string, + secret: string, +): Promise { + try { + const key = getSecret(secret); + const { plaintext } = await jose.compactDecrypt(jwe, key); + const data = JSON.parse(new TextDecoder().decode(plaintext)); + + if (!isOidcTokenData(data)) { + throw new Error("Invalid token data structure"); + } + + return data; + } catch (error) { + if (error instanceof jose.errors.JWEDecryptionFailed) { + throw new Error("Token decryption failed - possible tampering"); + } + if (error instanceof jose.errors.JWEInvalid) { + throw new Error("Invalid JWE format"); + } + // Wrap unexpected errors to avoid exposing internal details + const message = error instanceof Error ? error.message : "Unknown error"; + throw new Error(`Token decryption error: ${message}`); + } +} + +/** + * Type guard to validate OidcTokenData structure at runtime. + * Used after decrypting token data from cookie to ensure data integrity. + */ +export function isOidcTokenData(data: unknown): data is OidcTokenData { + if (typeof data !== "object" || data === null) { + return false; + } + + const obj = data as Record; + + return ( + typeof obj.accessToken === "string" && + typeof obj.expiresAt === "number" && + typeof obj.userId === "string" && + (obj.refreshToken === undefined || typeof obj.refreshToken === "string") && + (obj.refreshTokenExpiresAt === undefined || + typeof obj.refreshTokenExpiresAt === "number") + ); +} + +/** + * Saves OIDC tokens from account creation or update into HTTP-only cookie. + * Used by Better Auth database hooks for both initial login and re-login. + * + * @param account - Account data from Better Auth containing OIDC tokens + */ +export async function saveAccountToken(account: { + accessToken?: string | null; + refreshToken?: string | null; + accessTokenExpiresAt?: Date | string | null; + refreshTokenExpiresAt?: Date | string | null; + userId: string; +}) { + if (account.accessToken && account.userId) { + const expiresAt = account.accessTokenExpiresAt + ? new Date(account.accessTokenExpiresAt).getTime() + : Date.now() + TOKEN_ONE_HOUR_MS; + + const refreshTokenExpiresAt = account.refreshTokenExpiresAt + ? new Date(account.refreshTokenExpiresAt).getTime() + : undefined; + + const tokenData: OidcTokenData = { + accessToken: account.accessToken, + refreshToken: account.refreshToken || undefined, + expiresAt, + refreshTokenExpiresAt, + userId: account.userId, + }; + + console.log("[Save Token] Token data to save:", { + hasAccessToken: !!tokenData.accessToken, + hasRefreshToken: !!tokenData.refreshToken, + expiresAt: new Date(tokenData.expiresAt).toISOString(), + refreshTokenExpiresAt: tokenData.refreshTokenExpiresAt + ? new Date(tokenData.refreshTokenExpiresAt).toISOString() + : "none", + }); + + // Dynamic import to avoid circular dependency + const { saveTokenCookie } = await import("./auth"); + await saveTokenCookie(tokenData); + + console.log("[Save Token] Token cookie saved successfully"); + } else { + console.warn( + "[Save Token] Missing accessToken or userId, not saving token", + ); + } +} diff --git a/src/mocks/fixtures/registry_v0_1_servers/get.ts b/src/mocks/fixtures/registry_v0_1_servers/get.ts index 6d89f4e..cb2d301 100644 --- a/src/mocks/fixtures/registry_v0_1_servers/get.ts +++ b/src/mocks/fixtures/registry_v0_1_servers/get.ts @@ -2,239 +2,71 @@ export default { servers: [ { server: { - title: "consequat", - name: "4ixxisU8/yQXVlprhhN", - version: "do voluptate esse veniam", - description: "id aliquip fugiat quis", - _meta: { - ullamco_566: false, - "io.modelcontextprotocol.registry/publisher-provided": { - est_84: {}, - sed3: {}, - voluptate_: {}, - }, - }, - icons: [ - { - sizes: ["nostrud"], - mimeType: "laboris", - src: "http://AZUjhw.iemoiU+bM+FdKbi8L+QcXuAdmepZez7WVN,gwb6k.fLABJ", - }, - ], - packages: [ - { - version: "velit occaecat", - environmentVariables: [ - { - name: "nisi labore anim laborum", - description: "occaecat nostrud ipsum sit non", - choices: ["sunt reprehenderit"], - default: "cillum", - format: "reprehenderit Ut sit", - }, - ], - packageArguments: [ - { - name: "incididunt dolore aute", - description: "reprehenderit veniam est labore", - choices: ["dolor Lorem"], - default: "veniam in elit", - format: "aliqua aute", - }, - ], - runtimeArguments: [ - { - name: "pariatur laboris", - description: "culpa elit do", - choices: ["dolore laborum cupidatat velit sint"], - default: "aliquip dolore nisi cupidatat", - format: "ea", - }, - ], - }, - ], - remotes: [ - { - headers: [ - { - name: "aute qui exercitation", - description: "sed cupidatat est", - choices: ["consequat nostrud"], - default: "aliqua ad anim consequat", - format: "tempor sint Duis", - }, - ], - type: "id ullamco", - url: "velit nulla", - }, - ], + name: "awslabs/aws-nova-canvas", + description: "Image generation using Amazon Nova Canvas", + repository: { id: "awslabs", source: "github" }, + remotes: [], }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: false, - publishedAt: "1928-01-16T07:47:41.0Z", - status: "Lorem", - }, + }, + { + server: { + name: "tinyfish/agentql-mcp", + description: "A powerful MCP server for building AI agents", + repository: { id: "tinyfish", source: "github" }, + remotes: [], }, }, { server: { - title: "nisi in consectetur ut dolore", - name: "xIH8w/XkU", - version: "occaecat in labore", - description: "dolore Ut laborum et", - _meta: { - id58a: -54358865.657930315, - qui2: -45037479, - "io.modelcontextprotocol.registry/publisher-provided": { - id949: {}, - }, - }, - icons: [ - { - sizes: ["irure"], - mimeType: "nostrud", - src: "http://qpklHhfRUakxqQziHQlJvkYBCQ.schO4z0B", - }, - ], - packages: [ - { - version: "anim aute", - environmentVariables: [ - { - name: "veniam", - description: "dolore aliqua", - choices: ["velit ex in et magna"], - default: "Lorem quis cillum sit dolore", - format: "sint sed", - }, - ], - packageArguments: [ - { - name: "id nostrud cupidatat exercitation", - description: "ullamco tempor Excepteur fugiat et", - choices: ["fugiat eu mollit"], - default: "sint nostrud", - format: "proident occaecat pariatur", - }, - ], - runtimeArguments: [ - { - name: "eiusmod", - description: "anim Lorem", - choices: ["culpa exercitation minim"], - default: "labore cupidatat ea qui voluptate", - format: "minim exercitation dolor", - }, - ], - }, - ], - remotes: [ - { - headers: [ - { - name: "eu sed est", - description: "ut in ex est nulla", - choices: ["aute in ex aliquip nisi"], - default: "fugiat eiusmod Duis aliqua et", - format: "ullamco veniam dolore", - }, - ], - type: "tempor id ad qui", - url: "sint quis", - }, - ], + name: "datastax/astra-db-mcp", + description: "Integrate AI assistants with Astra DB", + repository: { id: "datastax", source: "github" }, + remotes: [], }, - _meta: { - dolor_3e9: 35203589, - consequat_a: 71612484, - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "1906-02-09T18:53:24.0Z", - status: "aute", - }, + }, + { + server: { + name: "microsoft/azure-mcp", + description: "Connect AI assistants to Azure services", + repository: { id: "microsoft", source: "github" }, + remotes: [], }, }, { server: { - title: "id", - name: "BVZ79CA/ewu.W", - version: "ut do occaecat", - description: "elit aliquip eu", - _meta: { - laborum_c_4: 59098891.74366808, - "io.modelcontextprotocol.registry/publisher-provided": { - culpabcc: {}, - dolor0: {}, - consectetur_5: {}, - }, - }, - icons: [ - { - sizes: ["sint est dolor exercitation"], - mimeType: "deserunt in ea", - src: "https://tN.xun..YtDqhkkWdXBxzPIXssrZHM.O5d", - }, - ], - packages: [ - { - version: "exercitation culpa mollit", - environmentVariables: [ - { - name: "nostrud sint", - description: "qui eiusmod", - choices: ["in in ad elit anim"], - default: "culpa sed fugiat laboris", - format: "quis eiusmod", - }, - ], - packageArguments: [ - { - name: "ullamco in officia esse", - description: "magna in qui eu adipisicing", - choices: ["do quis"], - default: "reprehenderit", - format: "voluptate sint consequat cupidatat irure", - }, - ], - runtimeArguments: [ - { - name: "in exercitation", - description: "occaecat", - choices: ["enim"], - default: "officia eu qui elit sed", - format: "voluptate ipsum dolore ullamco", - }, - ], - }, - ], - remotes: [ - { - headers: [ - { - name: "id quis enim Ut", - description: "quis mollit", - choices: ["ut labore"], - default: "adipisicing esse velit nisi sed", - format: "minim et consectetur", - }, - ], - type: "consectetur sed ad esse Ut", - url: "in sint Excepteur", - }, - ], + name: "google/mcp-google-apps", + description: "Virtual MCP for Google Workspace apps", + repository: { id: "google", source: "github" }, + remotes: [{ type: "http", url: "https://example.com/google" }], }, - _meta: { - "io.modelcontextprotocol.registry/official": { - isLatest: true, - publishedAt: "1941-06-16T06:09:48.0Z", - status: "magna aliqua consequat deserunt", - }, + }, + { + server: { + name: "figma/mcp-desktop", + description: "Virtual MCP for Figma Desktop application", + repository: { id: "figma", source: "github" }, + remotes: [{ type: "http", url: "https://example.com/figma" }], + }, + }, + { + server: { + name: "slack/mcp-slack", + description: "Virtual MCP for Slack workspaces", + repository: { id: "slack", source: "github" }, + remotes: [{ type: "http", url: "https://example.com/slack" }], + }, + }, + { + server: { + name: "atlassian/mcp-jira", + description: "Virtual MCP for managing Jira issues", + repository: { id: "atlassian", source: "github" }, + remotes: [{ type: "http", url: "https://example.com/jira" }], }, }, ], metadata: { - count: 79004745, - nextCursor: "non ipsum", + count: 8, + nextCursor: "next-page", }, }; diff --git a/src/mocks/mocker.ts b/src/mocks/mocker.ts index c908c3d..80e2b22 100644 --- a/src/mocks/mocker.ts +++ b/src/mocks/mocker.ts @@ -394,7 +394,9 @@ export function autoGenerateHandlers() { const operation = (pathItem as Record)[method]; if (!operation) continue; - const mswPath = `*/${rawPath.replace(/^\//, "").replace(/\{([^}]+)\}/g, ":$1")}`; + const mswPath = `*/${rawPath + .replace(/^\//, "") + .replace(/\{([^}]+)\}/g, ":$1")}`; result.push( handlersByMethod[method](mswPath, async () => { @@ -500,7 +502,9 @@ export function autoGenerateHandlers() { } } catch (e) { return new HttpResponse( - `[auto-mocker] Missing mock fixture: ${relPath}. ${e instanceof Error ? e.message : ""}`, + `[auto-mocker] Missing mock fixture: ${relPath}. ${ + e instanceof Error ? e.message : "" + }`, { status: 500 }, ); } @@ -542,7 +546,9 @@ export function autoGenerateHandlers() { } } else { // No JSON schema to validate against: explicit failure - const message = `no JSON schema for ${method.toUpperCase()} ${rawPath} status ${successStatus ?? "200"}`; + const message = `no JSON schema for ${method.toUpperCase()} ${rawPath} status ${ + successStatus ?? "200" + }`; console.error("[auto-mocker]", message); return new HttpResponse(`[auto-mocker] ${message}`, { status: 500, @@ -560,11 +566,12 @@ export function autoGenerateHandlers() { const s = (jsonValue as Record).servers; if (Array.isArray(s)) serversLen = s.length; } - // biome-ignore lint: dev fixture response summary console.log( `[auto-mocker] respond ${method.toUpperCase()} ${rawPath} -> ${ successStatus ? Number(successStatus) : 200 - } ${serversLen !== undefined ? `servers=${serversLen}` : ""} (${fixtureFileName})`, + } ${ + serversLen !== undefined ? `servers=${serversLen}` : "" + } (${fixtureFileName})`, ); } catch {} return HttpResponse.json(jsonValue, { diff --git a/src/mocks/server.ts b/src/mocks/server.ts index 8978fc5..4a2acd5 100644 --- a/src/mocks/server.ts +++ b/src/mocks/server.ts @@ -24,11 +24,9 @@ if (!port) { const httpServer = createServer(...handlers); httpServer.on("request", (req: IncomingMessage, _res: ServerResponse) => { - // biome-ignore lint: dev mock server request log console.log(`[mock] ${req.method} ${req.url}`); }); httpServer.listen(port, () => { - // biome-ignore lint: dev mock server startup log console.log(`MSW mock server running on http://localhost:${port}`); }); diff --git a/vitest.config.mts b/vitest.config.mts index 55026e8..0e75662 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -9,7 +9,8 @@ export default defineConfig({ setupFiles: ["src/mocks/test.setup.ts", "./vitest.setup.ts"], env: { // Exactly 32 bytes for AES-256 - BETTER_AUTH_SECRET: "12345678901234567890123456789012", + BETTER_AUTH_SECRET: "12345678901234567890123456789012", // Exactly 32 bytes for AES-256 + NEXT_PUBLIC_OIDC_PROVIDER_ID: "oidc", OIDC_ISSUER_URL: "https://test-issuer.com", OIDC_CLIENT_ID: "test-client-id", OIDC_CLIENT_SECRET: "test-client-secret",