Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,18 @@ def get_current_user(request: Request):
email=user.get("email"),
role=user.get("role"),
)


from app.core.settings import get_settings
from fastapi.responses import RedirectResponse

@router.get("/login", summary="Login Redirect")
def login_redirect():
"""
ALB認証フローの起着点。
既にALBで認証されているため、フロントエンドのダッシュボードへリダイレクトする。
"""
settings = get_settings()
# 末尾のスラッシュ調整などは必要に応じて行うが、基本は設定値を信頼
target_url = settings.frontend_url
return RedirectResponse(url=target_url, status_code=302)
34 changes: 24 additions & 10 deletions app/core/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,32 @@ async def dispatch(
user_info["sub"] = oidc_identity
if oidc_data:
try:
# JWT payload is the second part
payload_part = oidc_data.split(".")[1]
# Add padding if needed
payload_part += "=" * (-len(payload_part) % 4)
decoded = base64.urlsafe_b64decode(payload_part)
jwt_payload = json.loads(decoded)
user_info["email"] = jwt_payload.get("email")
user_info["username"] = jwt_payload.get(
"username"
) or jwt_payload.get("cognito:username")
# Format: header.payload.signature
parts = oidc_data.split(".")
if len(parts) > 1:
payload_part = parts[1]
# Add padding if needed
payload_part += "=" * (-len(payload_part) % 4)
decoded = base64.urlsafe_b64decode(payload_part)
jwt_payload = json.loads(decoded)

# Extract standard claims
user_info["email"] = jwt_payload.get("email")
# Cognito often provides 'cognito:username' or just 'username'
user_info["username"] = (
jwt_payload.get("username")
or jwt_payload.get("cognito:username")
or jwt_payload.get("email") # Fallback to email as username
)
# Extract role if present (custom attribute)
user_info["role"] = jwt_payload.get("custom:role") or "user"
except Exception as e:
print(f"Failed to decode OIDC data: {e}")
# If decoding fails, we still have the identity (sub) from header
if self.debug:
import traceback
traceback.print_exc()

elif self.debug:
# Local Development Mock
user_info = {
Expand Down
3 changes: 3 additions & 0 deletions app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ class AppSettings(BaseSettings):
database_url: str = Field(
default="sqlite:///./app_dev.sqlite3", alias="DATABASE_URL"
)
frontend_url: str = Field(
default="http://localhost:3000", alias="FRONTEND_URL"
)

aws: AWSSettings = Field(default_factory=AWSSettings)
llm: LLMSettings = Field(default_factory=LLMSettings)
Expand Down
125 changes: 125 additions & 0 deletions docs/auth-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# ALB + Cognito 認証フロー

## シーケンス図

```mermaid
sequenceDiagram
autonumber
actor User as ユーザー (Browser)
participant S3 as CloudFront + S3<br>(Reactアプリ)
participant ALB as ALB<br>(ロードバランサー)
participant Cognito as Cognito<br>(認証画面)
participant API as ECS Fargate<br>(FastAPI)

Note over User, S3: 1. アプリへの初回アクセス
User->>S3: https://myapp.com へアクセス
S3-->>User: React画面を返す (未ログイン状態)

Note over User, Cognito: 2. ログインフロー開始
User->>ALB: 「ログイン」ボタン押下<br>(任意の保護されたAPI /api/* へアクセス)
ALB-->>User: 302 Redirect (Cognitoへ誘導)

User->>Cognito: 自動的にAWSのログイン画面へ移動
User->>Cognito: ID / Password を入力
Cognito-->>User: 302 Redirect (ALBへ戻る + 認証コード)

Note over User, API: 3. セッション確立
User->>ALB: 認証コードを持ってALBへアクセス
ALB->>Cognito: 裏でトークン交換と検証
ALB-->>User: 元の画面へ戻す + Cookie (AWSELBAuthSessionCookie) を付与

Note over User, API: 4. 認証済み通信
User->>ALB: APIリクエスト (Cookie付き)
ALB->>ALB: Cookie検証 (OK)
ALB->>API: リクエスト転送 (x-amzn-oidc-dataヘッダ付与)
API-->>User: データ返却 (JSON)
```

## フェーズ1:認証(ログインして鍵をもらう)

AWSのインフラ(ALB + Cognito)が裏側で複雑な処理をすべて代行する。

1. **ユーザー操作**:Reactアプリ内の「ログイン」ボタンを押す(保護されたAPIを呼び出す)。
2. **ALBの処理(誘導)**
- リクエストに「鍵(Cookie)」がない場合、ALBは即座にCognitoのHosted UI(AWS標準のログイン画面)へリダイレクトする。
3. **Cognitoの処理**
- ユーザーはAWSの画面でIDとパスワードを入力する。
- 認証が成功すると、CognitoはALBへ「認証コード」を持って戻るよう指示する。
4. **トークンとクッキーの取得**
- ALBは裏側でCognitoと通信し、IDトークンなどを取得する。
- **重要**:ALBは取得したトークンをユーザーには直接渡さず、代わりに**セッションCookie (`AWSELBAuthSessionCookie`)** を発行してブラウザに保存させる。
- ユーザーは元のReact画面に戻ってくる。

## フェーズ2:APIリクエスト(鍵を見せて通してもらう)

### **ステップA:React側でのリクエスト作成**

Reactコード内では、特別なヘッダー(`Authorization`)を作成する必要はない。
ブラウザの標準機能により、発行された **Cookie (`AWSELBAuthSessionCookie`)** が自動的にリクエストに添付される。

### **ステップB:ネットワーク通過(CloudFront & ALB)**

1. リクエストは `https://myapp.com/api/...` に飛ぶ。
2. CloudFrontは `/api/*` の設定に基づき、リクエストをALBへ転送する。
- **重要**:この時、CloudFrontの設定で **Cookieを「転送する(Whitelist または All)」** 設定にしておく必要がある。そうしないと、ALBに届く前に鍵(Cookie)が捨てられてしまい、無限にログイン画面へループしてしまう。

### **ステップC:ALBでの検証とFastAPIへの引き渡し**

1. **ALBによる検証(門番)**
- ALBはリクエストに含まれるCookieを検証する。
- **OK**:Cookieが有効なら、その中の情報を復号し、`x-amzn-oidc-data` というHTTPヘッダーにユーザー情報(JWT)を詰め込んで、FastAPIへ転送する。
- **NG**:Cookieが無効/期限切れなら、FastAPIには通さず、再度Cognitoのログイン画面へ飛ばす。
2. **FastAPIでの利用(アプリの処理)**
- FastAPIには、すでに**ALBという信頼できる門番がチェックを済ませたリクエスト**しか届かない。
- そのため、署名検証などの複雑なチェックは必須ではなくなる(ゼロトラストの観点で行ってもよい)。
- ヘッダーの `x-amzn-oidc-data` から「誰がアクセスしたか(メールアドレス等)」を取り出し、ビジネスロジックを実行する。

## ALBが付与するヘッダー一覧

ALBは認証済みリクエストをバックエンドに転送する際、以下のヘッダーを付与する。

| ヘッダー名 | 説明 | 例 |
|-----------|------|-----|
| `x-amzn-oidc-identity` | ユーザー識別子(Cognito Sub) | `abcd-1234-efgh-5678` |
| `x-amzn-oidc-data` | ユーザー情報(JWT形式、Base64エンコード) | `eyJraWQ...` |
| `x-amzn-oidc-accesstoken` | アクセストークン(JWT) | `eyJraWQ...` |

### `x-amzn-oidc-data` のペイロード例

```json
{
"sub": "abcd-1234-efgh-5678",
"email": "user@example.com",
"username": "user@example.com",
"cognito:username": "user@example.com",
"custom:role": "admin"
}
```

## ローカル開発時の動作

ローカル環境ではALBが存在しないため、認証ヘッダーが付与されない。
バックエンドの `API_DEBUG=true` 設定により、モックユーザーが自動的に適用される。

### 設定方法

`.env` ファイルで以下を設定:

```bash
API_DEBUG=true
```

### モックユーザー情報

`API_DEBUG=true` の場合、以下のユーザー情報が `request.state.user` に設定される:

```python
{
"sub": "local-dev-user-id",
"username": "local_dev_user",
"email": "dev@example.com",
"role": "admin",
}
```

**注意**: 本番環境では必ず `API_DEBUG=false` に設定すること。
Loading