Skip to content

Instantly share code, notes, and snippets.

@mcihad
Last active March 12, 2026 19:48
Show Gist options
  • Select an option

  • Save mcihad/363569d6796a1553a602ca8afddc4d40 to your computer and use it in GitHub Desktop.

Select an option

Save mcihad/363569d6796a1553a602ca8afddc4d40 to your computer and use it in GitHub Desktop.

AkilliSehir OpenID + Next.js Auth Rehberi

Bu dokuman, bu projede calisan kimlik dogrulama mimarisini birebir anlatir ve ayni yaklasimi yeni Next.js projelerine tasimak icin hazirlanmistir. Tüm kod örnekleri bu projenin gerçek çalışan kodudur.

Kapsam:

  • NextAuth v5 (beta) + OIDC provider entegrasyonu
  • JWT tabanli session yonetimi
  • Access token suresi kontrolu ve refresh token akisi
  • Refresh token ile proaktif oturum yenileme
  • Oturum uzatma toast bildirimi
  • Role/roles claim okuma ve role normalization
  • Role bazli menuler ve endpoint/sayfa korumasi

1) Mimari Özeti

Kullanıcı
   │
   ▼
/giris sayfası  ──signIn('akillisehir')──▶  OIDC Provider (AkilliSehir)
                                                    │
                                        access_token + refresh_token
                                                    │
                                                    ▼
                                          auth.ts — jwt() callback
                                          (token + roller JWT'ye yazılır)
                                                    │
                                                    ▼
                                          auth.ts — session() callback
                                          (client'a güvenli aktarım)
                                                    │
                          ┌─────────────────────────┤
                          │                         │
                          ▼                         ▼
                  SessionProvider             Server Actions
                  (client watcher)            (auth() ile rol kontrolü)
                          │
              ┌───────────┴───────────┐
              │                       │
    Token süresi dolmak üzere     Hata durumu
    ──update({forceRefresh:true})─▶ jwt() callback
              │                       │
              ▼                       ▼
    Refresh başarılı          SessionExpired
    ──toast "Uzatıldı"──      ──signOut + /giris──

Akış adımları:

  1. Kullanıcı /giris sayfasından OIDC provider'a yönlendirilir.
  2. Başarılı login sonrası NextAuth, access/refresh/id token bilgilerini JWT içine yazar.
  3. Her istekte jwt() callback access token süresini kontrol eder.
  4. Süre dolduysa discovery endpoint üzerinden token endpoint bulunur ve refresh yapılır.
  5. Refresh başarılı ise yeni tokenlar JWT'ye yazılır.
  6. Client tarafında token süresi bitmeden önce session.update({ forceRefresh: true }) ile yenileme tetiklenir.
  7. session() callback, rol ve token bilgilerini client tarafına güvenli şekilde aktarır.
  8. Yenileme başarılıysa kullanıcıya "Oturum süresi uzatıldı" toast'ı gösterilir.

2) Gerekli Ortam Değişkenleri

.env.local dosyası (asla commit edilmez):

AUTH_SECRET=...              # openssl rand -base64 32
AUTH_TRUST_HOST=true
AUTH_URL=http://localhost:3000
AUTH_OPENID_ISSUER=https://login.example.com/   # Sonda slash zorunlu!
AUTH_OPENID_CLIENT_ID=...
AUTH_OPENID_CLIENT_SECRET=...
DATABASE_URL=postgresql://...

Notlar:

  • AUTH_OPENID_ISSUER sonda slash olacak şekilde verilmeli.
  • Kod gelen değerde slash eksik olsa da discovery URL oluştururken güvenle tamamlar (URL constructor ile).

3) Temel Dosya Yapısı

auth.ts                                   ← NextAuth ana yapılandırması
proxy.ts                                  ← Route koruması (middleware gibi)
types/next-auth.d.ts                      ← TypeScript tip genişletmeleri
app/api/auth/[...nextauth]/route.ts       ← NextAuth HTTP handler
components/providers/session-provider.tsx ← Client: token izleme + toast

4) Type Tanımları — types/next-auth.d.ts

NextAuth'un Session ve JWT arayüzleri bu dosyayla genişletilir. Bu olmadan TypeScript session.accessToken veya session.user.roles alanlarını tanımaz.

// types/next-auth.d.ts
import "next-auth";

type SessionDepartment = {
  departmentId: string;
  department: string;
};

declare module "next-auth" {
  interface Session {
    user: {
      id?: string;
      name?: string | null;
      email?: string | null;
      image?: string | null;
      roles?: string[];
      departmentIds?: string[];
      departments?: SessionDepartment[];
    };
    accessToken?: string;
    accessTokenExpires?: number;
    tokenRefreshedAt?: number;
    error?: string;
  }

  interface User {
    roles?: string[];
    departmentIds?: string[];
    departments?: SessionDepartment[];
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    roles?: string[];
    accessToken?: string;
    refreshToken?: string;
    idToken?: string;
    accessTokenExpires?: number;
    tokenRefreshedAt?: number;
    error?: string;
    departmentIds?: string[];
    departments?: SessionDepartment[];
  }
}

5) OIDC Provider Yapılandırması — auth.ts

5.1 Provider Tanımı

// auth.ts
import NextAuth from "next-auth";

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    {
      id: "akillisehir",
      name: "Akıllı Şehir",
      type: "oidc",
      issuer: process.env.AUTH_OPENID_ISSUER,
      clientId: process.env.AUTH_OPENID_CLIENT_ID,
      clientSecret: process.env.AUTH_OPENID_CLIENT_SECRET,
      authorization: {
        params: {
          // offline_access → refresh token alabilmek için zorunlu
          scope: "openid profile email roles offline_access",
        },
      },
      profile(profile) {
        return {
          id: String(profile.sub ?? "unknown"),
          // name için üçlü fallback zinciri
          name:
            profile.name ??
            profile.preferred_username ??
            profile.email ??
            "Kullanıcı",
          email: profile.email ?? null,
          image: null,
        };
      },
    },
  ],
  session: { strategy: "jwt" },
  pages: { signIn: "/giris" },
  trustHost: true,
  // ...callbacks aşağıda
});

5.2 Department/Birim Claim'lerini Normalize Eden Yardımcılar

OIDC token'ından gelen ham birim verisi tutarsız olabileceği için iki yardımcı fonksiyon normalize işlemini üstlenir:

// auth.ts — export'ların üzerinde tanımlı

type RawDepartmentClaim = {
  department_id?: unknown;
  department?: unknown;
};

// department_ids: string[] → boşları filtreler ve trim uygular
function normalizeDepartmentIds(value: unknown): string[] {
  if (!Array.isArray(value)) return [];
  return value
    .filter((item): item is string => typeof item === "string" && item.trim().length > 0)
    .map((item) => item.trim());
}

// departments: [{department_id, department}][] → temiz obje dizisi
function normalizeDepartments(value: unknown) {
  if (!Array.isArray(value)) return [];
  return value.flatMap((item) => {
    if (typeof item !== "object" || item === null) return [];
    const claim = item as RawDepartmentClaim;
    const departmentId =
      typeof claim.department_id === "string" ? claim.department_id.trim() : "";
    const department =
      typeof claim.department === "string" ? claim.department.trim() : "";
    if (!departmentId || !department) return [];
    return [{ departmentId, department }];
  });
}

6) JWT Callback — Token Yönetiminin Kalbi

Bu callback her oturum kontrolünde çalışır. Üç farklı senaryo ele alınır.

6.1 İlk Giriş — Token'ları JWT'ye Yaz

// auth.ts — callbacks.jwt
async jwt({ token, account, profile, trigger, session }) {
  const forceRefresh =
    trigger === "update" &&
    Boolean((session as { forceRefresh?: boolean } | undefined)?.forceRefresh);
  const now = Date.now();

  // İlk giriş: account ve profile nesneleri gelir
  if (account && profile) {
    token.accessToken  = account.access_token;
    token.refreshToken = account.refresh_token;
    token.idToken      = account.id_token;
    // OpenID'den gelen expires_at saniye cinsinden → ms'e çevir
    token.accessTokenExpires = account.expires_at
      ? account.expires_at * 1000
      : 0; // 0 ise bir sonraki istekte refresh denenir

    // Rol normalizasyonu: "ADMIN" veya ["ADMIN","DEPOCU"] → ["ADMIN","DEPOCU"]
    const raw =
      (profile as Record<string, unknown>).role ??
      (profile as Record<string, unknown>).roles;
    const arr = Array.isArray(raw) ? raw : raw ? [raw] : [];
    token.roles = arr.map((r: unknown) => String(r).toUpperCase());

    // Birim bilgileri
    token.departmentIds = normalizeDepartmentIds(
      (profile as Record<string, unknown>).department_ids
    );
    token.departments = normalizeDepartments(
      (profile as Record<string, unknown>).departments
    );

    token.tokenRefreshedAt = Date.now();
    token.error = undefined;
  }

6.2 Token Süresi Kontrolü

  // Token süresi dolmamışsa ve force refresh istenmemişse dokundurma
  if (
    !forceRefresh &&
    typeof token.accessTokenExpires === "number" &&
    now < token.accessTokenExpires
  ) {
    return token;
  }

  // Token süresi dolmuş + refresh token da yok → oturumu kapat
  if (
    typeof token.accessTokenExpires === "number" &&
    now >= token.accessTokenExpires &&
    !token.refreshToken
  ) {
    return {
      ...token,
      error: "SessionExpired",
      accessToken:  undefined,
      refreshToken: undefined,
      idToken:      undefined,
    };
  }

6.3 Refresh Token Akışı

  // Refresh token varsa yenilemeyi dene
  if (token.refreshToken) {
    try {
      const issuer = process.env.AUTH_OPENID_ISSUER ?? "";
      if (!issuer) return { ...token, error: "RefreshAccessTokenError" };

      // ⚠️ String birleştirme değil, URL constructor kullan!
      // Yanlış: issuer + "/.well-known/..." → path çakışması riski
      const discoveryUrl = new URL(
        ".well-known/openid-configuration",
        issuer.endsWith("/") ? issuer : `${issuer}/`
      );

      const discoveryRes = await fetch(discoveryUrl);
      if (!discoveryRes.ok) return { ...token, error: "RefreshAccessTokenError" };

      const discovery = await discoveryRes.json();

      const response = await fetch(discovery.token_endpoint, {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({
          grant_type:    "refresh_token",
          client_id:     process.env.AUTH_OPENID_CLIENT_ID ?? "",
          client_secret: process.env.AUTH_OPENID_CLIENT_SECRET ?? "",
          refresh_token: token.refreshToken as string,
        }),
      });

      const refreshedTokens = await response.json();

      if (!response.ok) {
        console.error("Token refresh failed:", refreshedTokens);
        // invalid_grant → refresh token artık geçersiz → oturumu kapat
        if (refreshedTokens?.error === "invalid_grant") {
          return {
            ...token,
            error:        "SessionExpired",
            accessToken:  undefined,
            refreshToken: undefined,
            idToken:      undefined,
          };
        }
        // Diğer hatalar (ağ, sunucu geçici) → session'ı düşürme
        return { ...token, error: "RefreshAccessTokenError" };
      }

      return {
        ...token,
        accessToken:  refreshedTokens.access_token,
        // Provider yeni refresh token dönmezse eskisini koru
        refreshToken: refreshedTokens.refresh_token ?? (token.refreshToken as string),
        idToken:      refreshedTokens.id_token ?? token.idToken,
        accessTokenExpires: refreshedTokens.expires_in
          ? Date.now() + refreshedTokens.expires_in * 1000
          : 0,
        tokenRefreshedAt: Date.now(), // ← client watcher bu değeri izler
        error: undefined,
      };
    } catch (error) {
      console.error("Token refresh error:", error);
      return { ...token, error: "RefreshAccessTokenError" };
    }
  }

  // Buraya ulaşıldıysa refresh token da yok → oturumu kapat
  return {
    ...token,
    error:        "SessionExpired",
    accessToken:  undefined,
    refreshToken: undefined,
    idToken:      undefined,
  };
},

Hata semantiği:

Hata Anlam Davranış
RefreshAccessTokenError Geçici ağ/discovery hatası Session düşürülmez, sonraki istekte tekrar denenir
SessionExpired invalid_grant veya token yokluğu Zorunlu signOut + /giris yönlendirmesi

7) Session Callback — Client'a Güvenli Aktarım

// auth.ts — callbacks.session
async session({ session, token }) {
  if (session.user) {
    const roles = Array.isArray(token.roles) ? (token.roles as string[]) : [];
    (session.user as unknown as { roles: string[] }).roles = roles;
    (session.user as unknown as { departmentIds: string[] }).departmentIds =
      Array.isArray(token.departmentIds) ? token.departmentIds : [];
    (session.user as unknown as {
      departments: Array<{ departmentId: string; department: string }>;
    }).departments = Array.isArray(token.departments) ? token.departments : [];
  }

  // API çağrıları için access token (Bearer header'da kullanılır)
  (session as unknown as Record<string, unknown>).accessToken        = token.accessToken;
  (session as unknown as Record<string, unknown>).accessTokenExpires = token.accessTokenExpires;
  // Client watcher bu timestamp'ı izler, artınca "Uzatıldı" toast'ı gösterir
  (session as unknown as Record<string, unknown>).tokenRefreshedAt   = token.tokenRefreshedAt;

  // Hata varsa client'a ilet (RefreshAccessTokenError veya SessionExpired)
  if (token.error) {
    (session as unknown as Record<string, unknown>).error = token.error;
  }
  return session;
},

8) authorized Callback — Route Erişim Kararı

// auth.ts — callbacks.authorized
authorized({ auth }) {
  // auth.user yoksa oturum açık değil → erişimi reddet (proxy.ts devreye girer)
  if (!auth?.user) return false;
  return true;
},

SessionExpired durumu burada ayrıca ele alınmaz. SessionRefreshWatcher bu durumda zorunlu signOut + /giris yönlendirmesi yapar.


11) Client Tarafı Oturum Yönetimi — session-provider.tsx

Bu dosya iki sorumluluk taşır:

  1. SessionRefreshWatcher — gizli bileşen, hiçbir şey render etmez, sadece izler.
  2. SessionProvider — tüm uygulamayı sarar, watcher'ı içerir.
// components/providers/session-provider.tsx
"use client";

import { SessionProvider as NextAuthSessionProvider } from "next-auth/react";
import { signOut, useSession } from "next-auth/react";
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import { toast } from "sonner";

// Token süresi bitmeden kaç ms önce refresh tetiklenir
const REFRESH_EARLY_MS = 60 * 1000;        // 1 dakika erken
// Zamanlayıcı için minimum gecikme (çok kısa döngü engellemek için)
const REFRESH_FALLBACK_DELAY_MS = 5 * 1000; // en az 5 saniye bekle

function SessionRefreshWatcher() {
  const { data: session, status, update } = useSession();
  const lastRefreshedAtRef = useRef<number | null>(null);
  const hasForcedLogoutRef = useRef(false);

  // ── Effect 1: Proaktif refresh zamanlayıcısı ──────────────────────────
  useEffect(() => {
    if (status !== "authenticated") return;

    const expiresAt = session?.accessTokenExpires;
    if (typeof expiresAt !== "number" || expiresAt <= 0) return;

    // Token zaten süresi dolmuşsa yeni döngü başlatma (Effect 2 üstlenir)
    if (Date.now() >= expiresAt) return;

    const refreshInMs = Math.max(
      expiresAt - Date.now() - REFRESH_EARLY_MS,
      REFRESH_FALLBACK_DELAY_MS
    );

    const timeout = window.setTimeout(() => {
      // jwt() callback'te forceRefresh: true algılanır → refresh akışı başlar
      void update({ forceRefresh: true });
    }, refreshInMs);

    return () => window.clearTimeout(timeout);
  }, [session?.accessTokenExpires, status, update]);

  // ── Effect 2: SessionExpired veya süresi dolmuş token → zorunlu çıkış ─
  useEffect(() => {
    if (status !== "authenticated") {
      hasForcedLogoutRef.current = false;
      return;
    }
    if (hasForcedLogoutRef.current) return; // çift tetiklenmeyi önle

    const expiresAt = session?.accessTokenExpires;
    const isTokenExpired =
      typeof expiresAt === "number" && expiresAt > 0 && Date.now() >= expiresAt;
    const isTerminalError = session?.error === "SessionExpired";

    if (!isTokenExpired && !isTerminalError) return;

    hasForcedLogoutRef.current = true;
    toast.error("Oturum süreniz doldu. Lütfen tekrar giriş yapın.");
    void signOut({ callbackUrl: "/giris" });
  }, [session?.accessTokenExpires, session?.error, status]);

  // ── Effect 3: Geçici refresh hatası → uyarı toast ─────────────────────
  useEffect(() => {
    if (status !== "authenticated") return;
    if (session?.error === "RefreshAccessTokenError") {
      toast.warning("Oturum yenileme denemesi başarısız oldu. Tekrar denenecek.");
    }
  }, [session?.error, status]);

  // ── Effect 4: Başarılı refresh → bilgi toast ──────────────────────────
  useEffect(() => {
    if (status !== "authenticated") return;

    const refreshedAt = session?.tokenRefreshedAt;
    if (typeof refreshedAt !== "number") return;

    // İlk yükleme değil, gerçekten yeni bir refresh olduysa göster
    if (
      lastRefreshedAtRef.current !== null &&
      refreshedAt > lastRefreshedAtRef.current
    ) {
      toast.success("Oturum süresi uzatıldı.");
    }
    lastRefreshedAtRef.current = refreshedAt;
  }, [session?.tokenRefreshedAt, status]);

  return null; // Hiçbir şey render etmez
}

export function SessionProvider({ children }: { children: ReactNode }) {
  return (
    <NextAuthSessionProvider>
      <SessionRefreshWatcher />
      {children}
    </NextAuthSessionProvider>
  );
}

SessionProvider root layout'ta kullanılır:

// app/layout.tsx
import { SessionProvider } from "@/components/providers/session-provider";

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <SessionProvider>
          {children}
        </SessionProvider>
      </body>
    </html>
  );
}

9) Route Handler — app/api/auth/[...nextauth]/route.ts

// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";

export const { GET, POST } = handlers;

NextAuth tüm OIDC callback, token exchange ve oturum API'lerini bu dosya üzerinden sunar. İki satırlık dosya — başka bir şey eklenmez.


10) Route Koruma — proxy.ts

// proxy.ts
export { auth as proxy } from "@/auth";

export const config = {
  matcher: [
    // Şu path'ler korumanın dışında:
    //   /giris       → login sayfası
    //   /api/auth    → NextAuth endpoint'leri
    //   _next/static → statik dosyalar
    //   _next/image  → görsel optimizasyon
    //   favicon.ico
    "/((?!giris|api/auth|_next/static|_next/image|favicon.ico).*)",
  ],
};

Bu dosya Next.js 16+'da middleware.ts yerine proxy.ts adıyla kullanılır. auth fonksiyonu middleware gibi çalışır; authorized callback false döndürene kadar eşleşen tüm route'lar oturum kontrolünden geçer.


12) Login Sayfası — app/giris/page.tsx

// app/giris/page.tsx
"use client";

import { signIn, useSession } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";

function LoginContent() {
  const { status } = useSession();
  const router = useRouter();
  const searchParams = useSearchParams();
  const error = searchParams.get("error");

  // Zaten giriş yapılmışsa dashboard'a yönlendir
  useEffect(() => {
    if (status === "authenticated") router.replace("/");
  }, [status, router]);

  return (
    // ...
    <Button onClick={() => signIn("akillisehir")}>
      Giriş Yap
    </Button>
    // ...
  );
}

signIn("akillisehir") çağrısındaki string, auth.ts'deki id: "akillisehir" ile tam eşleşmeli.


13) Rol Bazlı Yetkilendirme

13.1 Rol Tanımları — lib/nav-items.ts

// lib/nav-items.ts

// Roller — Prisma şemasıyla senkron tutulmalı (schema.prisma)
export const Role = {
  ADMIN:   "ADMIN",
  DEPOCU:  "DEPOCU",
  RESIM:   "RESIM",
  SAYIM:   "SAYIM",
  SAHA:    "SAHA",
  SORUMLU: "SORUMLU",
} as const;

export type Role = (typeof Role)[keyof typeof Role];

export interface NavItem {
  title: string;
  href: string;
  icon: LucideIcon;
  roles: Role[];   // Bu menü öğesine erişebilecek roller
}

// Menü öğelerine rol atama örneği:
export const navGroups: NavGroup[] = [
  {
    label: "Stok Yönetimi",
    items: [
      {
        title: "Stoklar",
        href: "/stoklar",
        icon: Package,
        roles: [Role.ADMIN, Role.DEPOCU, Role.SAHA],
      },
      {
        title: "Stok Fişleri",
        href: "/stok-fisleri",
        icon: FileText,
        roles: [Role.ADMIN, Role.DEPOCU],   // SAHA göremez
      },
      // ...
    ],
  },
];

13.2 Rol Filtresi

// lib/nav-items.ts
export function getNavGroupsForRoles(userRoles: string[]): NavGroup[] {
  return navGroups
    .map((group) => ({
      ...group,
      // Kullanıcının rollerinden en az biri item.roles içindeyse göster
      items: group.items.filter((item) =>
        item.roles.some((role) => userRoles.includes(role))
      ),
    }))
    .filter((group) => group.items.length > 0); // Boş grupları gizle
}

13.3 Sidebar'da Kullanımı (Client Component)

// components/layout/sidebar.tsx — ilgili kısım
const { data: session } = useSession();

const rawRoles = (session?.user as { roles?: string | string[] })?.roles;
const userRoles = Array.isArray(rawRoles)
  ? (rawRoles as string[])
  : rawRoles
    ? [rawRoles as string]
    : [];

// Kullanıcının rollerine göre filtrelenmiş menü grupları
const groups = getNavGroupsForRoles(userRoles);

13.4 Server Action'da Rol Kontrolü

UI gizlemek yeterli değildir — her server action'da ayrı sunucu tarafı kontrol zorunludur:

// app/(dashboard)/depolar/actions.ts
"use server";

import { auth } from "@/auth";
import { canManageDepolar, canViewDepolar } from "@/lib/depo-scope";

// ADMIN kontrolü için yardımcı — her action'da çağrılır
async function ensureAdmin() {
  const session = await auth();
  if (!canManageDepolar(session)) {
    throw new Error("Bu işlem için ADMIN yetkisi gerekli.");
  }
  return session;
}

// Görüntüleme kontrolü yardımcısı
async function ensureDepoViewAccess() {
  const session = await auth();
  if (!canViewDepolar(session)) {
    throw new Error("Bu modülü görüntüleme yetkiniz yok.");
  }
  return session;
}

// Kullanım — yetkisiz istek burada fırlatılır, client'a ulaşmaz
export async function createDepo(formData: FormData) {
  await ensureAdmin();
  // güvenli bölge: burası ADMIN'e ulaşır
}

export async function getDepolar() {
  await ensureDepoViewAccess();
  // güvenli bölge: burası ADMIN veya DEPOCU'ya ulaşır
}

13.5 Rol Yardımcı Fonksiyonları — lib/depo-scope.ts

// lib/depo-scope.ts
import "server-only"; // ← bu import client bundle'a sızmayı engeller

import type { Session } from "next-auth";

function readRoles(session: Session | null) {
  const rawRoles = (session?.user as { roles?: string[] } | undefined)?.roles;
  return Array.isArray(rawRoles) ? rawRoles : [];
}

export function canViewDepolar(session: Session | null) {
  const roles = readRoles(session);
  return roles.includes("ADMIN") || roles.includes("DEPOCU");
}

export function canManageDepolar(session: Session | null) {
  const roles = readRoles(session);
  return roles.includes("ADMIN"); // Sadece ADMIN değiştirebilir
}

"server-only" import'u, bu modülün bir "use client" bileşeninde import edilmesini derleme zamanında hata olarak işaretler. Rol mantığı client bundle'a asla sızmaz.


14) Yeni Projeye Taşıma Checklist

☐ 1.  next-auth v5 kur:  pnpm add next-auth@5.0.0-beta.30
☐ 2.  auth.ts oluştur (provider + jwt + session + authorized callbacks)
☐ 3.  Provider scope içine offline_access ekle (refresh token için zorunlu)
☐ 4.  types/next-auth.d.ts ile Session/JWT tiplerini genişlet
☐ 5.  app/api/auth/[...nextauth]/route.ts oluştur (2 satır)
☐ 6.  proxy.ts oluştur ve matcher'ı ayarla
☐ 7.  app/layout.tsx içinde <SessionProvider> ile sar
☐ 8.  SessionRefreshWatcher'ı SessionProvider içine göm
☐ 9.  Login sayfasında signIn('<provider-id>') tetikle
☐ 10. lib/nav-items.ts içinde Role sabitleri ve getNavGroupsForRoles tanımla
☐ 11. Her server action'da auth() ile rol kontrolü ekle
☐ 12. Rol yardımcılarını "server-only" ile işaretle

15) Sık Karşılaşılan Problemler ve Çözümler

Problem: getaddrinfo ENOTFOUND ...well-known

Sebep:

// ❌ Yanlış — path çakışması riski
const url = issuer + "/.well-known/openid-configuration";
// "https://login.example.com/realm" + "/.well-known/..." yanlış path verir

Çözüm:

// ✅ Doğru — URL constructor relative path'i doğru çözümler
const discoveryUrl = new URL(
  ".well-known/openid-configuration",
  issuer.endsWith("/") ? issuer : `${issuer}/`
);

Problem: invalid_grant geliyor ama session açık kalıyor

Sebep:

  • Refresh hatası sadece session.error set ediyor; authorized callback session'ı düşürmüyor.

Çözüm (kodda uygulandığı şekliyle):

// jwt() callback içinde
if (refreshedTokens?.error === "invalid_grant") {
  return {
    ...token,
    error:        "SessionExpired",   // ← özel hata kodu
    accessToken:  undefined,
    refreshToken: undefined,
    idToken:      undefined,
  };
}

// Client watcher bu hataya tepki verir:
const isTerminalError = session?.error === "SessionExpired";
if (isTerminalError) {
  void signOut({ callbackUrl: "/giris" }); // ← kullanıcı zorla çıkarılır
}

Problem: Roller bazen boş geliyor

Sebep:

  • Bazı OIDC provider'lar role (tekil string), bazıları roles (dizi) döndürür.

Çözüm (kodda uygulandığı şekliyle):

// jwt() callback — ilk giriş bloğu
const raw =
  (profile as Record<string, unknown>).role ??   // tekil string desteği
  (profile as Record<string, unknown>).roles;     // dizi desteği

const arr = Array.isArray(raw) ? raw : raw ? [raw] : []; // her zaman dizi
token.roles = arr.map((r: unknown) => String(r).toUpperCase()); // büyük harf

16) Güvenlik ve Operasyon Notları

  • AUTH_OPENID_CLIENT_SECRET asla client bundle'a sızmaz; jwt/session callback yalnızca sunucuda çalışır.
  • "server-only" import'u rol yardımcılarının client'a sızmasını derleme zamanında engeller.
  • JWT içinde yalnızca gerekli alanlar tutulur; raw token değerleri loglara yazılmaz.
  • RefreshAccessTokenErrorSessionExpired: geçici hatalarda kullanıcı atılmaz.
  • hasForcedLogoutRef çift logout döngüsünü önler.

17) Bu Projedeki Beklenen Davranış

Senaryo Davranış
Token geçerliyse JWT olduğu gibi döner, ek istek yapılmaz
Token süresi dolmak üzereyse (−1 dk) Client forceRefresh: true ile yenileme tetikler
Token süresi dolmuş, refresh başarılı Yeni token JWT'ye yazılır → toast: "Oturum süresi uzatıldı"
Geçici ağ/discovery hatası RefreshAccessTokenError set edilir, session düşürülmez
invalid_grant (refresh token geçersiz) SessionExpired → watcher signOut + /giris
Refresh token hiç yoksa Doğrudan SessionExpired → watcher signOut + /giris

Bu davranış seti, hem kullanıcı deneyimi hem güvenlik açısından dengeli ve tekrar kullanılabilir bir standart sunar.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment