From 48e1b283a25846f21fc8e689da981d54703e3984 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Wed, 19 Nov 2025 16:52:24 +0100 Subject: [PATCH 01/27] feat: handle refresh token --- src/lib/auth/__tests__/auth.test.ts | 29 ++-- src/lib/auth/auth.ts | 214 ++++++++++++++++++---------- src/lib/auth/types.ts | 35 +++++ src/lib/auth/utils.ts | 89 ++++++++++++ 4 files changed, 279 insertions(+), 88 deletions(-) create mode 100644 src/lib/auth/types.ts create mode 100644 src/lib/auth/utils.ts diff --git a/src/lib/auth/__tests__/auth.test.ts b/src/lib/auth/__tests__/auth.test.ts index 735e314..722471e 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", () => ({ @@ -90,7 +87,10 @@ describe("auth.ts", () => { expiresAt: Date.now() - 1000, // Expired 1 second ago }; - const encryptedPayload = await encrypt(expiredTokenData); + const encryptedPayload = await encrypt( + expiredTokenData, + process.env.BETTER_AUTH_SECRET, + ); mockCookies.get.mockReturnValue({ value: encryptedPayload }); const token = await getOidcProviderAccessToken("user-123"); @@ -106,7 +106,10 @@ describe("auth.ts", () => { expiresAt: Date.now() + 3600000, }; - const encryptedPayload = await encrypt(tokenData); + const encryptedPayload = await encrypt( + tokenData, + process.env.BETTER_AUTH_SECRET, + ); mockCookies.get.mockReturnValue({ value: encryptedPayload }); const token = await getOidcProviderAccessToken("user-123"); @@ -121,7 +124,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, + ); mockCookies.get.mockReturnValue({ value: encryptedPayload }); const token = await getOidcProviderAccessToken("user-123"); @@ -132,7 +138,10 @@ describe("auth.ts", () => { it("should return null and delete cookie 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, + ); mockCookies.get.mockReturnValue({ value: invalidPayload }); diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index adb8076..a0d413b 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -1,32 +1,19 @@ -import { createHash } from "node:crypto"; import type { 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"; +import type { OIDCDiscovery, OidcTokenData, TokenResponse } from "./types"; +import { decrypt, encrypt } from "./utils"; // Environment configuration +const OIDC_PROVIDER_ID = process.env.OIDC_PROVIDER_ID || "oidc"; const OIDC_ISSUER_URL = process.env.OIDC_ISSUER_URL || ""; +const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || ""; +const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || ""; const BASE_URL = process.env.BETTER_AUTH_URL || "http://localhost:3000"; const IS_PRODUCTION = process.env.NODE_ENV === "production"; - -/** - * 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. - */ -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()); -} +const BETTER_AUTH_SECRET = + process.env.BETTER_AUTH_SECRET || "build-time-better-auth-secret"; // Token expiration constants const TOKEN_ONE_HOUR_MS = 60 * 60 * 1000; // 1 hour in ms @@ -44,75 +31,123 @@ if (!trustedOrigins.includes(BASE_URL)) { } /** - * Represents the data stored in the encrypted OIDC token cookie. + * Cached token endpoint to avoid repeated discovery calls. */ -export interface OidcTokenData { - accessToken: string; - refreshToken?: string; - expiresAt: number; - userId: string; -} +let cachedTokenEndpoint: string | null = null; /** - * Type guard to validate OidcTokenData structure at runtime. + * Saves encrypted token data in HTTP-only cookie. */ -function isOidcTokenData(data: unknown): data is OidcTokenData { - if (typeof data !== "object" || data === null) { - return false; - } - - const obj = data as Record; +async function saveTokenCookie(tokenData: OidcTokenData): Promise { + const encrypted = await encrypt(tokenData, BETTER_AUTH_SECRET); + const cookieStore = await cookies(); - return ( - typeof obj.accessToken === "string" && - typeof obj.expiresAt === "number" && - typeof obj.userId === "string" && - (obj.refreshToken === undefined || typeof obj.refreshToken === "string") - ); + cookieStore.set(COOKIE_NAME, encrypted, { + httpOnly: true, + secure: IS_PRODUCTION, + sameSite: "lax", + maxAge: TOKEN_SEVEN_DAYS_SECONDS, + path: "/", + }); } /** - * Encrypts token data using JWE (JSON Web Encryption). - * Uses AES-256-GCM with direct key agreement (alg: 'dir'). - * Exported for testing purposes. + * Discovers and caches the token endpoint from OIDC provider. */ -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()); +async function getTokenEndpoint(): Promise { + if (cachedTokenEndpoint) { + return cachedTokenEndpoint; + } + + try { + const discoveryUrl = `${OIDC_ISSUER}/.well-known/openid-configuration`; + const response = await fetch(discoveryUrl); + + if (!response.ok) { + console.error( + "[Auth] Failed to fetch OIDC discovery document:", + response.status, + ); + return null; + } + + 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 { +async function refreshAccessToken( + refreshToken: string, + userId: string, +): Promise { 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"); - } - if (error instanceof jose.errors.JWEInvalid) { - throw new Error("Invalid JWE format"); + 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; } - // 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 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 newTokenData: OidcTokenData = { + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token || refreshToken, + expiresAt, + refreshTokenExpiresAt, + userId, + }; + + // Save the new token data in the cookie + await saveTokenCookie(newTokenData); + + 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", + secret: BETTER_AUTH_SECRET, baseURL: BASE_URL, trustedOrigins, session: { @@ -143,6 +178,7 @@ export const auth = betterAuth({ accessToken?: string; refreshToken?: string; accessTokenExpiresAt?: Date | string; + refreshTokenExpiresAt?: Date | string; userId: string; }) => { if (account.accessToken && account.userId) { @@ -150,23 +186,19 @@ export const auth = betterAuth({ ? 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, }; - 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: "/", - }); + await saveTokenCookie(tokenData); } }, }, @@ -191,7 +223,7 @@ 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( @@ -209,6 +241,32 @@ export async function getOidcProviderAccessToken( const now = Date.now(); if (tokenData.expiresAt <= now) { + // Check if refresh token is also expired + if ( + tokenData.refreshTokenExpiresAt && + tokenData.refreshTokenExpiresAt <= now + ) { + console.log("[Auth] Both access and refresh tokens expired"); + cookieStore.delete(COOKIE_NAME); + return null; + } + + // Attempt to refresh the token if refresh token is available + if (tokenData.refreshToken) { + console.log("[Auth] Access token expired, attempting refresh..."); + const refreshedData = await refreshAccessToken( + tokenData.refreshToken, + userId, + ); + + if (refreshedData) { + console.log("[Auth] Token refresh successful"); + return refreshedData.accessToken; + } + + console.log("[Auth] Token refresh failed, clearing cookie"); + } + cookieStore.delete(COOKIE_NAME); return null; } 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..5b125af --- /dev/null +++ b/src/lib/auth/utils.ts @@ -0,0 +1,89 @@ +/** + * Utility functions for authentication, token validation, and encryption. + */ + +import { createHash } from "node:crypto"; +import * as jose from "jose"; +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 | undefined): Uint8Array { + if (!secret) { + throw new Error("BETTER_AUTH_SECRET is required for encryption"); + } + // 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 | undefined, +): 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 | undefined, +): 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") + ); +} From a6125dbc6dcdca2457ff21bfde1958e26fb93231 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Wed, 19 Nov 2025 18:44:03 +0100 Subject: [PATCH 02/27] refactor: move to constants file --- AGENTS.md | 2 +- CLAUDE.md | 2 +- dev-auth/README.md | 2 +- src/lib/auth/auth.ts | 68 ++++++++++++++++----------------------- src/lib/auth/constants.ts | 32 +++++++++++++++--- 5 files changed, 59 insertions(+), 47 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2d09ab6..4edf33f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -325,7 +325,7 @@ 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_CLIENT_ID`, `OIDC_CLIENT_SECRET` ### Development diff --git a/CLAUDE.md b/CLAUDE.md index 91fe034..d0c4b45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -274,7 +274,7 @@ 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, CLIENT_ID, etc.) ### API Calls Failing diff --git a/dev-auth/README.md b/dev-auth/README.md index d7875c8..1cffd36 100644 --- a/dev-auth/README.md +++ b/dev-auth/README.md @@ -40,6 +40,6 @@ 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_ISSUER_URL` - `OIDC_CLIENT_ID` - `OIDC_CLIENT_SECRET` diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index a0d413b..af24c2c 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -1,35 +1,23 @@ -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 { cookies } from "next/headers"; +import { + BASE_URL, + BETTER_AUTH_SECRET, + COOKIE_NAME, + IS_PRODUCTION, + OIDC_CLIENT_ID, + OIDC_CLIENT_SECRET, + OIDC_ISSUER_URL, + OIDC_PROVIDER_ID, + TOKEN_ONE_HOUR_MS, + TOKEN_SEVEN_DAYS_SECONDS, + TRUSTED_ORIGINS, +} from "./constants"; import type { OIDCDiscovery, OidcTokenData, TokenResponse } from "./types"; import { decrypt, encrypt } from "./utils"; -// Environment configuration -const OIDC_PROVIDER_ID = process.env.OIDC_PROVIDER_ID || "oidc"; -const OIDC_ISSUER_URL = process.env.OIDC_ISSUER_URL || ""; -const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || ""; -const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || ""; -const BASE_URL = process.env.BETTER_AUTH_URL || "http://localhost:3000"; -const IS_PRODUCTION = process.env.NODE_ENV === "production"; -const BETTER_AUTH_SECRET = - process.env.BETTER_AUTH_SECRET || "build-time-better-auth-secret"; - -// 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); -} - /** * Cached token endpoint to avoid repeated discovery calls. */ @@ -60,13 +48,13 @@ async function getTokenEndpoint(): Promise { } try { - const discoveryUrl = `${OIDC_ISSUER}/.well-known/openid-configuration`; + const discoveryUrl = `${OIDC_ISSUER_URL}/.well-known/openid-configuration`; const response = await fetch(discoveryUrl); if (!response.ok) { console.error( "[Auth] Failed to fetch OIDC discovery document:", - response.status, + response.status ); return null; } @@ -87,7 +75,7 @@ async function getTokenEndpoint(): Promise { */ async function refreshAccessToken( refreshToken: string, - userId: string, + userId: string ): Promise { try { const tokenEndpoint = await getTokenEndpoint(); @@ -116,7 +104,7 @@ async function refreshAccessToken( console.error( "[Auth] Token refresh failed:", response.status, - response.statusText, + response.statusText ); return null; } @@ -146,10 +134,10 @@ async function refreshAccessToken( } } -export const auth = betterAuth({ +export const auth: Auth = betterAuth({ secret: BETTER_AUTH_SECRET, baseURL: BASE_URL, - trustedOrigins, + trustedOrigins: TRUSTED_ORIGINS, session: { cookieCache: { enabled: true, @@ -175,10 +163,10 @@ export const auth = betterAuth({ account: { create: { after: async (account: { - accessToken?: string; - refreshToken?: string; - accessTokenExpiresAt?: Date | string; - refreshTokenExpiresAt?: Date | string; + accessToken?: string | null; + refreshToken?: string | null; + accessTokenExpiresAt?: Date | string | null; + refreshTokenExpiresAt?: Date | string | null; userId: string; }) => { if (account.accessToken && account.userId) { @@ -204,14 +192,14 @@ export const auth = betterAuth({ }, }, }, -} as BetterAuthOptions); +}); /** * Retrieves the OIDC provider access token from HTTP-only cookie. * Returns null if token not found, expired, or belongs to different user. */ export async function getOidcProviderAccessToken( - userId: string, + userId: string ): Promise { try { const cookieStore = await cookies(); @@ -228,7 +216,7 @@ export async function getOidcProviderAccessToken( // Decryption failure indicates tampering, corruption, or wrong secret console.error( "[Auth] Token decryption failed - possible tampering or invalid format:", - error, + error ); cookieStore.delete(COOKIE_NAME); return null; @@ -256,7 +244,7 @@ export async function getOidcProviderAccessToken( console.log("[Auth] Access token expired, attempting refresh..."); const refreshedData = await refreshAccessToken( tokenData.refreshToken, - userId, + userId ); if (refreshedData) { diff --git a/src/lib/auth/constants.ts b/src/lib/auth/constants.ts index f6163af..95bbb4e 100644 --- a/src/lib/auth/constants.ts +++ b/src/lib/auth/constants.ts @@ -1,6 +1,30 @@ /** - * 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 +export const OIDC_PROVIDER_ID = process.env.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]; From 15b60057a5f6fb789554d93622dd17f05c726023 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 12:52:25 +0100 Subject: [PATCH 03/27] refactor: env var --- vitest.config.mts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vitest.config.mts b/vitest.config.mts index 55026e8..1c503bd 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 + OIDC_PROVIDER_ID: "oidc", OIDC_ISSUER_URL: "https://test-issuer.com", OIDC_CLIENT_ID: "test-client-id", OIDC_CLIENT_SECRET: "test-client-secret", From ec0e948178906aecbcb7e20026a8b6d0cb6956df Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 13:18:35 +0100 Subject: [PATCH 04/27] refactor: use NEXT_PUBLIC_OIDC_PROVIDER_ID as unique env var --- src/lib/auth/constants.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/auth/constants.ts b/src/lib/auth/constants.ts index 95bbb4e..1d76b04 100644 --- a/src/lib/auth/constants.ts +++ b/src/lib/auth/constants.ts @@ -3,7 +3,8 @@ */ // Environment configuration -export const OIDC_PROVIDER_ID = process.env.OIDC_PROVIDER_ID || "oidc"; +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 || ""; From cddb5159e23f879a30faec7dbc3c9e143642a023 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 13:24:07 +0100 Subject: [PATCH 05/27] refactor: update doc for env vars --- AGENTS.md | 11 +++++++++-- CLAUDE.md | 9 ++++++++- dev-auth/README.md | 9 ++++++--- src/lib/auth/constants.ts | 5 +++++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4edf33f..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_URL`, `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 d0c4b45..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_URL, 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/dev-auth/README.md b/dev-auth/README.md index 1cffd36..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_URL` -- `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/src/lib/auth/constants.ts b/src/lib/auth/constants.ts index 1d76b04..d4fe7bc 100644 --- a/src/lib/auth/constants.ts +++ b/src/lib/auth/constants.ts @@ -3,6 +3,11 @@ */ // 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). + * Not sensitive data - it's just an identifier. + */ export const OIDC_PROVIDER_ID = process.env.NEXT_PUBLIC_OIDC_PROVIDER_ID || "oidc"; export const OIDC_ISSUER_URL = process.env.OIDC_ISSUER_URL || ""; From f87d93ea91627fa1d20ca95ec37c0b71f1f4c908 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 14:38:40 +0100 Subject: [PATCH 06/27] lint and format --- biome.json | 14 ++++++++++++-- src/lib/auth/auth.ts | 12 ++++++------ src/mocks/mocker.ts | 17 ++++++++++++----- src/mocks/server.ts | 2 -- 4 files changed, 30 insertions(+), 15 deletions(-) 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/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index af24c2c..77bb1f8 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -54,7 +54,7 @@ async function getTokenEndpoint(): Promise { if (!response.ok) { console.error( "[Auth] Failed to fetch OIDC discovery document:", - response.status + response.status, ); return null; } @@ -75,7 +75,7 @@ async function getTokenEndpoint(): Promise { */ async function refreshAccessToken( refreshToken: string, - userId: string + userId: string, ): Promise { try { const tokenEndpoint = await getTokenEndpoint(); @@ -104,7 +104,7 @@ async function refreshAccessToken( console.error( "[Auth] Token refresh failed:", response.status, - response.statusText + response.statusText, ); return null; } @@ -199,7 +199,7 @@ export const auth: Auth = betterAuth({ * Returns null if token not found, expired, or belongs to different user. */ export async function getOidcProviderAccessToken( - userId: string + userId: string, ): Promise { try { const cookieStore = await cookies(); @@ -216,7 +216,7 @@ export async function getOidcProviderAccessToken( // Decryption failure indicates tampering, corruption, or wrong secret console.error( "[Auth] Token decryption failed - possible tampering or invalid format:", - error + error, ); cookieStore.delete(COOKIE_NAME); return null; @@ -244,7 +244,7 @@ export async function getOidcProviderAccessToken( console.log("[Auth] Access token expired, attempting refresh..."); const refreshedData = await refreshAccessToken( tokenData.refreshToken, - userId + userId, ); if (refreshedData) { 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}`); }); From 50f154f92dca1b127f2d6e22745507c03120f9ed Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 18:35:13 +0100 Subject: [PATCH 07/27] feat: configure hey-api client passing the bearer token to api services --- src/app/catalog/actions.ts | 5 ++- src/app/layout.tsx | 1 - src/lib/api-client.ts | 78 ++++++++++++++++++++++++++++++++------ 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/src/app/catalog/actions.ts b/src/app/catalog/actions.ts index 0e9e39a..b50e46f 100644 --- a/src/app/catalog/actions.ts +++ b/src/app/catalog/actions.ts @@ -1,10 +1,11 @@ "use server"; -import { getRegistryV01Servers } from "@/generated/sdk.gen"; +import { getAuthenticatedClient } from "@/lib/api-client"; export async function getServersSummary() { try { - const resp = await getRegistryV01Servers(); + const api = await getAuthenticatedClient(); + const resp = await api.getRegistryV01Servers(); const data = resp.data; const items = Array.isArray(data?.servers) ? data.servers : []; 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/lib/api-client.ts b/src/lib/api-client.ts index e24e5fd..24c186c 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,21 +1,75 @@ /** * 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 { client } from "@/generated/client.gen"; +import * as apiServices from "@/generated/sdk.gen"; +import { auth } from "./auth/auth"; +import { getValidOidcToken } from "./auth/token"; + +/** + * Gets an authenticated API client with OIDC access token. + * Automatically refreshes the token if expired. + * + * 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) { + try { + const session = await auth.api.getSession({ + headers: await nextHeaders(), + }); + + if (!session?.user?.id) { + redirect("/signin"); + } + + const token = await getValidOidcToken(session.user.id); + + if (!token) { + redirect("/signin"); + } + + accessToken = token; + } catch (error) { + console.error("[API Client] Error getting access token:", error); + redirect("/signin"); + } + } -// 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 || "", -}); + // Configure client with authentication + client.setConfig({ + baseUrl: process.env.API_BASE_URL || "", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); -export { client }; + return { ...apiServices, client }; +} From a57c8a58717daa30533d92db97b2453f98566790 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 18:35:28 +0100 Subject: [PATCH 08/27] feat(auth): handle refresh token --- src/app/api/auth/refresh-token/route.ts | 74 ++++++ src/lib/auth/README.md | 141 ++++++++++ src/lib/auth/__tests__/auth.test.ts | 15 +- src/lib/auth/__tests__/token.test.ts | 335 ++++++++++++++++++++++++ src/lib/auth/auth.ts | 80 +++--- src/lib/auth/token.ts | 66 +++++ 6 files changed, 667 insertions(+), 44 deletions(-) create mode 100644 src/app/api/auth/refresh-token/route.ts create mode 100644 src/lib/auth/README.md create mode 100644 src/lib/auth/__tests__/token.test.ts create mode 100644 src/lib/auth/token.ts 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..da64046 --- /dev/null +++ b/src/app/api/auth/refresh-token/route.ts @@ -0,0 +1,74 @@ +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, + ); + + if (!refreshedData) { + console.error("[Refresh API] Token refresh failed"); + cookieStore.delete(COOKIE_NAME); + return NextResponse.json({ error: "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/lib/auth/README.md b/src/lib/auth/README.md new file mode 100644 index 0000000..83f7947 --- /dev/null +++ b/src/lib/auth/README.md @@ -0,0 +1,141 @@ +# 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[Call /api/auth/refresh-token] + F --> G{Refresh successful?} + G -->|Yes| H[Save new token in cookie] + G -->|No| C + H --> E + E --> I[Make API call with 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 722471e..c610c60 100644 --- a/src/lib/auth/__tests__/auth.test.ts +++ b/src/lib/auth/__tests__/auth.test.ts @@ -80,7 +80,7 @@ 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", @@ -96,7 +96,8 @@ 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(); }); it("should return null when token belongs to different user", async () => { @@ -135,7 +136,7 @@ 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( @@ -148,7 +149,8 @@ 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).toHaveBeenCalled(); }); @@ -159,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), ); }); diff --git a/src/lib/auth/__tests__/token.test.ts b/src/lib/auth/__tests__/token.test.ts new file mode 100644 index 0000000..e8bf539 --- /dev/null +++ b/src/lib/auth/__tests__/token.test.ts @@ -0,0 +1,335 @@ +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 consoleErrorSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + consoleLogSpy = vi.spyOn(console, "log").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, + ); + 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, + ); + 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(consoleErrorSpy).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, + ); + 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, + ); + 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(consoleErrorSpy).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(consoleErrorSpy).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, + ); + 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(consoleErrorSpy).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index 77bb1f8..be94095 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -73,7 +73,7 @@ async function getTokenEndpoint(): Promise { * Attempts to refresh the access token using the refresh token. * Returns new token data if successful, null otherwise. */ -async function refreshAccessToken( +export async function refreshAccessToken( refreshToken: string, userId: string, ): Promise { @@ -127,6 +127,7 @@ async function refreshAccessToken( // 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); @@ -141,7 +142,12 @@ export const auth: Auth = betterAuth({ 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({ @@ -152,13 +158,13 @@ export const auth: 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: { @@ -190,6 +196,36 @@ export const auth: Auth = betterAuth({ } }, }, + update: { + after: async (account: { + accessToken?: string | null; + refreshToken?: string | null; + accessTokenExpiresAt?: Date | string | null; + refreshTokenExpiresAt?: Date | string | null; + userId: string; + }) => { + // Same logic as create - save tokens on re-login + 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, + }; + + await saveTokenCookie(tokenData); + } + }, + }, }, }, }); @@ -213,55 +249,23 @@ export async function getOidcProviderAccessToken( try { 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) { - // Check if refresh token is also expired - if ( - tokenData.refreshTokenExpiresAt && - tokenData.refreshTokenExpiresAt <= now - ) { - console.log("[Auth] Both access and refresh tokens expired"); - cookieStore.delete(COOKIE_NAME); - return null; - } - - // Attempt to refresh the token if refresh token is available - if (tokenData.refreshToken) { - console.log("[Auth] Access token expired, attempting refresh..."); - const refreshedData = await refreshAccessToken( - tokenData.refreshToken, - userId, - ); - - if (refreshedData) { - console.log("[Auth] Token refresh successful"); - return refreshedData.accessToken; - } - - console.log("[Auth] Token refresh failed, clearing cookie"); - } - - 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/token.ts b/src/lib/auth/token.ts new file mode 100644 index 0000000..52d6bf8 --- /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.error("[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); +} From c89da941779ab7abe8e60e820803230cd521428d Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 18:42:40 +0100 Subject: [PATCH 09/27] refactor: refresh token diagram flow --- src/lib/auth/README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/lib/auth/README.md b/src/lib/auth/README.md index 83f7947..0004af8 100644 --- a/src/lib/auth/README.md +++ b/src/lib/auth/README.md @@ -45,12 +45,14 @@ flowchart TD B -->|No| C[Redirect to /signin] B -->|Yes| D{Token expired?} D -->|No| E[Use existing token] - D -->|Yes| F[Call /api/auth/refresh-token] - F --> G{Refresh successful?} - G -->|Yes| H[Save new token in cookie] - G -->|No| C - H --> E - E --> I[Make API call with 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 From c3a228a4451180d1eadb05c13a1252dedc8dac01 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 18:54:31 +0100 Subject: [PATCH 10/27] fix: remove leftover catalogue page --- src/app/catalogue/page.tsx | 74 -------------------------------------- 1 file changed, 74 deletions(-) delete mode 100644 src/app/catalogue/page.tsx 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} -

-
-
- - -
-
- ); -} From 04656fc63edd837bb45675df1f72e4d35b364f55 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 19:02:06 +0100 Subject: [PATCH 11/27] fix: api-client new instance --- src/lib/api-client.ts | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 24c186c..4b3f544 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -15,15 +15,26 @@ import { headers as nextHeaders } from "next/headers"; import { redirect } from "next/navigation"; -import { client } from "@/generated/client.gen"; +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; +if (!API_BASE_URL) { + throw new Error( + "API_BASE_URL environment variable is required but not set. Please configure it in your .env file.", + ); +} + /** * 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 @@ -63,13 +74,15 @@ export async function getAuthenticatedClient(accessToken?: string) { } } - // Configure client with authentication - client.setConfig({ - baseUrl: process.env.API_BASE_URL || "", - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); + // Create a new client instance per request to avoid race conditions + const authenticatedClient = createClient( + createConfig({ + baseUrl: API_BASE_URL, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }), + ); - return { ...apiServices, client }; + return { ...apiServices, client: authenticatedClient }; } From b253e316bf5c6893a3d64085a5e69c1964c3c529 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 19:07:07 +0100 Subject: [PATCH 12/27] fix: raise console error in case of missing token or userId --- src/lib/auth/auth.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index be94095..23a9be9 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -77,6 +77,11 @@ export async function refreshAccessToken( refreshToken: string, userId: string, ): Promise { + if (!refreshToken || !userId) { + console.error("[Auth] Missing refresh token or userId"); + return null; + } + try { const tokenEndpoint = await getTokenEndpoint(); From 19231fe04c2b069928b71ea59bcbac32a1c15c2b Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 19:12:42 +0100 Subject: [PATCH 13/27] fix: redirect to sign in if refresh token is expired --- src/app/api/auth/refresh-token/route.ts | 1 + src/components/user-menu/user-menu.test.tsx | 2 +- src/lib/auth/auth.ts | 7 +++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/api/auth/refresh-token/route.ts b/src/app/api/auth/refresh-token/route.ts index da64046..ff46435 100644 --- a/src/app/api/auth/refresh-token/route.ts +++ b/src/app/api/auth/refresh-token/route.ts @@ -52,6 +52,7 @@ export async function POST(request: NextRequest) { const refreshedData = await refreshAccessToken( tokenData.refreshToken, userId, + tokenData.refreshTokenExpiresAt, ); if (!refreshedData) { 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/auth/auth.ts b/src/lib/auth/auth.ts index 23a9be9..f7ecd31 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -76,12 +76,19 @@ async function getTokenEndpoint(): 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 tokenEndpoint = await getTokenEndpoint(); From b46fdd66ae3cbb7f3c032f06d1e195db7e4579a8 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 19:16:12 +0100 Subject: [PATCH 14/27] feat: implement OIDC authentication with automatic token refresh - Add Better Auth with OIDC (OAuth2 Authorization Code Flow) - Implement custom token storage in encrypted HTTP-only cookies - Add automatic access token refresh when expired - Create dedicated /api/auth/refresh-token endpoint - Fix race condition by creating new client instance per request - Add fail-fast validation for API_BASE_URL env var - Add refresh token expiration check before API call - Add warning when provider doesn't return new refresh token - Add account.update.after hook for re-login flow - Add comprehensive auth documentation in src/lib/auth/README.md - Add 22 unit tests for auth flows Security improvements: - AES-256-GCM encryption for token storage - Server-side only API client (no token exposure to client) - HTTP-only cookies (not accessible via JavaScript) - SameSite protection - 7-day session management with Better Auth cookie cache Fixes #50 --- src/lib/auth/auth.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index f7ecd31..1c1e05b 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -128,9 +128,16 @@ export async function refreshAccessToken( ? 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", + ); + } + const newTokenData: OidcTokenData = { accessToken: tokenResponse.access_token, - refreshToken: tokenResponse.refresh_token || refreshToken, + refreshToken: newRefreshToken, expiresAt, refreshTokenExpiresAt, userId, From 1daf0f334eb1c835b64249d72dc014d877497e95 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 19:21:20 +0100 Subject: [PATCH 15/27] refactor: move to util reusable account auth info --- src/lib/auth/auth.ts | 63 ++++--------------------------------------- src/lib/auth/utils.ts | 37 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 58 deletions(-) diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index 1c1e05b..9eda8a0 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -11,12 +11,11 @@ import { OIDC_CLIENT_SECRET, OIDC_ISSUER_URL, OIDC_PROVIDER_ID, - TOKEN_ONE_HOUR_MS, TOKEN_SEVEN_DAYS_SECONDS, TRUSTED_ORIGINS, } from "./constants"; import type { OIDCDiscovery, OidcTokenData, TokenResponse } from "./types"; -import { decrypt, encrypt } from "./utils"; +import { decrypt, encrypt, saveAccountToken } from "./utils"; /** * Cached token endpoint to avoid repeated discovery calls. @@ -25,8 +24,9 @@ let cachedTokenEndpoint: string | null = null; /** * Saves encrypted token data in HTTP-only cookie. + * Exported for use by saveAccountToken in utils. */ -async function saveTokenCookie(tokenData: OidcTokenData): Promise { +export async function saveTokenCookie(tokenData: OidcTokenData): Promise { const encrypted = await encrypt(tokenData, BETTER_AUTH_SECRET); const cookieStore = await cookies(); @@ -187,63 +187,10 @@ export const auth: Auth = betterAuth({ databaseHooks: { account: { create: { - after: async (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, - }; - - await saveTokenCookie(tokenData); - } - }, + after: saveAccountToken, }, update: { - after: async (account: { - accessToken?: string | null; - refreshToken?: string | null; - accessTokenExpiresAt?: Date | string | null; - refreshTokenExpiresAt?: Date | string | null; - userId: string; - }) => { - // Same logic as create - save tokens on re-login - 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, - }; - - await saveTokenCookie(tokenData); - } - }, + after: saveAccountToken, }, }, }, diff --git a/src/lib/auth/utils.ts b/src/lib/auth/utils.ts index 5b125af..7b76c6c 100644 --- a/src/lib/auth/utils.ts +++ b/src/lib/auth/utils.ts @@ -4,6 +4,7 @@ import { createHash } from "node:crypto"; import * as jose from "jose"; +import { TOKEN_ONE_HOUR_MS } from "./constants"; import type { OidcTokenData } from "./types"; /** @@ -87,3 +88,39 @@ export function isOidcTokenData(data: unknown): data is OidcTokenData { 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, + }; + + // Dynamic import to avoid circular dependency + const { saveTokenCookie } = await import("./auth"); + await saveTokenCookie(tokenData); + } +} From 477661e6b7fd683389efef667e61e2f2477ed3b8 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 19:25:16 +0100 Subject: [PATCH 16/27] fix: update env var --- vitest.config.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vitest.config.mts b/vitest.config.mts index 1c503bd..0e75662 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -10,7 +10,7 @@ export default defineConfig({ env: { // Exactly 32 bytes for AES-256 BETTER_AUTH_SECRET: "12345678901234567890123456789012", // Exactly 32 bytes for AES-256 - OIDC_PROVIDER_ID: "oidc", + 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", From 07434cfe3e3d72d5ec01db2c770d0d818c6dd73e Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 19:30:43 +0100 Subject: [PATCH 17/27] fix: remove throw error on better auth secret env var --- src/lib/auth/utils.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/lib/auth/utils.ts b/src/lib/auth/utils.ts index 7b76c6c..4954b9b 100644 --- a/src/lib/auth/utils.ts +++ b/src/lib/auth/utils.ts @@ -12,10 +12,7 @@ import type { OidcTokenData } from "./types"; * 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 | undefined): Uint8Array { - if (!secret) { - throw new Error("BETTER_AUTH_SECRET is required for encryption"); - } +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()); } @@ -27,7 +24,7 @@ function getSecret(secret: string | undefined): Uint8Array { */ export async function encrypt( data: OidcTokenData, - secret: string | undefined, + secret: string, ): Promise { const key = getSecret(secret); const plaintext = new TextEncoder().encode(JSON.stringify(data)); @@ -43,7 +40,7 @@ export async function encrypt( */ export async function decrypt( jwe: string, - secret: string | undefined, + secret: string, ): Promise { try { const key = getSecret(secret); From 4eb6f4a0de67cf15f1616483e59a2d46bae10eb3 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 19:33:32 +0100 Subject: [PATCH 18/27] fix: remove throw error on api base url env var --- src/lib/api-client.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 4b3f544..4df7539 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -22,11 +22,6 @@ import { getValidOidcToken } from "./auth/token"; // Validate required environment variables at module load time (fail-fast) const API_BASE_URL = process.env.API_BASE_URL; -if (!API_BASE_URL) { - throw new Error( - "API_BASE_URL environment variable is required but not set. Please configure it in your .env file.", - ); -} /** * Gets an authenticated API client with OIDC access token. From e33fca5c6e598b20ea3f5230103d398d9a6660a0 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 19:35:27 +0100 Subject: [PATCH 19/27] refactor: comment --- src/lib/auth/constants.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/auth/constants.ts b/src/lib/auth/constants.ts index d4fe7bc..bb8356d 100644 --- a/src/lib/auth/constants.ts +++ b/src/lib/auth/constants.ts @@ -6,7 +6,8 @@ /** * 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). - * Not sensitive data - it's just an identifier. + * 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"; From 2f6ee5d251ec4b47d0445cda77fbb3b5b9533b2a Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 19:38:47 +0100 Subject: [PATCH 20/27] fix: types --- src/lib/auth/__tests__/auth.test.ts | 8 ++++---- src/lib/auth/__tests__/token.test.ts | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/lib/auth/__tests__/auth.test.ts b/src/lib/auth/__tests__/auth.test.ts index c610c60..0d93dc3 100644 --- a/src/lib/auth/__tests__/auth.test.ts +++ b/src/lib/auth/__tests__/auth.test.ts @@ -89,7 +89,7 @@ describe("auth.ts", () => { const encryptedPayload = await encrypt( expiredTokenData, - process.env.BETTER_AUTH_SECRET, + process.env.BETTER_AUTH_SECRET as string, ); mockCookies.get.mockReturnValue({ value: encryptedPayload }); @@ -109,7 +109,7 @@ describe("auth.ts", () => { const encryptedPayload = await encrypt( tokenData, - process.env.BETTER_AUTH_SECRET, + process.env.BETTER_AUTH_SECRET as string, ); mockCookies.get.mockReturnValue({ value: encryptedPayload }); @@ -127,7 +127,7 @@ describe("auth.ts", () => { const encryptedPayload = await encrypt( tokenData, - process.env.BETTER_AUTH_SECRET, + process.env.BETTER_AUTH_SECRET as string, ); mockCookies.get.mockReturnValue({ value: encryptedPayload }); @@ -141,7 +141,7 @@ describe("auth.ts", () => { const invalidData = { accessToken: "token" }; // Missing userId and expiresAt const invalidPayload = await encrypt( invalidData as OidcTokenData, - process.env.BETTER_AUTH_SECRET, + process.env.BETTER_AUTH_SECRET as string, ); mockCookies.get.mockReturnValue({ value: invalidPayload }); diff --git a/src/lib/auth/__tests__/token.test.ts b/src/lib/auth/__tests__/token.test.ts index e8bf539..32ebe91 100644 --- a/src/lib/auth/__tests__/token.test.ts +++ b/src/lib/auth/__tests__/token.test.ts @@ -84,7 +84,7 @@ describe("token", () => { const encryptedPayload = await encrypt( tokenData, - process.env.BETTER_AUTH_SECRET, + process.env.BETTER_AUTH_SECRET as string, ); mockCookies.get.mockReturnValue({ value: encryptedPayload }); @@ -105,7 +105,7 @@ describe("token", () => { const encryptedPayload = await encrypt( expiredTokenData, - process.env.BETTER_AUTH_SECRET, + process.env.BETTER_AUTH_SECRET as string, ); mockCookies.get.mockReturnValue({ value: encryptedPayload }); @@ -154,7 +154,7 @@ describe("token", () => { const encryptedPayload = await encrypt( expiredTokenData, - process.env.BETTER_AUTH_SECRET, + process.env.BETTER_AUTH_SECRET as string, ); mockCookies.get.mockReturnValue({ value: encryptedPayload }); @@ -182,7 +182,7 @@ describe("token", () => { const encryptedPayload = await encrypt( expiredTokenData, - process.env.BETTER_AUTH_SECRET, + process.env.BETTER_AUTH_SECRET as string, ); mockCookies.get.mockReturnValue({ value: encryptedPayload }); @@ -295,7 +295,7 @@ describe("token", () => { const encryptedPayload = await encrypt( expiredTokenData, - process.env.BETTER_AUTH_SECRET, + process.env.BETTER_AUTH_SECRET as string, ); mockCookies.get.mockReturnValue({ value: encryptedPayload }); From 1e04eacf4c18011c0b33c8721148695be70ae141 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 19:39:48 +0100 Subject: [PATCH 21/27] leftover --- src/lib/auth/__tests__/auth.test.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/lib/auth/__tests__/auth.test.ts b/src/lib/auth/__tests__/auth.test.ts index 0d93dc3..2752be7 100644 --- a/src/lib/auth/__tests__/auth.test.ts +++ b/src/lib/auth/__tests__/auth.test.ts @@ -49,7 +49,7 @@ vi.mock("better-auth/plugins", () => ({ genericOAuth: vi.fn(() => ({})), })); -describe("auth.ts", () => { +describe("auth", () => { let consoleErrorSpy: ReturnType; beforeEach(() => { @@ -206,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); - }); - }); }); From afd590c66dbe5b0dae624848d69f4ba7131b247d Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 20:05:50 +0100 Subject: [PATCH 22/27] feat(card): add server card component --- package.json | 1 + pnpm-lock.yaml | 3 + src/components/server-card.test.tsx | 47 +++ src/components/server-card.tsx | 80 ++++++ src/components/ui/badge.tsx | 46 +++ src/components/ui/card.tsx | 92 ++++++ .../fixtures/registry_v0_1_servers/get.ts | 270 ++++-------------- 7 files changed, 320 insertions(+), 219 deletions(-) create mode 100644 src/components/server-card.test.tsx create mode 100644 src/components/server-card.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/card.tsx diff --git a/package.json b/package.json index d6dd6bf..b8d9e52 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,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/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/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", }, }; From 4e3debaf82864c6edebf7befd3bf75a2569eeddc Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 20 Nov 2025 21:10:49 +0100 Subject: [PATCH 23/27] fix: client api on server actions --- dev-auth/oidc-provider.mjs | 1 + src/app/catalog/actions.ts | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) 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/src/app/catalog/actions.ts b/src/app/catalog/actions.ts index b50e46f..88355ec 100644 --- a/src/app/catalog/actions.ts +++ b/src/app/catalog/actions.ts @@ -1,11 +1,33 @@ "use server"; +import type { V0ServerJson } from "@/generated/types.gen"; import { getAuthenticatedClient } from "@/lib/api-client"; +export async function getServers(): Promise { + try { + const api = await getAuthenticatedClient(); + const resp = await api.getRegistryV01Servers({ + client: api.client, + }); + 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); + } catch (error) { + console.error("[catalog] Failed to fetch servers:", error); + return []; + } +} + export async function getServersSummary() { try { const api = await getAuthenticatedClient(); - const resp = await api.getRegistryV01Servers(); + const resp = await api.getRegistryV01Servers({ + client: api.client, + }); const data = resp.data; const items = Array.isArray(data?.servers) ? data.servers : []; From 99ce1ec9c45cdcaafbb283664b6257ef84259fe6 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Fri, 21 Nov 2025 09:32:53 +0100 Subject: [PATCH 24/27] chore: pnpm script for run real oidc + msw --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index b8d9e52..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", From 2085ddfd71b20eb5944b64adcb5ccec1531531a0 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Fri, 21 Nov 2025 09:34:31 +0100 Subject: [PATCH 25/27] fix: redirect issue --- src/app/api/auth/refresh-token/route.ts | 5 +- src/app/catalog/actions.ts | 77 +++++++++++++------------ src/lib/api-client.ts | 27 ++++----- src/lib/auth/token.ts | 2 +- 4 files changed, 57 insertions(+), 54 deletions(-) diff --git a/src/app/api/auth/refresh-token/route.ts b/src/app/api/auth/refresh-token/route.ts index ff46435..bfe8dac 100644 --- a/src/app/api/auth/refresh-token/route.ts +++ b/src/app/api/auth/refresh-token/route.ts @@ -58,7 +58,10 @@ export async function POST(request: NextRequest) { if (!refreshedData) { console.error("[Refresh API] Token refresh failed"); cookieStore.delete(COOKIE_NAME); - return NextResponse.json({ error: "Refresh failed" }, { status: 401 }); + return NextResponse.json( + { error: "[Refresh API] Refresh failed" }, + { status: 401 }, + ); } return NextResponse.json({ diff --git a/src/app/catalog/actions.ts b/src/app/catalog/actions.ts index 88355ec..315274b 100644 --- a/src/app/catalog/actions.ts +++ b/src/app/catalog/actions.ts @@ -4,48 +4,51 @@ import type { V0ServerJson } from "@/generated/types.gen"; import { getAuthenticatedClient } from "@/lib/api-client"; export async function getServers(): Promise { - try { - const api = await getAuthenticatedClient(); - const resp = await api.getRegistryV01Servers({ - client: api.client, - }); - 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); - } catch (error) { - 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 []; + } + + 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 api = await getAuthenticatedClient(); - const resp = await api.getRegistryV01Servers({ - client: api.client, - }); - 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/lib/api-client.ts b/src/lib/api-client.ts index 4df7539..7262989 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -47,26 +47,23 @@ const API_BASE_URL = process.env.API_BASE_URL; export async function getAuthenticatedClient(accessToken?: string) { // If no token provided, get it from the session if (accessToken === undefined) { - try { - const session = await auth.api.getSession({ - headers: await nextHeaders(), - }); + const session = await auth.api.getSession({ + headers: await nextHeaders(), + }); - if (!session?.user?.id) { - redirect("/signin"); - } - - const token = await getValidOidcToken(session.user.id); + if (!session?.user?.id) { + console.log("[API Client] user not found, redirecting to signin"); + redirect("/signin"); + } - if (!token) { - redirect("/signin"); - } + const token = await getValidOidcToken(session.user.id); - accessToken = token; - } catch (error) { - console.error("[API Client] Error getting access token:", error); + if (!token) { + console.log("[API Client] token not found, redirecting to signin"); redirect("/signin"); } + + accessToken = token; } // Create a new client instance per request to avoid race conditions diff --git a/src/lib/auth/token.ts b/src/lib/auth/token.ts index 52d6bf8..d7a8963 100644 --- a/src/lib/auth/token.ts +++ b/src/lib/auth/token.ts @@ -30,7 +30,7 @@ async function refreshOidcAccessToken(userId: string): Promise { }); if (!response.ok) { - console.error("[Token] Refresh failed:", response.status); + console.warn("[Token] Refresh failed:", response.status); return null; } From 7396a893801aad5d8f297a97a94dddc0634e8f68 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Fri, 21 Nov 2025 09:35:55 +0100 Subject: [PATCH 26/27] format --- src/lib/auth/utils.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/lib/auth/utils.ts b/src/lib/auth/utils.ts index 4954b9b..e4d058c 100644 --- a/src/lib/auth/utils.ts +++ b/src/lib/auth/utils.ts @@ -99,6 +99,14 @@ export async function saveAccountToken(account: { refreshTokenExpiresAt?: Date | string | null; userId: string; }) { + console.log("[Save Token] Account data received:", { + hasAccessToken: !!account.accessToken, + hasRefreshToken: !!account.refreshToken, + userId: account.userId, + accessTokenExpiresAt: account.accessTokenExpiresAt, + refreshTokenExpiresAt: account.refreshTokenExpiresAt, + }); + if (account.accessToken && account.userId) { const expiresAt = account.accessTokenExpiresAt ? new Date(account.accessTokenExpiresAt).getTime() @@ -116,8 +124,23 @@ export async function saveAccountToken(account: { 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", + ); } } From b1100e38a66d56a8b95af4b82ef90dd2733c3ab0 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Fri, 21 Nov 2025 09:39:41 +0100 Subject: [PATCH 27/27] test: update use cases --- src/lib/auth/__tests__/token.test.ts | 10 ++++++---- src/lib/auth/utils.ts | 8 -------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/lib/auth/__tests__/token.test.ts b/src/lib/auth/__tests__/token.test.ts index 32ebe91..6e750b1 100644 --- a/src/lib/auth/__tests__/token.test.ts +++ b/src/lib/auth/__tests__/token.test.ts @@ -58,11 +58,13 @@ vi.mock("better-auth/plugins", () => ({ 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"), @@ -138,7 +140,7 @@ describe("token", () => { const token = await getValidOidcToken(userId); expect(token).toBeNull(); - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(consoleWarnSpy).toHaveBeenCalledWith( "[Token] Refresh failed:", 401, ); @@ -235,7 +237,7 @@ describe("token", () => { const token = await getValidOidcToken(userId); expect(token).toBeNull(); - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(consoleWarnSpy).toHaveBeenCalledWith( "[Token] Refresh failed:", 401, ); @@ -254,7 +256,7 @@ describe("token", () => { const token = await getValidOidcToken(userId); expect(token).toBeNull(); - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(consoleWarnSpy).toHaveBeenCalledWith( "[Token] Refresh failed:", 500, ); @@ -329,7 +331,7 @@ describe("token", () => { expect(token1).toBeNull(); expect(token2).toBeNull(); - expect(consoleErrorSpy).toHaveBeenCalledTimes(2); + expect(consoleWarnSpy).toHaveBeenCalledTimes(2); }); }); }); diff --git a/src/lib/auth/utils.ts b/src/lib/auth/utils.ts index e4d058c..fdaa521 100644 --- a/src/lib/auth/utils.ts +++ b/src/lib/auth/utils.ts @@ -99,14 +99,6 @@ export async function saveAccountToken(account: { refreshTokenExpiresAt?: Date | string | null; userId: string; }) { - console.log("[Save Token] Account data received:", { - hasAccessToken: !!account.accessToken, - hasRefreshToken: !!account.refreshToken, - userId: account.userId, - accessTokenExpiresAt: account.accessTokenExpiresAt, - refreshTokenExpiresAt: account.refreshTokenExpiresAt, - }); - if (account.accessToken && account.userId) { const expiresAt = account.accessTokenExpiresAt ? new Date(account.accessTokenExpiresAt).getTime()