Angular'da Authentication State: Functional Guard'lar, Interceptor, Tek Seferlik Token Refresh ve UI Yetkilendirmesi
Angular'daki guard ve gizli butonlar güvenlik sınırı değil, kullanıcı deneyimidir; asıl karar API'de verilir. Signal tabanlı bir auth state service, canMatch guard'ları, permission directive'i, eşzamanlı 401'leri tek refresh'e bağlayan bir interceptor ve BroadcastChannel ile sekmeler arası çıkış yazıyor, hepsini Vitest ile sınıyor ve guard'ı atlayan bir isteğin API'de 403 aldığını gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar Angular 22.2, Vitest 5.1, .NET 10 ve Keycloak 26.8.2 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un Angular uygulamasında "İadeler" sayfası yalnız orders.refund yetkisi olanlara görünüyor; menüdeki link de gizli, route'u da bir guard koruyor. Ada tarayıcının DevTools'unu açıp guard'ı devre dışı bırakırsa ne olur? İkinci soru: access token'ın süresi doldu ve sayfa aynı anda üç API (Application Programming Interface) çağrısı yaptı; üçü de 401 aldı. Uygulama kaç kez refresh denemeli? Üçüncüsü: Ada iki sekmede açık; birinden çıkış yaptı. Diğer sekme ne zaman fark eder?
Bu yazıda tarayıcı tarafını tamamlıyoruz. 14. yazıda Angular'ı bir BFF'in (Backend for Frontend) arkasına koyduk; token görmüyor, yalnız cookie ve X-CSRF header'ı var. Ama Angular'ın hâlâ "kim giriş yaptı, neyi gösterebilirim?" sorusunu cevaplaması gerekiyor. Token'ı tarayıcıda tutmak zorunda kalan uygulamalar için de en zor parçayı, eşzamanlı 401'lerde tek seferlik refresh'i yazacağız. Angular'ın HttpClient, interceptor ve guard temelleri sitenin Angular yazılarında anlatıldı; burada auth'a özgü kısımlara odaklanıyoruz. Bütün kodun testleri Vitest ile koşuyor.
İki Senaryo, Tek Kural
BFF senaryosunda Angular'ın auth state'i iki çağrıdan oluşuyor: /bff/user kim olduğunu, 16. yazıda API'ye eklediğimiz /api/me/permissions neler yapabileceğini söylüyor. Token senaryosunda (Angular'ın public client olduğu, RFC (Request for Comments) 10017'nin üçüncü sıraya koyduğu mimari) access token bellekte duruyor ve süresi dolunca yenilenmesi gerekiyor. İki senaryoda da tek kural değişmiyor: Angular'daki her yetki kararı kullanıcı deneyimi içindir; güvenlik kararı API'de verilir.
Signal Tabanlı Auth State
export interface BffUser {
sub: string;
name: string;
email: string;
}
export type AuthState =
| { status: 'unknown' }
| { status: 'anonymous' }
| { status: 'authenticated'; user: BffUser; permissions: ReadonlySet<string> };
export type AuthMessage = 'signed-in' | 'signed-out';
Durumu üç değerli bir discriminated union olarak tutmak, en sık yapılan hatayı tipte engelliyor: isLoggedIn: boolean ile başlayan bir uygulamada "henüz bilmiyorum" durumu false'a düşer ve guard, sayfa yenilendiğinde oturumu açık kullanıcıyı login'e gönderir. unknown durumunda guard'lar beklemeli:
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly state = signal<AuthState>({ status: 'unknown' });
// Aynı origin'deki diğer sekmeler giriş/çıkıştan haberdar olur
private readonly channel = new BroadcastChannel('bookstore-auth');
private loading: Promise<void> | null = null;
readonly status = computed(() => this.state().status);
readonly user = computed(() => {
const state = this.state();
return state.status === 'authenticated' ? state.user : null;
});
readonly permissions = computed(() => {
const state = this.state();
return state.status === 'authenticated' ? state.permissions : new Set<string>();
});
constructor() {
this.channel.onmessage = (event: MessageEvent<AuthMessage>) => {
if (event.data === 'signed-out') {
this.state.set({ status: 'anonymous' });
} else {
this.loading = null;
void this.ensureLoaded();
}
};
inject(DestroyRef).onDestroy(() => this.channel.close());
}
hasPermission(permission: string): boolean {
return this.permissions().has(permission);
}
// Guard'lar aynı anda çağırsa da /bff/user tek kez istenir
ensureLoaded(): Promise<void> {
this.loading ??= this.load();
return this.loading;
}
login(returnUrl: string): void {
window.location.assign(`/bff/login?returnUrl=${encodeURIComponent(returnUrl)}`);
}
async logout(): Promise<void> {
const { logoutUrl } = await firstValueFrom(this.http.post<{ logoutUrl: string }>('/bff/logout', null));
this.state.set({ status: 'anonymous' });
this.channel.postMessage('signed-out' satisfies AuthMessage);
window.location.assign(logoutUrl);
}
private async load(): Promise<void> {
try {
const user = await firstValueFrom(this.http.get<BffUser>('/bff/user'));
const permissions = await firstValueFrom(this.http.get<string[]>('/api/me/permissions'));
this.state.set({ status: 'authenticated', user, permissions: new Set(permissions) });
} catch {
this.state.set({ status: 'anonymous' });
}
}
}
Dışarıya yalnız computed signal'lar açılıyor; state'i değiştirebilen tek yer service'in kendisi. ensureLoaded aynı Promise'i paylaştırıyor: sayfa açılırken üç guard ve bir component aynı anda sorsa da /bff/user'a tek istek gidiyor. Login bir XHR (XMLHttpRequest) değil, tam sayfa yönlendirme; 14. yazıda neden böyle olduğunu gördük. Permission'lar Angular'da hesaplanmıyor, API'den geliyor; rol → permission eşlemesi tek yerde, sunucuda kalıyor.
unknown durumu template'te de işe yarıyor. Ana sayfadaki @switch bu durum için hiçbir şey çizmiyor; böylece sayfa yenilendiğinde oturumu açık bir kullanıcı bir an "Sign in" butonunu görüp sonra adının belirmesini izlemiyor. Uzun süren bir yüklemede burada bir iskelet görünüm göstermek daha iyi; ama "giriş yapmamış" görünümünü varsayılan yapmak, kullanıcıya oturumunun düştüğünü düşündürür ve gereksiz bir giriş denemesine yol açar.
Functional Guard'lar: CanMatch ve CanActivate
// Giriş yapılmamışsa route hiç eşleşmez ve kullanıcı BFF'in login'ine gider
export const authGuard: CanMatchFn = async (_route, segments) => {
const auth = inject(AuthService);
await auth.ensureLoaded();
if (auth.status() === 'authenticated') {
return true;
}
auth.login('/' + segments.map(s => s.path).join('/'));
return false;
};
// Yetki yoksa 403 sayfasına; bu bir UX kararı, asıl kontrol API'de
export const permissionGuard = (permission: string): CanMatchFn => async () => {
const auth = inject(AuthService);
const router = inject(Router);
await auth.ensureLoaded();
return auth.hasPermission(permission) ? true : router.parseUrl('/forbidden');
};
export const routes: Routes = [
{ path: '', component: HomePage },
{ path: 'orders', component: OrdersPage, canMatch: [authGuard] },
{ path: 'refunds', component: RefundsPage, canMatch: [authGuard, permissionGuard('orders.refund')] },
{ path: 'forbidden', component: ForbiddenPage }
];
canMatch'i canActivate'e tercih ettim. canActivate route eşleştikten sonra çalışır; canMatch ise route'un eşleşip eşleşmeyeceğine karar verir. Lazy-loaded bir route'ta bu fark önemli: canMatch false dönerse o route'un JavaScript chunk'ı hiç indirilmez, yönetim panelinin kodu yetkisiz kullanıcının tarayıcısına gelmez. Bir de esneklik var: canMatch reddederse router aynı path için sıradaki route'u dener, böylece aynı adrese rol başına farklı component bağlanabilir.
Angular 22'de CanMatchFn'in imzası üç parametre alıyor; testi ilk yazdığımda iki argümanla çağırdım ve derleyici durdurdu:
✘ [ERROR] TS2554: Expected 3 arguments, but got 2. [plugin angular-compiler]
An argument for 'currentSnapshot' was not provided.
node_modules/@angular/router/types/_router_module-chunk.d.ts:3108:57:
3108 │ ...egment[], currentSnapshot: PartialMatchRouteSnapshot) => Mayb...
Üçüncü parametre PartialMatchRouteSnapshot: eşleşme anında bilinen params, queryParams, data gibi alanlar. Guard'larımda kullanmadığım için tanımda yok, testte boş bir nesne veriyorum:
const snapshot = {} as PartialMatchRouteSnapshot;
it('authGuard sends an anonymous user to the BFF login with the return URL', async () => {
status.set('anonymous');
const result = await TestBed.runInInjectionContext(() =>
authGuard({}, [new UrlSegment('orders', {})], snapshot));
expect(result).toBe(false);
expect(login).toHaveBeenCalledWith('/orders');
});
it('permissionGuard redirects to /forbidden without the permission', async () => {
status.set('authenticated');
const result = await TestBed.runInInjectionContext(() => permissionGuard('orders.refund')({}, [], snapshot));
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/forbidden');
permissions.add('orders.refund');
expect(await TestBed.runInInjectionContext(() => permissionGuard('orders.refund')({}, [], snapshot))).toBe(true);
});
UI Yetkilendirmesi: Permission Directive
// <button *appHasPermission="'orders.refund'">: yalnız görünürlük; API yine kontrol eder
@Directive({ selector: '[appHasPermission]' })
export class HasPermissionDirective {
private readonly auth = inject(AuthService);
private readonly template = inject(TemplateRef<unknown>);
private readonly container = inject(ViewContainerRef);
readonly appHasPermission = input.required<string>();
constructor() {
effect(() => {
this.container.clear();
if (this.auth.hasPermission(this.appHasPermission())) {
this.container.createEmbeddedView(this.template);
}
});
}
}
<a *appHasPermission="'orders.refund'" routerLink="/refunds">Refunds</a>
Directive bir effect içinde iki signal okuyor: input ve auth state'in permission'ları. İkisinden biri değişince görünüm yeniden kuruluyor; çıkış yapan kullanıcının butonu, başka bir sekmeden gelen signed-out mesajıyla bile anında kayboluyor. Testi signal'ı değiştirip DOM'u (Document Object Model) kontrol ediyor:
it('shows the element only while the permission is present', () => {
const permissions = signal(new Set<string>());
TestBed.configureTestingModule({
providers: [{ provide: AuthService, useValue: { hasPermission: (p: string) => permissions().has(p) } }]
});
const fixture = TestBed.createComponent(Host);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button')).toBeNull();
permissions.set(new Set(['orders.refund']));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button')?.textContent).toBe('Refund');
});
Butonu gizlemek ile disabled yapmak arasında bir UX (User Experience) tercihi var: gizlemek ekranı sade tutar, devre dışı bırakıp bir açıklama göstermek kullanıcıya "bu var ama senin yetkin yok" bilgisini verir. Destek ekibi için ikincisi çoğu zaman daha az destek talebi demek.
Interceptor'lar: BFF'te Header, Token'da Single-Flight Refresh
BFF senaryosundaki interceptor 14. yazıdaki tek satırlık csrfHeaderInterceptor; Angular'ın refresh'le hiç işi yok, BFF hallediyor. Token senaryosunda ise access token'ın süresi dolduğunda API 401 döner ve uygulamanın refresh yapıp isteği tekrarlaması gerekir. Saf hâliyle yazılan bir interceptor her 401'de refresh'i ayrı ayrı tetikler: sayfa açılışında üç istek, üç refresh. Refresh token rotation açıksa (09. yazı) ikinci refresh, birinci tarafından zaten tüketilmiş bir refresh token'ı gönderir; sunucu bunu reuse olarak görüp bütün token family'yi iptal eder ve kullanıcı oturumdan atılır. Çözüm refresh'i tek bir uçuşta paylaştırmak:
// BFF kullanmayan (token'ı tarayıcıda tutan) senaryo için: access token yalnız bellekte
@Injectable({ providedIn: 'root' })
export class TokenRefresher {
// HttpBackend: refresh isteği interceptor zincirine girmez, döngü oluşmaz
private readonly http = new HttpClient(inject(HttpBackend));
private inFlight: Observable<string> | null = null;
accessToken: string | null = null;
refreshCount = 0;
refresh(): Observable<string> {
this.inFlight ??= this.http.post<{ accessToken: string }>('/auth/refresh', null, { withCredentials: true }).pipe(
map(response => response.accessToken),
tap(token => {
this.accessToken = token;
this.refreshCount++;
}),
finalize(() => (this.inFlight = null)),
shareReplay(1)
);
return this.inFlight;
}
}
const RETRIED = new HttpContextToken<boolean>(() => false);
const withToken = (req: HttpRequest<unknown>, token: string | null) =>
token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;
export const tokenRefreshInterceptor: HttpInterceptorFn = (req, next) => {
const tokens = inject(TokenRefresher);
const sentWith = tokens.accessToken;
return next(withToken(req, sentWith)).pipe(
catchError((error: unknown) => {
if (!(error instanceof HttpErrorResponse) || error.status !== 401 || req.context.get(RETRIED)) {
return throwError(() => error);
}
const retry = (token: string) => next(withToken(req.clone({ context: req.context.set(RETRIED, true) }), token));
// Bu istek gönderildikten sonra token zaten yenilendiyse tekrar refresh etme
if (tokens.accessToken !== null && tokens.accessToken !== sentWith) {
return retry(tokens.accessToken);
}
return tokens.refresh().pipe(switchMap(retry));
})
);
};
Dört ayrıntı var. shareReplay(1) aynı anda gelen bütün 401'lerin aynı refresh isteğine abone olmasını sağlıyor; finalize iş bitince bir sonraki refresh için yer açıyor. sentWith kontrolü geç gelen 401'leri yakalıyor: eski token'la gönderilmiş bir isteğin 401'i, refresh bittikten sonra gelirse yeni bir refresh başlatmak yerine yeni token'la tekrar deneniyor. RETRIED context token'ı sonsuz döngüyü kesiyor: yenilenen token'la da 401 alan istek bir daha denenmiyor. Refresh isteği HttpBackend üzerinden gidiyor, yani interceptor zincirine girmiyor. Bu senaryoda refresh token'ı nerede tutacağın (HttpOnly cookie, bellek) ayrı bir tehdit modeli tartışması; ilerideki "Frontend Güvenliği" serisinin 05. yazısının konusu.
Testi tam olarak Önce Tahmin Et'teki durumu kuruyor:
it('refreshes once for three concurrent 401s and retries all of them', () => {
const results: unknown[] = [];
for (const url of ['/api/books', '/api/orders/mine', '/api/me/permissions']) {
http.get(url).subscribe(body => results.push(body));
}
for (const req of backend.match(r => r.url.startsWith('/api/'))) {
expect(req.request.headers.get('Authorization')).toBe('Bearer old');
req.flush(null, { status: 401, statusText: 'Unauthorized' });
}
backend.expectOne('/auth/refresh').flush({ accessToken: 'new' });
const retried = backend.match(r => r.url.startsWith('/api/'));
expect(retried.length).toBe(3);
for (const req of retried) {
expect(req.request.headers.get('Authorization')).toBe('Bearer new');
req.flush({ ok: true });
}
expect(results.length).toBe(3);
expect(tokens.refreshCount).toBe(1);
backend.verify();
});
$ npx ng test --watch=false --reporters=verbose
✓ bookstore-web src/app/auth/csrf.interceptor.spec.ts > csrfHeaderInterceptor > adds X-CSRF to same-origin calls 7ms
✓ bookstore-web src/app/auth/csrf.interceptor.spec.ts > csrfHeaderInterceptor > does not leak the header to other origins 1ms
✓ bookstore-web src/app/auth/token-refresh.interceptor.spec.ts > tokenRefreshInterceptor > refreshes once for three concurrent 401s and retries all of them 8ms
✓ bookstore-web src/app/auth/token-refresh.interceptor.spec.ts > tokenRefreshInterceptor > gives up after one retry so a broken token cannot loop 1ms
✓ bookstore-web src/app/auth/has-permission.directive.spec.ts > HasPermissionDirective > shows the element only while the permission is present 14ms
✓ bookstore-web src/app/auth/auth.guards.spec.ts > auth guards > authGuard sends an anonymous user to the BFF login with the return URL 7ms
✓ bookstore-web src/app/auth/auth.guards.spec.ts > auth guards > permissionGuard redirects to /forbidden without the permission 2ms
Test Files 4 passed (4)
Tests 7 passed (7)
Sekmeler Arası Senkron: BroadcastChannel
Ada iki sekmede açık ve birinden çıkış yapıyor. BFF senaryosunda cookie zaten silindi; diğer sekme bir sonraki API çağrısında 401 alacak. Ama o çağrıya kadar ekranda Ada'nın adı, siparişleri ve "İade" butonu duruyor; ortak bir bilgisayarda bu bir sızıntı. BroadcastChannel aynı origin'deki sekmeler arasında mesaj taşıyor. AuthService çıkışta signed-out gönderiyor, diğer sekmelerdeki service'ler state'i anonymous'a çekiyor ve signal'lara bağlı her şey (directive, header, guard) aynı anda güncelleniyor. Mesaj yalnız aynı tarayıcı profilindeki sekmelere gider; başka bir cihazdaki oturumu kapatmak 12. yazıdaki back-channel logout'un işi.
Önce Tahmin Et'in üçüncü sorusunun cevabı: BroadcastChannel ile hemen; onsuz bir sonraki API çağrısında. Sekme uzun süre arka planda kalıp hiç çağrı yapmıyorsa, onsuz hiçbir zaman.
Asıl Kontrol API'de
Guard'ı devre dışı bırakmak tarayıcıda birkaç saniyelik iş: DevTools'ta bir breakpoint, ya da doğrudan API'ye istek. Bunu kendi lab'ımda, Ada'nın oturumuyla denedim. API'de iade endpoint'i permission'a bağlı:
// UI butonu gizlese de gizlemese de karar burada verilir
app.MapPost("/orders/{id:int}/refund", (int id) => Results.Ok(new { Order = id, Status = "refund-requested" }))
.RequireAuthorization(policy => policy.RequirePermission(Permissions.OrdersRefund));
public static AuthorizationPolicyBuilder RequirePermission(this AuthorizationPolicyBuilder policy, string permission) =>
policy.RequireAuthenticatedUser().RequireAssertion(context =>
RolePermissions.For(RolePermissions.KeycloakRealmRoles(context.User)).Contains(permission));
$ for u in ada alan; do
J=/tmp/authwork2-c14-$u; sh login14.sh $u $J > /dev/null
echo "$u: $(curl -sk -b $J -H 'X-CSRF: 1' -X POST -o /tmp/r.txt -w '%{http_code}' https://localhost:19214/api/orders/1001/refund) $(cat /tmp/r.txt)"
done
ada: 403
alan: 200 {"order":1001,"status":"refund-requested"}
Önce Tahmin Et'in ilk sorusunun cevabı bu: Ada guard'ı aşarsa boş bir sayfa görür, iade butonuna basabilirse API 403 döner. Guard ve directive bir güvenlik sınırı değil; kullanıcıyı yapamayacağı bir işe başlamaktan koruyan bir UX katmanı. Gerçek iade kuralı 16. yazıdaki RefundPolicy ile API'de ayrıca tenant, limit ve zaman kontrolü de yapmalı.
Üretimde Dikkat
Auth state'in ilk yüklenişi uygulamanın açılış süresine ekleniyor: /bff/user ve /api/me/permissions iki gidiş dönüş. Bunları tek bir endpoint'te birleştirebilir ya da provideAppInitializer ile uygulama açılmadan önce yükleyebilirsin; ikincisinde açılış ekranı bu çağrı bitene kadar bekler, bu yüzden zaman aşımı ve hata durumunu düşün. Kullanıcı yetkisi oturum sırasında değişebilir: permission listesini sonsuza dek cache'leme; 403 aldığında ya da belli aralıklarla yeniden yükle.
401 ve 403'ü ayrı ele al: 401 "kim olduğunu bilmiyorum" (giriş ya da refresh), 403 "biliyorum ama izin yok" (yetki mesajı, yeniden giriş değil). Her 403'te login'e yönlendiren bir interceptor kullanıcıyı sonsuz bir giriş döngüsüne sokar. Token senaryosunda refresh endpoint'inin de başarısız olabileceğini unutma; refresh 401 dönerse state'i anonymous'a çek ve kullanıcıyı login'e gönder, tekrar deneme.
Sık Düşülen Tuzaklar
Birincisi, guard'ı ya da gizli butonu güvenlik sınırı sanmak. İkincisi, auth state'i boolean tutup "henüz bilinmiyor" durumunu kaybetmek. Üçüncüsü, eşzamanlı 401'lerde her birine ayrı refresh; rotation açıkken kullanıcıyı oturumdan atar. Dördüncüsü, refresh isteğini aynı interceptor zincirinden göndermek ve 401 döngüsüne girmek.
Beşincisi, rol → permission eşlemesini Angular'da kopyalamak; iki yerde tutulan kural er ya da geç ayrışır. Altıncısı, CSRF (Cross-Site Request Forgery) header'ını ya da token'ı başka origin'lere giden isteklere de eklemek. Yedincisi, çıkışta yalnız state'i temizleyip BFF'e ve Keycloak'a gitmemek.
Küçük Bir Deneme
★☆☆ AuthService.load başarılı olunca, önceki durum anonymous ise signed-in mesajı gönder. İki sekme açıp birinde giriş yapınca diğerinin de güncellendiğini gözle.
★★☆ tokenRefreshInterceptor'a bir test daha yaz: refresh isteği 401 dönerse üç bekleyen isteğin hepsi hata almalı ve refreshCount artmamalı. Kodun bunu doğru yaptığını mı, yoksa bir düzeltme gerektiğini mi gösterdi?
★★★ Refunds sayfasını lazy-loaded yap (loadComponent), production build al ve yetkisiz kullanıcıyla sayfa açıldığında o chunk'ın ağda hiç indirilmediğini canMatch ile, indirildiğini canActivate ile göster.
Seriyi tarayıcıdan sunucuya, oradan authorization server'a kadar dolaştık. Son yazıda geriye bakıyoruz: ASP.NET Core 6'dan 11'e authentication ve Identity'de neler değişti, eski bir projeyi yükseltirken hangi davranışlar sessizce farklılaşır ve .NET 11 ile neler geliyor.