
# Go net/http セッション

パッケージ `github.com/keenauth/keenauth-go/bff` は、Go の `net/http` アプリケーションにサインイン機能を追加します。サーバーが OAuth クライアントとなり、すべてのトークンをサーバー側で保持します。ブラウザが受け取るのは暗号化された HttpOnly のセッション Cookie だけで、アクセストークン、リフレッシュトークン、ID トークンが届くことはありません。これは Backend-for-Frontend（BFF）パターンです。パッケージは標準ライブラリのみを使用します。まだ公開されていません。

ベアラートークンを受け取る API を保護する場合は、[API の保護](/docs/sdk/protect-api) を参照してください。

## 始める前に

1. ダッシュボードで、環境内に Web アプリケーションを作成します。
2. クライアント認証方式を選択します。クライアントシークレット（`bff.ClientSecretBasic`）または `private_key_jwt`（`bff.PrivateKeyJWT`）のいずれかです。`private_key_jwt` を使う場合は、アプリケーションの公開鍵を登録し、秘密鍵はサーバー上で保管してください。
3. リダイレクト URI `https://app.acme.example/auth/callback` と、サインアウト後のリダイレクト URI `https://app.acme.example/` を追加します。
4. 32 文字以上の Cookie 用シークレットを生成します。たとえば `openssl rand -base64 32` を使用します。シークレットはリポジトリではなく、シークレットストアに保管してください。

## セットアップ

```go
package main

import (
	"fmt"
	"net/http"
	"os"

	"github.com/keenauth/keenauth-go/bff"
)

func main() {
	auth, err := bff.New(bff.Options{
		Issuer:     "https://acme.eu.keenauth.app", // your environment's issuer, exactly
		ClientID:   "app_01j9zzzzzzzzzzzzzzzzzzzz",
		ClientAuth: bff.ClientSecretBasic{Secret: os.Getenv("KEENAUTH_CLIENT_SECRET")},
		AppBaseURL: "https://app.acme.example",
		Secrets:    []string{os.Getenv("KEENAUTH_COOKIE_SECRET")},
	})
	if err != nil {
		panic(err) // configuration errors stop the app at start-up
	}

	mux := http.NewServeMux()
	mux.Handle("GET /orders", auth.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		s := bff.FromContext(r.Context()).Session
		fmt.Fprintf(w, "Signed in as %s", s.User.Email)
	})))

	http.ListenAndServe(":8080", auth.Middleware(mux))
}
```

クライアントシークレットの代わりに `private_key_jwt` を使うには、秘密鍵を渡します。パッケージは、トークンリクエストと PAR リクエストごとに新しいクライアントアサーションを署名します（`iss` と `sub` はクライアント ID、`aud` は発行者、有効期間は 2 分、`jti` は毎回新しい値になります）。

```go
pemBytes, err := os.ReadFile("/run/secrets/keenauth-app-key.pem")
if err != nil {
	panic(err)
}
key, err := assertion.ParsePrivateKeyPEM(pemBytes) // import "github.com/keenauth/keenauth-go/assertion"
if err != nil {
	panic(err)
}
auth, err := bff.New(bff.Options{
	Issuer:     "https://acme.eu.keenauth.app",
	ClientID:   "app_01j9zzzzzzzzzzzzzzzzzzzz",
	ClientAuth: bff.PrivateKeyJWT{Key: key, KeyID: "app-key-2026-11"},
	AppBaseURL: "https://app.acme.example",
	Secrets:    []string{os.Getenv("KEENAUTH_COOKIE_SECRET")},
})
```

ES256（P-256）、EdDSA（Ed25519）、RS256 および PS256（RSA、2048 ビット以上）に対応しています。アルゴリズムは鍵に従って決まり、RSA 鍵で PSS を使う場合は `Algorithm: "PS256"` を指定します。`Key` には任意の `crypto.Signer` を指定できるため、KMS や HSM で保管している鍵も使用できます。別の場所で署名する場合は、代わりに `bff.PrivateKeyJWT{Assertion: f}` を設定してください。`f` は呼び出しのたびに新しい署名済みアサーションを返す関数です。`Key` と `Assertion` は両方ではなく、いずれか一方のみを設定してください。

`AppBaseURL` はアプリの公開アドレスです。パッケージはリダイレクト URI と、すべてのリダイレクトをこの値から組み立てます。リクエストの `Host` ヘッダーや `X-Forwarded-*` ヘッダーからは組み立てません。平文の `http` は `localhost` の場合にのみ許可されます。

## ルート

`Middleware` は 3 つのルートを提供し、それ以外のすべてのリクエストでセッションを読み取ります。`mux.Handle("/auth/", auth)` を使って、ルートだけを単独でマウントすることもできます。

| ルート | 役割 |
|---|---|
| `GET /auth/login` | サインインを開始します。省略可能なクエリパラメータは `returnTo`（ユーザーが戻る先）と `organization`（組織にサインインする）です。 |
| `GET /auth/callback` | サインインを完了させ、セッション Cookie を設定します。 |
| `GET` または `POST /auth/logout` | KeenAuth でサインアウトし、アプリに戻します。 |

サインインには PKCE（S256）を用いた認可コードフローを使用します。環境がサポートしている場合は、リクエストをサーバーにプッシュ（PAR）します。コールバックでは、パッケージが `state`、`iss`、および ID トークン（`nonce` を含む）を検証します。サインインが成功するたびに新しいセッションが作成され、古いセッションは破棄されます。

`returnTo` にはアプリ内のパスを指定する必要があります（例: `/orders?tab=open`）。`//evil.example` や完全な URL など、それ以外の値は無視され、ユーザーは `/` に移動します。

## ハンドラーの保護

`auth.RequireAuth(handler)` は、セッションがあるリクエストのみを通過させます。

- セッションのないページリクエスト（`GET` または `HEAD`）は、`returnTo` を付けて `/auth/login` にリダイレクトされます。
- `/api/…` へのリクエスト、またはそれ以外のメソッドのリクエストには、問題ドキュメントを含む `401` が返されます。どのパスでも `401` を返すには `bff.GuardOptions{API: true}` を指定します。

`POST`、`PUT`、`PATCH`、`DELETE` など、安全でないメソッドでは、ブラウザがリクエストはアプリから送られたものであることを示す必要があります。具体的には、`Sec-Fetch-Site: same-origin` を送るか、アプリのオリジンと一致する `Origin` ヘッダーを送ります。それ以外のリクエストには `403` が返されます。これは `SameSite=Lax` に加えて、クロスサイトリクエストフォージェリから保護するためのものです。

## セッションの利用と API の呼び出し

`bff.FromContext(r.Context())` は次の値を返します。

- `Session`: `nil`、またはサインイン済みのユーザーです。`Session.User` には `Subject`、`Email`、`Name`、`OrgID`、`OrgRoles` などの型付き ID トークンクレームが含まれます。すべてのクレームは `Session.User.Claims` にあります。セッションにトークンが含まれることはありません。
- `AccessToken`: サーバー側の呼び出しに使える有効なアクセストークン、または `""` です。ブラウザには送らないでください。

アクセストークンの有効期限が 60 秒以内に迫ると、ハンドラーの実行前にパッケージがトークンを更新します。更新のたびにリフレッシュトークンもローテーションされ、新しいセッション Cookie がレスポンスに設定されます。サーバーがリフレッシュトークンを拒否した場合、セッションは終了します。一時的なサーバーエラーの場合は、セッションは変更されません。1 つのプロセス内で同時に発生したリクエストは、1 回のリフレッシュを共有します。

## 組織の切り替え

`auth.SwitchOrganizationPath("org_01j9…", "/dashboard")` は、`/auth/login` へのリンクを返します。このリンクを使うと、ユーザーは指定した組織で再度サインインします。KeenAuth はメンバーシップを確認します。サインイン後、`Session.User.OrgID` にその組織が格納されます。

## サインアウト

フォームから `/auth/logout` に `POST` を送信します。パッケージはセッション Cookie を削除し（ストアを使用している場合は保存済みセッションも削除し）、リフレッシュトークンを取り消して、ブラウザを環境のログアウトエンドポイントに送ります。そこからアプリに戻されます。ブラウザが別のサイトからのリクエストとして示したリクエストは拒否されます。`Sec-Fetch-Site` を送らないブラウザでは、自社のページからの `POST` によってのみサインアウトできます。

## セッションの保存

デフォルトでは、セッション全体が Cookie に格納されます。この Cookie は、シークレットから導出した鍵を用いて AES-256-GCM で暗号化され、認証されます。変更された Cookie や複製された Cookie は拒否されます。Cookie の名前は `__Host-keenauth_session.0` で、`Secure`、`HttpOnly`、`SameSite=Lax`、`Path=/` が設定されます。`http://localhost` では、`__Host-` プレフィックスと `Secure` は付与されません。大きなセッションは最大 3 つの Cookie に分割されます。

トークンをサーバー側に保持するには、`Options.Store` に `bff.SessionStore`（`Get`、`Set`、`Delete` を持つ型）を設定します。たとえば Redis を使用する実装などです。この場合、Cookie には暗号化されたランダムなセッション ID のみが格納されます。サインインのたびに新しい ID が作成され、以前のレコードは削除されます。ストアにはトークンが含まれるため、アクセスを制限してください。`bff.MemoryStore` は開発環境とテストでのみ使用してください。

セッションの有効期間は最長 7 日です（`SessionMaxAge`）。リフレッシュしても有効期間は延長されません。シークレットを変更するには、新しいシークレットを先頭に指定します: `Secrets: []string{newSecret, oldSecret}`。古いシークレットは `SessionMaxAge` の経過後に削除してください。

## 現在の制限

- DPoP にバインドされたトークンと、バックチャネルログアウトはまだ扱えません。
- リフレッシュの共有は 1 つのサーバープロセス内に限られます。複数のインスタンスで運用する場合は、アプリケーション設定の `refresh_reuse_interval_s`（デフォルトは 5 秒）により、並行するリフレッシュが成功するようになります。`0` に設定すると、2 回目のリフレッシュでセッションが終了します。
