Skip to content

Instantly share code, notes, and snippets.

@mcihad
Last active March 15, 2026 20:29
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 v4 + 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
  • Sunucu taraflı OIDC logout + token revocation

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──

─── Logout Akışı ──────────────────────────────────────────────────────

/cikis sayfası — "Çıkış Yap" butonu
   │
   ▼
POST /api/auth/logout                         ← sunucu tarafı işlem
   ├─▶ getToken() ile JWT'den refresh+access+id_token okunur
   ├─▶ revocation_endpoint → refresh_token iptal edilir
   ├─▶ revocation_endpoint → access_token iptal edilir
  └─▶ end_session_endpoint URL'si oluşturulur (client_id + id_token_hint + post_logout_redirect_uri)
   │
   ▼
signOut({ redirect: false })                  ← NextAuth yerel cookie temizlenir
   │
   ▼
window.location.href = endSessionUrl          ← tarayıcı OIDC provider'a yönlendirilir
   │
   ▼
OIDC Provider — /Authorization/Logout        ← SSO oturumu sunucuda kapatılır
   │                                            (diğer uygulamalar da çıkış yapar)
   ▼
post_logout_redirect_uri → /giris            ← kullanıcı giriş sayfasına döner

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.
  9. Logout: Sunucu tarafında token'lar iptal edilir, OIDC end_session ile SSO oturumu kapatılır.

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=...
AUTH_OPENID_POST_LOGOUT_REDIRECT_URI=http://localhost:3001/giris  # Provider'da kayıtlı tam URI
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).
  • AUTH_OPENID_POST_LOGOUT_REDIRECT_URI provider'da kayıtlı olan tam URI ile birebir eşleşmeli. OpenIDDict strict matching yapar; küçük fark (port, trailing slash) ID2052 hatasına yol açar.
  • Bu değer verilmezse fallback olarak aktif isteğin origin'i kullanılır: req.nextUrl.origin + /giris.

3) Temel Dosya Yapısı

lib/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
app/api/auth/logout/route.ts                  ← Sunucu taraflı token revocation + end_session URL
app/cikis/page.tsx                            ← Logout sayfası (OIDC logout akışını tetikler)
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","SAHA"] → ["ADMIN","SAHA"]
    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,
  };
},

6.4 Audience (aud) İçinde client_id Doğrulaması

Token doğrulamada sadece süre (exp) kontrolü yeterli değildir. aud claim'i içinde uygulamanın client_id değerinin bulunması da doğrulanmalıdır.

aud hem tek string hem dizi olabilir:

function hasExpectedAudience(audience: unknown, expectedClientId: string): boolean {
  if (typeof audience === "string") {
    return audience.trim() === expectedClientId;
  }

  if (Array.isArray(audience)) {
    return audience.some(
      (item) => typeof item === "string" && item.trim() === expectedClientId
    );
  }

  return false;
}

Bu kontrolde sıralama:

  1. Önce access_token içindeki aud okunur.
  2. access_token'da aud yoksa id_token içindeki aud okunur.
  3. aud bulunamazsa veya client_id eşleşmezse oturum SessionExpired olarak kapatılır.

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) Sunucu Taraflı Logout + Token Revocation — app/api/auth/logout/route.ts

Neden Gerekli?

NextAuth'un signOut() yalnızca yerel JWT cookie'sini siler. OIDC provider'da:

  • Refresh token geçerliliğini korur — başkası ele geçirirse yeniden kullanabilir.
  • SSO oturumu açık kalır — aynı provider'ı kullanan diğer uygulamalar oturumu hâlâ aktif görür.

Sunucu taraflı logout üç işlemi sırayla yapar:

  1. revocation_endpoint ile refresh token'ı iptal et.
  2. revocation_endpoint ile access token'ı iptal et.
  3. Tarayıcıyı end_session_endpoint'e yönlendir (SSO oturumunu kapat).

Uygulama — app/api/auth/logout/route.ts

import { getToken } from "next-auth/jwt";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

import { env } from "@/lib/env";

export async function POST(req: NextRequest) {
    const token = await getToken({ req, secret: env.server.NEXTAUTH_SECRET });

    if (!token) {
        return NextResponse.json({ error: "Oturum bulunamadı" }, { status: 401 });
    }

    // Discovery'den canlı endpoint'leri al
    let endSessionEndpoint: string | undefined;
    let revocationEndpoint: string | undefined;

    try {
        const discoveryRes = await fetch(env.server.AUTH_OPENID_WELL_KNOWN, {
            cache: "no-store",
        });
        if (discoveryRes.ok) {
            const discovery = (await discoveryRes.json()) as {
                end_session_endpoint?: string;
                revocation_endpoint?: string;
            };
            endSessionEndpoint = discovery.end_session_endpoint;
            revocationEndpoint = discovery.revocation_endpoint;
        }
    } catch {
        // Discovery başarısız olsa da yerel oturum temizlenir
    }

    // Refresh token'ı iptal et (gelecekteki tüm yenileme girişimlerini engeller)
    if (revocationEndpoint && token.refreshToken) {
        try {
            await fetch(revocationEndpoint, {
                method: "POST",
                headers: { "Content-Type": "application/x-www-form-urlencoded" },
                body: new URLSearchParams({
                    client_id: env.server.AUTH_OPENID_CLIENT_ID,
                    client_secret: env.server.AUTH_OPENID_CLIENT_SECRET,
                    token: token.refreshToken,
                    token_type_hint: "refresh_token",
                }),
                cache: "no-store",
            });
        } catch { /* Sessizce devam et */ }
    }

    // Access token'ı iptal et
    if (revocationEndpoint && token.accessToken) {
        try {
            await fetch(revocationEndpoint, {
                method: "POST",
                headers: { "Content-Type": "application/x-www-form-urlencoded" },
                body: new URLSearchParams({
                    client_id: env.server.AUTH_OPENID_CLIENT_ID,
                    client_secret: env.server.AUTH_OPENID_CLIENT_SECRET,
                    token: token.accessToken,
                    token_type_hint: "access_token",
                }),
                cache: "no-store",
            });
        } catch { /* Sessizce devam et */ }
    }

    // Tarayıcı yönlendirmesi için OIDC end_session URL'si oluştur
    let endSessionUrl: string | null = null;
    if (endSessionEndpoint) {
        const url = new URL(endSessionEndpoint);
      url.searchParams.set("client_id", env.server.AUTH_OPENID_CLIENT_ID);
        if (token.idToken) {
            // id_token_hint: provider hangi kullanıcının SSO oturumunu kapatacağını anlar
            url.searchParams.set("id_token_hint", token.idToken);
        }
      const postLogoutRedirectUri =
        env.server.AUTH_OPENID_POST_LOGOUT_REDIRECT_URI ??
        `${req.nextUrl.origin}/giris`;
      url.searchParams.set("post_logout_redirect_uri", postLogoutRedirectUri);
        endSessionUrl = url.toString();
    }

    return NextResponse.json({ endSessionUrl });
}

Client Tarafı Logout Akışı — app/cikis/page.tsx

async function handleLogout() {
    setLoading(true);
    try {
        // 1) Sunucu tarafında token'ları iptal et, end_session URL'sini al
        const res = await fetch("/api/auth/logout", { method: "POST" });
        const { endSessionUrl } = await res.json();

        // 2) NextAuth yerel session cookie'sini temizle
        await signOut({ redirect: false });

        // 3) OIDC provider'da tarayıcı oturumunu kapat
        window.location.href = endSessionUrl ?? "/giris";
    } catch {
        // Hata durumunda salt yerel çıkış yap
        await signOut({ callbackUrl: "/giris" });
    }
}

Revoke Edilmiş Token Davranışı

Harici bir revocation (admin tarafından iptal) durumunda, refresh token'ı artık geçersiz olduğu için jwt() callback'te refresh girişimi invalid_grant döndürür. Bu hata zaten SessionExpired olarak işaretlenerek SessionRefreshWatcher tarafından zorunlu signOut + /giris yönlendirmesi tetikler. Ek bir işlem gerekmez.

Senaryo Davranış
Kullanıcı çıkış yapar Revocation + end_session + yerel cookie temizlenir
Token dışarıdan iptal edilir invalid_grantSessionExpired → zorla signOut
discovery_endpoint erişilemiyor Yalnızca yerel çıkış yapılır, hata fırlatılmaz

12) 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 v4 kur:  pnpm add next-auth
☐ 2.  lib/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 (iki satır)
☐ 6.  app/api/auth/logout/route.ts oluştur (revocation + end_session)
☐ 7.  proxy.ts oluştur ve matcher'ı ayarla
☐ 8.  app/layout.tsx içinde <SessionProvider> ile sar
☐ 9.  SessionRefreshWatcher'ı SessionProvider içine göm
☐ 10. Login sayfasında signIn('<provider-id>') tetikle
☐ 11. Logout sayfasında /api/auth/logout → signOut({ redirect: false }) → endSessionUrl akışı kur
☐ 12. lib/nav-items.ts içinde Role sabitleri ve getNavGroupsForRoles tanımla
☐ 13. Her server action'da auth() ile rol kontrolü ekle
☐ 14. 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
Kullanıcı "Çıkış Yap" tıklar Revocation → yerel signOut → OIDC end_session → /giris
Token dışarıdan iptal edilir Bir sonraki refresh'te invalid_grantSessionExpired → watcher zorla signOut
Discovery erişilemiyor (logout sırasında) Yalnızca yerel cookie temizlenir, hata kullanıcıya yansımaz

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