Skip to Content
📘 ClubUp v0.1 — koncepčný návrh. Implementácia ešte nezačala.
Autentifikácia (SSO)OIDC Client setup (Auth.js v5)

OIDC Client setup (Auth.js v5)

Konfigurácia ClubUp ako OIDC client pre auth.sportup.sk.

Knižnica

Auth.js v5 (predtým NextAuth.js). Zvolené, lebo:

  • Native Next.js 15 App Router support
  • Generic OIDC provider config (žiadny lock-in na konkrétny IdP)
  • Dobrá TypeScript podpora
  • Manage session cez encrypted cookies (JWE)
  • Built-in CSRF, PKCE, nonce, state handling

Inštalácia

npm install next-auth@beta

Konfigurácia

packages/auth/index.ts

import NextAuth from 'next-auth'; import type { NextAuthConfig } from 'next-auth'; export const authConfig: NextAuthConfig = { providers: [ { id: 'sportup', name: 'SportUp', type: 'oidc', issuer: process.env.SPORTUP_OIDC_ISSUER!, clientId: process.env.SPORTUP_OIDC_CLIENT_ID!, clientSecret: process.env.SPORTUP_OIDC_CLIENT_SECRET!, authorization: { params: { scope: 'openid profile email sportup_roles', }, }, // PKCE je default v Auth.js v5 checks: ['pkce', 'state', 'nonce'], profile(profile) { return { id: profile.sub, name: profile.name, email: profile.email, image: profile.picture, // Custom claims sportupPersonId: profile.sub, roles: parseRoles(profile.sportup_roles ?? []), }; }, }, ], callbacks: { async jwt({ token, user, account, trigger }) { // First sign-in if (user && account) { token.sportupPersonId = user.sportupPersonId; token.roles = user.roles; token.accessToken = account.access_token; token.refreshToken = account.refresh_token; token.accessTokenExpiresAt = account.expires_at! * 1000; } // Refresh access token if expiring soon if (Date.now() > (token.accessTokenExpiresAt as number) - 60_000) { return await refreshAccessToken(token); } return token; }, async session({ session, token }) { session.user.sportupPersonId = token.sportupPersonId as string; session.user.roles = token.roles as string[]; session.accessToken = token.accessToken as string; return session; }, }, session: { strategy: 'jwt', // JWE-encrypted cookie maxAge: 60 * 60 * 24, // 24h }, cookies: { sessionToken: { name: '__Secure-clubup.session', options: { httpOnly: true, sameSite: 'lax', path: '/', secure: process.env.NODE_ENV === 'production', }, }, }, pages: { signIn: '/login', error: '/login/error', }, }; export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);

Použitie v apps/app

// apps/app/auth.ts export { handlers, auth, signIn, signOut } from '@clubup/auth';
// apps/app/app/api/auth/[...nextauth]/route.ts export { GET, POST } from '@clubup/auth/handlers';
// apps/app/middleware.ts export { auth as middleware } from '@clubup/auth'; export const config = { matcher: ['/(dashboard|profil|kurzy)/:path*'], };

Refresh token rotation

// packages/auth/refresh.ts async function refreshAccessToken(token: any) { try { const response = await fetch(`${process.env.SPORTUP_OIDC_ISSUER}/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: process.env.SPORTUP_OIDC_CLIENT_ID!, client_secret: process.env.SPORTUP_OIDC_CLIENT_SECRET!, grant_type: 'refresh_token', refresh_token: token.refreshToken, }), }); const tokens = await response.json(); if (!response.ok) throw tokens; return { ...token, accessToken: tokens.access_token, accessTokenExpiresAt: Date.now() + tokens.expires_in * 1000, refreshToken: tokens.refresh_token ?? token.refreshToken, }; } catch (error) { return { ...token, error: 'RefreshAccessTokenError' }; } }

Pri RefreshAccessTokenError sa user vyhodí na /login (middleware to detekuje cez session.error).

Type safety

// packages/auth/types.d.ts import 'next-auth'; import type { Role } from './rbac'; declare module 'next-auth' { interface Session { user: { sportupPersonId: string; name: string; email: string; image?: string; roles: Role[]; }; accessToken: string; error?: 'RefreshAccessTokenError'; } interface User { sportupPersonId: string; roles: Role[]; } } declare module 'next-auth/jwt' { interface JWT { sportupPersonId: string; roles: Role[]; accessToken: string; refreshToken: string; accessTokenExpiresAt: number; } }

Discovery & JWKS

Auth.js automaticky:

  • Volá ${issuer}/.well-known/openid-configuration pri prvom requeste
  • Cachuje výsledok 24h
  • Sťahuje JWKS z jwks_uri a verifikuje podpis ID tokenu
  • Cachuje JWKS 24h, refresh pri unknown kid

Environment variables

VariablePríkladPopis
SPORTUP_OIDC_ISSUERhttps://auth.sportup.skOIDC issuer URL
SPORTUP_OIDC_CLIENT_IDclubup-app-prodID priradený SportUp adminom
SPORTUP_OIDC_CLIENT_SECRET...Secret (Vercel env, nikdy v kóde)
AUTH_SECRET(random 32 bytes base64)Pre encryption session cookie
AUTH_URLhttps://app.clubup.skBase URL aplikácie

Pre apps/admin rovnaké, ale AUTH_URL=https://admin.clubup.sk.

Príklad — chránená Server Action

'use server'; import { auth } from '@/auth'; export async function purchaseCourse(courseId: string) { const session = await auth(); if (!session) throw new Error('UNAUTHORIZED'); return await createOrder({ studentId: session.user.sportupPersonId, courseId, }); }

Príklad — chránený Route Handler

import { auth } from '@/auth'; export async function GET(req: Request) { const session = await auth(); if (!session) { return Response.json({ error: 'unauthorized' }, { status: 401 }); } const data = await loadData(session.user.sportupPersonId); return Response.json(data); }

Troubleshooting

ProblemPríčinaRiešenie
nonce mismatchBrowser zablokoval cookiesSkontroluj SameSite, Secure flags
state mismatchUser otvoril 2 prihl. oknáUX: detect a redirect na nový login
invalid_clientBad client_id/secret v envSkontroluj Vercel env vars
redirect_uri_mismatchURL nesedí s whitelistomPošli SportUp adminovi presný URL
JWKS fetch errorDiscovery doc nedostupnýSkontroluj sieť, fallback na cached