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
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ı:
- Kullanıcı
/girissayfasından OIDC provider'a yönlendirilir. - Başarılı login sonrası NextAuth, access/refresh/id token bilgilerini JWT içine yazar.
- Her istekte
jwt()callback access token süresini kontrol eder. - Süre dolduysa discovery endpoint üzerinden token endpoint bulunur ve refresh yapılır.
- Refresh başarılı ise yeni tokenlar JWT'ye yazılır.
- Client tarafında token süresi bitmeden önce
session.update({ forceRefresh: true })ile yenileme tetiklenir. session()callback, rol ve token bilgilerini client tarafına güvenli şekilde aktarır.- Yenileme başarılıysa kullanıcıya "Oturum süresi uzatıldı" toast'ı gösterilir.
.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_ISSUERsonda slash olacak şekilde verilmeli.- Kod gelen değerde slash eksik olsa da discovery URL oluştururken güvenle tamamlar (URL constructor ile).
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
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[];
}
}// 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
});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 }];
});
}Bu callback her oturum kontrolünde çalışır. Üç farklı senaryo ele alınır.
// 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;
} // 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,
};
} // 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 |
// 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;
},// 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;
},
SessionExpireddurumu burada ayrıca ele alınmaz.SessionRefreshWatcherbu durumda zorunlusignOut+/girisyönlendirmesi yapar.
Bu dosya iki sorumluluk taşır:
SessionRefreshWatcher— gizli bileşen, hiçbir şey render etmez, sadece izler.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>
);
}// 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.
// 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.
// 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.
// 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
},
// ...
],
},
];// 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
}// 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);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
}// 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.
☐ 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
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}/`
);Sebep:
- Refresh hatası sadece
session.errorset ediyor;authorizedcallback 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
}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 harfAUTH_OPENID_CLIENT_SECRETasla 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.
RefreshAccessTokenError≠SessionExpired: geçici hatalarda kullanıcı atılmaz.hasForcedLogoutRefçift logout döngüsünü önler.
| 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.