Angular実践 #2 認証と HTTP Interceptor

読了 8分

#1 プロジェクトの骨格 で、ルーティングとレイアウト、空のダッシュボード画面まで敷きました。とはいえログインのないダッシュボードは事実上、静的ページに過ぎません。今回はその上に 認証フロー を載せていきます — ログイン画面、トークンの保存、ルート保護、そしてすべてのリクエストにトークンを自動で付与する HTTP Interceptor まで。

バックエンドの前提 #

今回のコードは、次の 2 つのエンドポイントがあると仮定します。

バックエンド API の前提
POST /api/auth/login   { email, password }       → { accessToken: string }
GET  /api/auth/me      Authorization: Bearer ...  → { id, email, name }

モダン Python バックエンド (FastAPI) シリーズで作ったバックエンドでも、Django REST Framework で作ったものでも、形さえ同じなら問題なく繋がります。JWT (Access Token) をヘッダに乗せて送る、よくあるパターンですね。Refresh Token は最後に短い段落で触れるだけにします。environment.apiBase は #1 ですでに敷いてある前提です。

Login 画面 — Reactive Forms #

まずはログインコンポーネントを作ります。中級 #1 Reactive Forms で扱ったパターンそのまま、FormGroup 2 フィールド + 検証 + (ngSubmit) です。

src/app/pages/login/login.component.ts
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { AuthService } from '../../auth/auth.service';

@Component({
  selector: 'app-login',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './login.component.html',
})
export class LoginComponent {
  private fb = inject(FormBuilder);
  private auth = inject(AuthService);
  private router = inject(Router);
  private route = inject(ActivatedRoute);

  protected readonly loading = signal(false);
  protected readonly errorMsg = signal<string | null>(null);

  protected readonly form = this.fb.nonNullable.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]],
  });

  submit() {
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      return;
    }

    this.loading.set(true);
    this.errorMsg.set(null);
    const { email, password } = this.form.getRawValue();

    this.auth.login(email, password).subscribe({
      next: () => {
        const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? '/dashboard';
        this.router.navigateByUrl(returnUrl);
      },
      error: (err) => {
        this.errorMsg.set(err?.error?.message ?? 'ログインに失敗しました。');
        this.loading.set(false);
      },
    });
  }
}

テンプレートは平凡です — 入力 2 つ、検証メッセージ、サーバーエラー、submit ボタンくらい。肝心なのは、このコンポーネントが自分の責任を正確に把握している点です。入力を受け取って AuthService.login() を呼び、成功したらルーターで次の画面に送る。トークンを保存したりヘッダを触ったりはしません。それは次の段階の責任です。

AuthService — トークンを持つ唯一の場所 #

認証状態はアプリ全体で 1 カ所に集めます。基礎 #5 Service と DI のパターンそのまま、providedIn: 'root' でシングルトンを作り、そこにトークンとユーザー情報をシグナルで保持します。

src/app/auth/auth.service.ts
import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap } from 'rxjs';
import { environment } from '../../environments/environment';

export interface User {
  id: number;
  email: string;
  name: string;
}

const TOKEN_KEY = 'auth_token';

@Injectable({ providedIn: 'root' })
export class AuthService {
  private http = inject(HttpClient);

  // アプリ起動時に localStorage からトークンを復元
  private readonly _token = signal<string | null>(localStorage.getItem(TOKEN_KEY));
  private readonly _user = signal<User | null>(null);

  readonly token = this._token.asReadonly();
  readonly user = this._user.asReadonly();
  readonly isLoggedIn = computed(() => this._token() !== null);

  login(email: string, password: string): Observable<{ accessToken: string }> {
    return this.http
      .post<{ accessToken: string }>(`${environment.apiBase}/auth/login`, { email, password })
      .pipe(
        tap((res) => this.setToken(res.accessToken)),
        tap(() => this.fetchMe().subscribe()),
      );
  }

  fetchMe(): Observable<User> {
    return this.http
      .get<User>(`${environment.apiBase}/auth/me`)
      .pipe(tap((user) => this._user.set(user)));
  }

  logout(): void {
    this._token.set(null);
    this._user.set(null);
    localStorage.removeItem(TOKEN_KEY);
  }

  private setToken(token: string): void {
    this._token.set(token);
    localStorage.setItem(TOKEN_KEY, token);
  }
}

3 点だけ押さえます。

  1. _tokenlocalStorage から アプリ起動時に一度だけ 復元します。リロードしてもログイン状態が維持される理由はこれです。
  2. トークンを保存する経路は setToken() ひとつだけです。シグナルと localStorage を 常に一緒に 更新するための小さなガードレールです。外部からシグナルを直接触れないように _token は private にし、外部には asReadonly() だけを公開しています。
  3. isLoggedIncomputed で派生させます。トークンが変わると自動で更新されるので、ガードもヘッダコンポーネントも、同じ 1 つの真実を見ます。
注記
実務ではトークンを localStorage に置くのは XSS に弱い という指摘が頻繁に出ます。より安全な選択肢はバックエンド側から HttpOnly; Secure; SameSite=Strict クッキーで返してもらう方法で、この場合クライアントはトークンを直接触る必要すらありません。ただし SPA + 別ドメインのバックエンド + モバイルアプリ共有のようなシナリオでは、ヘッダ + localStorage が依然としてよく使われるので、両者のトレードオフを意識して選んでください。本記事は最も一般的なヘッダ方式で進めます。

ログインフロー — 1 行でおさらい #

ここまでのパーツを並べて流れをまとめると — /login のフォーム送信 → AuthService.login() → POST 成功時に tap がトークンをシグナル + localStorage に保存 → 続いて fetchMe() がユーザー情報を埋める → コンポーネントは returnUrl (またはデフォルトの /dashboard) に遷移。コンポーネントはトークンを直接触りません。 保存・復元の責任はすべて AuthService の中にあり、コンポーネントは単なる呼び出し側です。

Auth Guard — 保護されたルート #

トークンがなければ /dashboard に入れないようにブロックする必要がありますね。中級 #6 Guards で扱った関数型ガードのパターンをそのまま持ってきます。

src/app/auth/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';

export const authGuard: CanActivateFn = (_route, state) => {
  const auth = inject(AuthService);
  const router = inject(Router);

  if (auth.isLoggedIn()) {
    return true;
  }

  // 元々向かっていた URL を returnUrl として保存
  return router.createUrlTree(['/login'], {
    queryParams: { returnUrl: state.url },
  });
};

ルートにはこう付けます。

src/app/app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './auth/auth.guard';

export const routes: Routes = [
  { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
  {
    path: 'login',
    loadComponent: () => import('./pages/login/login.component').then(m => m.LoginComponent),
  },
  {
    path: 'dashboard',
    canActivate: [authGuard],
    loadComponent: () => import('./pages/dashboard/dashboard.component').then(m => m.DashboardComponent),
  },
  { path: '**', redirectTo: 'dashboard' },
];

未認証の状態で /dashboard/orders にアクセスすると、ガードが /login?returnUrl=%2Fdashboard%2Forders に送り、ログイン後は LoginComponent がその returnUrl を読み取って正確にその場所に戻します。ユーザー視点では 「お、また同じ画面に戻ってきた」 が自然ですね。

HTTP Interceptor — ヘッダの自動付与 #

次は認証付きリクエストの番です。呼び出しごとに headers: { Authorization: 'Bearer ...' } を手で付けるのは答えではありません。基礎 #7 で 1 行で紹介した 関数型 Interceptor が、まさにこの場面で輝きます。

src/app/auth/auth.interceptor.ts
import { inject } from '@angular/core';
import { HttpInterceptorFn } from '@angular/common/http';
import { AuthService } from './auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).token();

  // ログインリクエスト自体にはトークンを付けない
  if (!token || req.url.includes('/auth/login')) {
    return next(req);
  }

  const cloned = req.clone({
    setHeaders: { Authorization: `Bearer ${token}` },
  });

  return next(cloned);
};

HttpRequest不変 (immutable) なので、直接修正せずに req.clone() で新しいリクエストを作ったうえで next() に流します。登録は app.config.ts 1 行。

src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './auth/auth.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withInterceptors([authInterceptor])),
  ],
};

この 1 行でアプリの すべての HTTP リクエストにトークンが自動で付きます。UserServiceOrderService、これから作るすべてのサービスは、この事実を意識する必要なく単に http.get() を呼ぶだけで済みます。

401 の自動処理 — 期限切れトークンを掃除する #

トークンは期限切れになることもあれば、バックエンド側で無効化されることもあります。その結果は通常 401 Unauthorized で返ってきます。Interceptor はレスポンスも横取りできるので、その場で一括処理しましょう。

src/app/auth/auth.interceptor.ts (401 処理を追加)
import { inject } from '@angular/core';
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  const router = inject(Router);
  const token = auth.token();

  const outgoing =
    !token || req.url.includes('/auth/login')
      ? req
      : req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });

  return next(outgoing).pipe(
    catchError((err: HttpErrorResponse) => {
      if (err.status === 401 && !req.url.includes('/auth/login')) {
        auth.logout();
        router.navigateByUrl('/login');
      }
      return throwError(() => err);
    }),
  );
};

期限切れトークンで何かの画面を見ているときに API が 401 を返してきたら、すべてのコンポーネントはその事実を知らないまま きれいにログイン画面に戻ります。401 処理のコードをサービスごとに繰り返す必要がなくなります。これが Interceptor の本質です — 横断的関心事 (cross-cutting concern) を 1 カ所にまとめる。

ヒント
すべてのリクエストに対して 401 で一括ログアウトさせるのが常に正解とは限りません。例えばパスワード変更フォームで「現在のパスワードが間違っている」を 401 で返すバックエンドなら、その呼び出しはログアウト対象であってはなりません。通常はバックエンド側で、認証失敗は 401、ビジネス検証の失敗は 422 や 400 と取り決めるか、Interceptor で特定の URL をホワイトリストとして除外する方法で処理します。

ユーザー情報の表示 — ヘッダの名前とログアウト #

先ほど AuthService.user() にユーザー情報を入れたので、ヘッダコンポーネントはそれをそのまま使えばよいです。

src/app/layout/header.component.ts
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../auth/auth.service';

@Component({
  selector: 'app-header',
  standalone: true,
  template: `
    <header class="app-header">
      <h1>Dashboard</h1>
      @if (auth.user(); as user) {
        <div class="user-area">
          <span>{{ user.name }}</span>
          <button type="button" (click)="logout()">ログアウト</button>
        </div>
      }
    </header>
  `,
})
export class HeaderComponent {
  protected auth = inject(AuthService);
  private router = inject(Router);

  logout() {
    this.auth.logout();
    this.router.navigateByUrl('/login');
  }
}

@if (auth.user(); as user) 1 行で — ユーザー情報が埋まっているときだけ、名前とログアウトボタンが表示されます。シグナルが変わるとテンプレートが自動で更新されるので、subscribeOnInit も不要です。

ただしリロード直後はトークンは復元されているけれど、user はまだ空という瞬間があります。通常は AppComponentアプリ起動時に一度だけ fetchMe() を呼びます。

src/app/app.component.ts (起動時にユーザー復元)
export class AppComponent {
  private auth = inject(AuthService);

  constructor() {
    if (this.auth.isLoggedIn()) {
      this.auth.fetchMe().subscribe({
        error: () => this.auth.logout(), // トークンが死んでいたら片付ける
      });
    }
  }
}

トークンだけ生きていてバックエンドで無効化されたケースも、ここで一気に拾えます。

Refresh Token — パターン紹介 #

Access Token の寿命は短いほどセキュリティ上は有利ですが、ユーザーから見ると頻繁にログアウトされて不便ですね。そこでよく Refresh Token をペアで持ち、Access Token が切れたら Refresh で静かに新しい Access を受け取って続行します。

Interceptor の中で 401 を受け取ったとき、すぐにログアウトする代わりに /api/auth/refresh を一度試して、成功したら 元々失敗したリクエストを新しいトークンで再送する パターンが標準です。並行して飛んでくるリクエストに備えて、RxJS の Subject で更新リクエストを 1 度だけ流して残りは結果を待たせる、というディテールも付いてきます。コードがそこそこ長くなるので本記事では分量の都合で扱いませんが、「401 を見たら無条件ログアウト」が第 1 段階 → 「一度はリフレッシュを試す」が次の段階 という流れだけ頭に入れておいてください。

まとめ #

今回は #1 の空の骨格に、認証フローを埋め込みました。

  • Login 画面: Reactive Forms + AuthService.login() の呼び出し役だけ
  • AuthService: トークン・ユーザーをシグナルで持つ唯一の場所、localStorage で永続化
  • Auth Guard: 関数型 CanActivateFn、未認証なら returnUrl を保存して /login
  • HTTP Interceptor: すべてのリクエストに Bearer ヘッダを自動付与 + 401 で自動ログアウト
  • ヘッダ: auth.user() シグナルをそのままバインド、リロード時には fetchMe() で復元

ルーティング・レイアウト・認証まで敷けたので、次の段階は本物のデータです。次回 「Angular実践 #3 フォームと API CRUD」 では、保護された領域の中でリソース 1 つ (例: posts または tasks) を CRUD する流れを最後まで追います — 一覧取得、詳細 + 編集フォーム、作成 / 修正 / 削除、そして楽観的更新 (optimistic update) まで。認証付きリクエストが自動で通る環境で、ドメインロジックに集中する番です。

X