# 機能設計書 48-OpenID認証

## 概要

本ドキュメントは、Actualの「OpenID認証」機能の設計内容を記載する。OpenID Connectを使用したシングルサインオン（SSO）機能について、処理内容とデータ構造を定義する。

### 本機能の処理概要

OpenID認証機能は、外部のアイデンティティプロバイダー（IdP）を使用したシングルサインオン認証を提供する。PKCE（Proof Key for Code Exchange）を使用したセキュアな認証フローを実装し、マルチユーザーモードをサポートする。

**業務上の目的・背景**：企業や組織での利用において、既存の認証基盤（Azure AD、Okta、Keycloak等）との統合を可能にする。マルチユーザーモードでは、各ユーザーに個別のアカウントと権限を付与できる。

**機能の利用シーン**：組織の認証基盤と連携したい場合、マルチユーザーでのデータ共有を行いたい場合、パスワード管理を一元化したい場合に利用される。

**主要な処理内容**：
1. OpenID設定の初期化（bootstrapOpenId）
2. 認証フロー開始（loginWithOpenIdSetup）
3. コールバック処理（loginWithOpenIdFinalize）
4. ユーザー情報の取得と自動作成
5. セッション管理

**関連システム・外部連携**：外部アイデンティティプロバイダー（IdP）、openid-clientライブラリ

**権限による制御**：OpenID有効化・無効化は管理者のみ。初回ユーザーはADMIN権限、以降はBASIC権限。

## 関連画面

| 画面No | 画面名 | 関連種別 | 関連する操作・処理 |
|--------|--------|----------|------------------|
| 2 | ログイン画面 | 主機能 | OpenID Connectによるシングルサインオン認証処理 |
| 3 | OpenIDコールバック画面 | 主機能 | OpenID認証コールバック処理・トークン取得 |
| 4 | エラー画面 | 遷移先機能 | OpenID認証エラー時の表示処理 |

## 機能種別

認証・シングルサインオン処理

## 入力仕様

### 入力パラメータ

#### OpenID設定（bootstrapOpenId）

| パラメータ名 | 型 | 必須 | 説明 | バリデーション |
|-------------|-----|-----|------|---------------|
| issuer | string | Yes | IdPのIssuer URLまたはDiscovery URL | URL形式 |
| client_id | string | Yes | クライアントID | 空文字不可 |
| client_secret | string | Yes | クライアントシークレット | 空文字不可 |
| server_hostname | string | Yes | Actualサーバーのホスト名 | URL形式 |
| authMethod | string | No | 認証方式（openid/その他） | - |

#### ログイン開始（loginWithOpenIdSetup）

| パラメータ名 | 型 | 必須 | 説明 | バリデーション |
|-------------|-----|-----|------|---------------|
| returnUrl | string | Yes | 認証後のリダイレクト先 | 有効なURL |
| firstTimeLoginPassword | string | No | 初回ログイン時のパスワード確認 | - |

#### コールバック（loginWithOpenIdFinalize）

| パラメータ名 | 型 | 必須 | 説明 | バリデーション |
|-------------|-----|-----|------|---------------|
| code | string | Yes | 認可コード | 空文字不可 |
| state | string | Yes | 状態パラメータ | 空文字不可 |
| iss | string | No | Issuer（一部IdPで使用） | - |

### 入力データソース

- 管理者によるOpenID設定
- IdPからのコールバックパラメータ

## 出力仕様

### 出力データ

| 項目名 | 型 | 説明 |
|--------|-----|------|
| url | string | IdPの認証URLまたはリダイレクトURL |
| token | string | セッショントークン（コールバック後） |
| error | string | エラーコード |

### 出力先

- クライアントへのリダイレクト/レスポンス
- accountデータベース（auth, users, sessions, pending_openid_requestsテーブル）

## 処理フロー

### 処理シーケンス

```
1. OpenID設定（bootstrapOpenId）
   ├─ 設定パラメータ検証
   ├─ IdPへのDiscoveryリクエスト
   ├─ openid-clientのセットアップ
   └─ authテーブルに設定保存

2. ログイン開始（loginWithOpenIdSetup）
   ├─ returnUrlの検証
   ├─ 既存ユーザー確認/初回パスワード検証
   ├─ PKCE用コード生成（state, code_verifier, code_challenge）
   ├─ pending_openid_requestsに保存
   └─ IdPの認証URLを返却

3. コールバック処理（loginWithOpenIdFinalize）
   ├─ code, state検証
   ├─ pending_openid_requestsから検証データ取得
   ├─ IdPにトークン交換リクエスト
   ├─ ユーザー情報取得（userinfo）
   ├─ ユーザー作成または既存ユーザー取得
   ├─ セッション作成
   └─ リダイレクトURL返却
```

### フローチャート

```mermaid
flowchart TD
    A[ログインボタン] --> B[loginWithOpenIdSetup]
    B --> C{returnUrl有効?}

    C -->|No| D[エラー: invalid-return-url]
    C -->|Yes| E{初回ログイン?}

    E -->|Yes| F{パスワード確認}
    E -->|No| G[PKCE生成]

    F -->|失敗| H[エラー: invalid-password]
    F -->|成功| G

    G --> I[pending_openid_requestsに保存]
    I --> J[IdP認証URL返却]

    J --> K[ユーザーをIdPへリダイレクト]
    K --> L[IdPでの認証]
    L --> M[コールバック /openid/callback]

    M --> N[loginWithOpenIdFinalize]
    N --> O{code/state有効?}

    O -->|No| P[エラー]
    O -->|Yes| Q[pending_openid_requestsから検証]

    Q --> R{リクエスト有効?}
    R -->|No| S[エラー: invalid-or-expired-state]
    R -->|Yes| T[IdPにトークン交換]

    T --> U[userinfo取得]
    U --> V{ユーザー存在?}

    V -->|No| W{自動作成許可?}
    V -->|Yes| X[既存ユーザー取得]

    W -->|Yes| Y[新規ユーザー作成]
    W -->|No| Z[エラー: openid-grant-failed]

    Y --> AA[セッション作成]
    X --> AA
    AA --> AB[リダイレクトURL返却]
```

## ビジネスルール

### 業務ルール

| ルールNo | ルール名 | 内容 | 適用条件 |
|---------|---------|------|---------|
| BR-48-01 | PKCE必須 | PKCE（S256）を使用した認可コードフロー | 常時 |
| BR-48-02 | 状態有効期限 | pending_openid_requestsは5分間有効 | 認証フロー中 |
| BR-48-03 | 初回ユーザー | 初回ログインユーザーはADMIN+owner権限 | 既存ユーザー0件時 |
| BR-48-04 | 自動作成モード | userCreationMode='login'の場合、未登録ユーザーも自動作成 | 設定時 |
| BR-48-05 | ユーザー識別 | preferred_username/login/email/id/subの順で識別子取得 | userinfo取得時 |

### ユーザー識別子の優先順位

1. preferred_username
2. login
3. email
4. id
5. sub

## データベース操作仕様

### 操作別データベース影響一覧

| 操作 | 対象テーブル | 操作種別 | 概要 |
|-----|-------------|---------|------|
| OpenID設定 | auth | DELETE/INSERT | 設定保存 |
| ログイン開始 | pending_openid_requests | DELETE/INSERT | 認証状態保存 |
| コールバック | pending_openid_requests | SELECT/DELETE | 状態検証 |
| コールバック | users | INSERT/UPDATE | ユーザー作成/更新 |
| コールバック | sessions | INSERT | セッション作成 |

### テーブル別操作詳細

#### authテーブル

| 操作 | 項目（カラム名） | 更新値・取得条件 | 備考 |
|-----|-----------------|-----------------|------|
| SELECT | extra_data | method = 'openid' AND active = 1 | 設定取得 |
| DELETE | * | method = 'openid' | 既存設定削除 |
| INSERT | method, display_name, extra_data, active | 'openid', 'OpenID', JSON設定, 1 | 新規設定 |

#### pending_openid_requestsテーブル

| 操作 | 項目（カラム名） | 更新値・取得条件 | 備考 |
|-----|-----------------|-----------------|------|
| DELETE | * | expiry_time < now | 期限切れ削除 |
| INSERT | state, code_verifier, return_url, expiry_time | 値, 値, 値, now+300秒 | 認証状態保存 |
| SELECT | code_verifier, return_url | state = ? AND expiry_time > now | 状態検証 |

#### usersテーブル

| 操作 | 項目（カラム名） | 更新値・取得条件 | 備考 |
|-----|-----------------|-----------------|------|
| SELECT | id, display_name | user_name = ? AND enabled = 1 | 既存ユーザー取得 |
| INSERT | id, user_name, display_name, enabled, owner, role | UUID, 識別子, 表示名, 1, 初回?1:0, 初回?ADMIN:BASIC | 新規ユーザー |
| UPDATE | display_name | id = ? | 表示名更新 |

## エラー処理

### エラーケース一覧

| エラーコード | エラー種別 | 発生条件 | 対処方法 |
|------------|----------|---------|---------|
| missing-issuer-or-discoveryURL | 設定エラー | issuerまたはdiscoveryURL未設定 | 設定を確認 |
| missing-client-id | 設定エラー | client_id未設定 | 設定を確認 |
| missing-client-secret | 設定エラー | client_secret未設定 | 設定を確認 |
| missing-server-hostname | 設定エラー | server_hostname未設定 | 設定を確認 |
| configuration-error | 設定エラー | IdP設定取得失敗 | IdP設定を確認 |
| return-url-missing | リクエストエラー | returnUrl未指定 | returnUrlを指定 |
| invalid-return-url | リクエストエラー | 無効なreturnUrl | 正しいURLを指定 |
| invalid-password | 認証エラー | 初回パスワード不一致 | 正しいパスワードで再試行 |
| openid-not-configured | 設定エラー | OpenID未設定 | 管理者に設定を依頼 |
| openid-setup-failed | 設定エラー | クライアントセットアップ失敗 | 設定を確認 |
| missing-authorization-code | リクエストエラー | codeパラメータなし | - |
| missing-state | リクエストエラー | stateパラメータなし | - |
| invalid-or-expired-state | 認証エラー | 状態が無効または期限切れ | 最初からやり直し |
| openid-grant-failed | 認証エラー | トークン交換失敗 | IdP設定を確認 |

### リトライ仕様

認証フロー失敗時は最初からやり直し。自動リトライなし。

## トランザクション仕様

ユーザー作成処理はトランザクション内で実行（既存ユーザーチェック→作成）。

## パフォーマンス要件

- IdPへのリクエスト: タイムアウト20秒
- 認証フロー全体: IdP応答時間に依存

## セキュリティ考慮事項

1. **PKCE**: S256方式でcode_challengeを使用
2. **state検証**: CSRF対策として状態パラメータを検証
3. **リダイレクトURL検証**: 登録済みホスト名のみ許可
4. **トークン有効期限**: IdPの設定または固定値で期限管理
5. **HTTPSタイムアウト**: 20秒のタイムアウトでDoS対策

## 備考

- OpenID ConnectはOAuth 2.0ベースの認証プロトコル
- マルチユーザーモードにはOpenID認証が必須
- デスクトップアプリではローカルOAuthサーバーを使用してコールバックを処理

---

## コードリーディングガイド

本機能を理解するために参照すべきファイルと、推奨する読み解き順序を以下に示す。

### 推奨読解順序

#### Step 1: データ構造を理解する

OpenID認証で使用されるデータ構造を理解する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 1-1 | openid.js | `packages/sync-server/src/accounts/openid.js` | 設定パラメータの構造 |

**読解のコツ**: issuer, client_id, client_secret, server_hostnameが必須設定。pending_openid_requestsテーブルでstate, code_verifier, return_urlを管理。

#### Step 2: エントリーポイントを理解する

OpenID認証のサーバーサイドAPIを確認する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 2-1 | app-openid.js | `packages/sync-server/src/app-openid.js` | Express ルーティング（1-106行目） |

**主要処理フロー**:
1. **19-36行目**: /enableエンドポイント（OpenID有効化、管理者のみ）
2. **38-55行目**: /disableエンドポイント（OpenID無効化、管理者のみ）
3. **57-87行目**: /configエンドポイント（設定取得、パスワード確認必要）
4. **89-103行目**: /callbackエンドポイント（認証コールバック）

#### Step 3: 認証ロジックを理解する

OpenID認証の詳細ロジックを確認する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 3-1 | openid.js | `packages/sync-server/src/accounts/openid.js` | 認証関数の実装（18-372行目） |

**主要処理フロー**:
- **18-66行目**: bootstrapOpenId関数（設定初期化）
- **68-90行目**: setupOpenIdClient関数（クライアントセットアップ）
- **92-167行目**: loginWithOpenIdSetup関数（認証開始）
- **169-332行目**: loginWithOpenIdFinalize関数（コールバック処理）
- **350-372行目**: isValidRedirectUrl関数（リダイレクト検証）

### プログラム呼び出し階層図

```
app-openid.js (Express Routes)
    │
    ├─ POST /enable
    │      ├─ validateSessionMiddleware() → 認証確認
    │      ├─ isAdmin() → 権限確認
    │      └─ enableOpenID() (account-db.js)
    │
    ├─ POST /disable
    │      ├─ validateSessionMiddleware()
    │      ├─ isAdmin()
    │      └─ disableOpenID() (account-db.js)
    │
    ├─ POST /config
    │      ├─ getOwnerCount() → ユーザー数確認
    │      ├─ checkPassword() → パスワード確認
    │      └─ getOpenIDConfig() → 設定取得
    │
    └─ GET /callback
           └─ loginWithOpenIdFinalize() (openid.js)

openid.js
    │
    ├─ bootstrapOpenId(config)
    │      ├─ custom.setHttpOptionsDefaults({timeout: 20000})
    │      ├─ setupOpenIdClient(config)
    │      └─ accountDb.transaction() → authテーブル更新
    │
    ├─ loginWithOpenIdSetup(returnUrl, firstTimeLoginPassword)
    │      ├─ isValidRedirectUrl(returnUrl)
    │      ├─ checkPassword(firstTimeLoginPassword) [初回時]
    │      ├─ setupOpenIdClient(config)
    │      ├─ generators.state() / generators.codeVerifier() / generators.codeChallenge()
    │      ├─ INSERT INTO pending_openid_requests
    │      └─ client.authorizationUrl() → IdP認証URL生成
    │
    └─ loginWithOpenIdFinalize(body)
           ├─ setupOpenIdClient(config)
           ├─ SELECT FROM pending_openid_requests
           ├─ client.callback() / client.grant() → トークン交換
           ├─ client.userinfo() → ユーザー情報取得
           ├─ accountDb.transaction() → ユーザー作成/取得
           ├─ INSERT INTO sessions
           ├─ clearExpiredSessions()
           └─ return {url: `${return_url}/openid-cb?token=${token}`}
```

### データフロー図

```
[入力]                    [処理]                         [出力]

OpenID設定 ─────────────▶ bootstrapOpenId()
                               │
                               ├─ Issuer.discover() ────▶ IdP設定取得
                               │
                               └─ authテーブル ─────────▶ 設定保存

ログインリクエスト        loginWithOpenIdSetup()
returnUrl ─────────────▶      │
                               ├─ PKCE生成 ─────────────▶ state, code_verifier
                               │
                               ├─ pending_openid_requests ▶ 状態保存
                               │
                               └─ authorizationUrl() ───▶ IdP認証URL
                                        │
                                        ▼
                                   [IdP認証画面]
                                        │
                                        ▼
コールバック              loginWithOpenIdFinalize()
code, state ───────────▶      │
                               ├─ pending_openid_requests ▶ 状態検証
                               │
                               ├─ client.callback() ────▶ トークン交換
                               │
                               ├─ client.userinfo() ────▶ ユーザー情報
                               │
                               ├─ usersテーブル ────────▶ ユーザー作成/取得
                               │
                               ├─ sessionsテーブル ─────▶ セッション作成
                               │
                               └─ ─────────────────────▶ リダイレクトURL
                                                          (token付き)
```

### 関連ファイル一覧

| ファイル | パス | 種別 | 役割 |
|---------|------|------|------|
| openid.js | `packages/sync-server/src/accounts/openid.js` | ソース | OpenID認証ロジック |
| app-openid.js | `packages/sync-server/src/app-openid.js` | ソース | OpenID関連APIルート |
| account-db.js | `packages/sync-server/src/account-db.js` | ソース | アカウントDB操作、enableOpenID/disableOpenID |
| AuthSettings.tsx | `packages/desktop-client/src/components/settings/AuthSettings.tsx` | ソース | 認証設定UIコンポーネント |
| index.ts | `packages/desktop-electron/index.ts` | ソース | デスクトップアプリのOAuthサーバー |
| validate-user.js | `packages/sync-server/src/util/validate-user.js` | ソース | TOKEN_EXPIRATION_NEVER定数 |
| user-service.js | `packages/sync-server/src/services/user-service.js` | ソース | ユーザー関連サービス |
