Skip to content

Commit 9442bec

Browse files
authored
feat(auth): handle refresh token (#67)
* feat: handle refresh token * refactor: move to constants file * refactor: env var * refactor: use NEXT_PUBLIC_OIDC_PROVIDER_ID as unique env var * refactor: update doc for env vars * lint and format * feat: configure hey-api client passing the bearer token to api services * feat(auth): handle refresh token * refactor: refresh token diagram flow * fix: remove leftover catalogue page * fix: api-client new instance * fix: raise console error in case of missing token or userId * fix: redirect to sign in if refresh token is expired * 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 * refactor: move to util reusable account auth info * fix: update env var * fix: remove throw error on better auth secret env var * fix: remove throw error on api base url env var * refactor: comment * fix: types * leftover * feat(card): add server card component * fix: client api on server actions * chore: pnpm script for run real oidc + msw * fix: redirect issue * format * test: update use cases
1 parent 025daa5 commit 9442bec

File tree

29 files changed

+1497
-505
lines changed

29 files changed

+1497
-505
lines changed

AGENTS.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,8 @@ export async function createServer(formData: FormData) {
276276
### Using Generated API Client
277277

278278
**⚠️ IMPORTANT:**
279-
- Never edit files in `src/generated/*`** - they are auto-generated and will be overwritten
279+
280+
- Never edit files in `src/generated/*`\*\* - they are auto-generated and will be overwritten
280281
- **Always use server actions** - Client components should not call the API directly
281282
- The API client is server-side only (no `NEXT_PUBLIC_` env vars needed)
282283

@@ -325,7 +326,13 @@ pnpm generate-client # Fetch swagger.json and regenerate
325326

326327
- OIDC provider agnostic
327328
- Stateless JWT authentication
328-
- Environment variables: `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`
329+
- Environment variables:
330+
- `OIDC_ISSUER_URL` - OIDC provider URL
331+
- `OIDC_CLIENT_ID` - OAuth2 client ID
332+
- `OIDC_CLIENT_SECRET` - OAuth2 client secret
333+
- `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.
334+
- `BETTER_AUTH_URL` - Application base URL
335+
- `BETTER_AUTH_SECRET` - Secret for token encryption
329336

330337
### Development
331338

CLAUDE.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ pnpm generate-client:nofetch # Regenerate without fetching
205205
### Mocking & Testing
206206

207207
- **MSW Auto-Mocker**
208+
208209
- Auto-generates handlers from `swagger.json` and creates fixtures under `src/mocks/fixtures` on first run.
209210
- Strict validation with Ajv + ajv-formats; fixtures are type-checked against `@api/types.gen` by default.
210211
- 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
274275
### Authentication Not Working
275276

276277
- **Development**: Ensure OIDC mock is running (`pnpm oidc`)
277-
- **Production**: Check environment variables (OIDC_ISSUER, CLIENT_ID, etc.)
278+
- **Production**: Check environment variables:
279+
- `OIDC_ISSUER_URL` - OIDC provider URL
280+
- `OIDC_CLIENT_ID` - OAuth2 client ID
281+
- `OIDC_CLIENT_SECRET` - OAuth2 client secret
282+
- `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.
283+
- `BETTER_AUTH_URL` - Application base URL
284+
- `BETTER_AUTH_SECRET` - Secret for token encryption
278285

279286
### API Calls Failing
280287

biome.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
2+
"$schema": "https://biomejs.dev/schemas/2.3.6/schema.json",
33
"vcs": {
44
"enabled": true,
55
"clientKind": "git",
@@ -11,10 +11,20 @@
1111
},
1212
"overrides": [
1313
{
14-
"includes": ["src/generated/core/bodySerializer.gen.ts"],
14+
"includes": ["src/generated/**"],
1515
"linter": {
1616
"enabled": false
1717
}
18+
},
19+
{
20+
"includes": ["src/mocks/**"],
21+
"linter": {
22+
"rules": {
23+
"suspicious": {
24+
"noConsole": "off"
25+
}
26+
}
27+
}
1828
}
1929
],
2030
"formatter": {

dev-auth/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ The provider is pre-configured with:
4040

4141
Replace this with a real OIDC provider (Okta, Keycloak, Auth0, etc.) by updating the environment variables in `.env.local`:
4242

43-
- `OIDC_ISSUER`
44-
- `OIDC_CLIENT_ID`
45-
- `OIDC_CLIENT_SECRET`
43+
- `OIDC_ISSUER_URL` - OIDC provider URL
44+
- `OIDC_CLIENT_ID` - OAuth2 client ID
45+
- `OIDC_CLIENT_SECRET` - OAuth2 client secret
46+
- `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.
47+
- `BETTER_AUTH_URL` - Application base URL (e.g., `http://localhost:3000`)
48+
- `BETTER_AUTH_SECRET` - Secret for token encryption

dev-auth/oidc-provider.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const configuration = {
3232
"http://localhost:3001/api/auth/oauth2/callback/oidc",
3333
"http://localhost:3002/api/auth/oauth2/callback/oidc",
3434
"http://localhost:3003/api/auth/oauth2/callback/oidc",
35+
"http://localhost:3000/api/auth/oauth2/callback/okta",
3536
],
3637
response_types: ["code"],
3738
grant_types: ["authorization_code", "refresh_token"],

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"scripts": {
77
"dev": "concurrently -n \"OIDC,Mock,Next\" -c \"blue,magenta,green\" \"pnpm oidc\" \"pnpm mock:server\" \"pnpm dev:next\"",
88
"dev:next": "next dev",
9+
"dev:mock-server": "concurrently -n \"Mock,Next\" -c \"magenta,green\" \"pnpm mock:server\" \"pnpm dev:next\"",
910
"build": "next build",
1011
"start": "next start",
1112
"lint": "biome check",
@@ -22,6 +23,7 @@
2223
"dependencies": {
2324
"@radix-ui/react-avatar": "^1.1.11",
2425
"@radix-ui/react-dropdown-menu": "^2.1.16",
26+
"@radix-ui/react-slot": "^1.2.4",
2527
"ajv": "^8.17.1",
2628
"ajv-formats": "^3.0.1",
2729
"better-auth": "1.4.0-beta.25",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { cookies } from "next/headers";
2+
import type { NextRequest } from "next/server";
3+
import { NextResponse } from "next/server";
4+
import { refreshAccessToken } from "@/lib/auth/auth";
5+
import { BETTER_AUTH_SECRET, COOKIE_NAME } from "@/lib/auth/constants";
6+
import type { OidcTokenData } from "@/lib/auth/types";
7+
import { decrypt } from "@/lib/auth/utils";
8+
9+
/**
10+
* API Route Handler to refresh OIDC access token.
11+
*
12+
* This Route Handler can modify cookies (unlike Server Actions during render).
13+
*/
14+
export async function POST(request: NextRequest) {
15+
try {
16+
const body = await request.json();
17+
const { userId } = body;
18+
19+
if (!userId) {
20+
return NextResponse.json({ error: "Missing userId" }, { status: 400 });
21+
}
22+
23+
const cookieStore = await cookies();
24+
const encryptedCookie = cookieStore.get(COOKIE_NAME);
25+
26+
if (!encryptedCookie?.value) {
27+
return NextResponse.json({ error: "No token found" }, { status: 401 });
28+
}
29+
30+
let tokenData: OidcTokenData;
31+
try {
32+
tokenData = await decrypt(encryptedCookie.value, BETTER_AUTH_SECRET);
33+
} catch (error) {
34+
console.error("[Refresh API] Token decryption failed:", error);
35+
cookieStore.delete(COOKIE_NAME);
36+
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
37+
}
38+
39+
if (tokenData.userId !== userId) {
40+
console.error("[Refresh API] Token userId mismatch");
41+
cookieStore.delete(COOKIE_NAME);
42+
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
43+
}
44+
45+
if (!tokenData.refreshToken) {
46+
console.error("[Refresh API] No refresh token available");
47+
cookieStore.delete(COOKIE_NAME);
48+
return NextResponse.json({ error: "No refresh token" }, { status: 401 });
49+
}
50+
51+
// Call refreshAccessToken which will save the new token in the cookie
52+
const refreshedData = await refreshAccessToken(
53+
tokenData.refreshToken,
54+
userId,
55+
tokenData.refreshTokenExpiresAt,
56+
);
57+
58+
if (!refreshedData) {
59+
console.error("[Refresh API] Token refresh failed");
60+
cookieStore.delete(COOKIE_NAME);
61+
return NextResponse.json(
62+
{ error: "[Refresh API] Refresh failed" },
63+
{ status: 401 },
64+
);
65+
}
66+
67+
return NextResponse.json({
68+
success: true,
69+
accessToken: refreshedData.accessToken,
70+
});
71+
} catch (error) {
72+
console.error("[Refresh API] Error during token refresh:", error);
73+
return NextResponse.json(
74+
{ error: "Internal server error" },
75+
{ status: 500 },
76+
);
77+
}
78+
}

src/app/catalog/actions.ts

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,54 @@
11
"use server";
22

3-
import { getRegistryV01Servers } from "@/generated/sdk.gen";
3+
import type { V0ServerJson } from "@/generated/types.gen";
4+
import { getAuthenticatedClient } from "@/lib/api-client";
5+
6+
export async function getServers(): Promise<V0ServerJson[]> {
7+
const api = await getAuthenticatedClient();
8+
const resp = await api.getRegistryV01Servers({
9+
client: api.client,
10+
});
11+
12+
if (resp.error) {
13+
console.error("[catalog] Failed to fetch servers:", resp.error);
14+
return [];
15+
}
16+
17+
if (!resp.data) {
18+
return [];
19+
}
20+
21+
const data = resp.data;
22+
const items = Array.isArray(data?.servers) ? data.servers : [];
23+
24+
// Extract the server objects from the response
25+
return items
26+
.map((item) => item?.server)
27+
.filter((server): server is V0ServerJson => server != null);
28+
}
429

530
export async function getServersSummary() {
6-
try {
7-
const resp = await getRegistryV01Servers();
8-
const data = resp.data;
9-
const items = Array.isArray(data?.servers) ? data.servers : [];
10-
11-
const titles = items
12-
.map((it) => it?.server?.title ?? it?.server?.name)
13-
.filter((t): t is string => typeof t === "string")
14-
.slice(0, 5);
15-
16-
const sample = items.slice(0, 5).map((it) => ({
17-
title: it?.server?.title ?? it?.server?.name ?? "Unknown",
18-
name: it?.server?.name ?? "unknown",
19-
version: it?.server?.version,
20-
}));
21-
22-
return { count: items.length, titles, sample };
23-
} catch (error) {
24-
// Log the error for debugging
25-
console.error("[catalog] Failed to fetch servers:", error);
31+
const api = await getAuthenticatedClient();
32+
const resp = await api.getRegistryV01Servers({ client: api.client });
33+
34+
if (resp.error) {
35+
console.error("[catalog] Failed to fetch servers:", resp.error);
36+
return { count: 0, titles: [], sample: [] };
37+
}
38+
39+
if (!resp.data) {
2640
return { count: 0, titles: [], sample: [] };
2741
}
42+
43+
const items = Array.isArray(resp.data?.servers) ? resp.data.servers : [];
44+
45+
const sample = items.slice(0, 5).map((it) => ({
46+
title: it?.server?.title ?? it?.server?.name ?? "Unknown",
47+
name: it?.server?.name ?? "unknown",
48+
version: it?.server?.version,
49+
}));
50+
51+
const titles = sample.map((s) => s.title);
52+
53+
return { count: items.length, titles, sample };
2854
}

src/app/catalogue/page.tsx

Lines changed: 0 additions & 74 deletions
This file was deleted.

0 commit comments

Comments
 (0)