
# ローカルモックサーバー

`keenauth mock serve` は、ネットワークにアクセスできない CI ジョブとローカル開発のために、OpenID Connect、SAML 2.0、SCIM 2.0 の小さなサーバーと、`/v1` 管理 API のメモリ内版を手元のマシンで起動します。KeenAuth の環境、API キー、インターネット接続は必要ありません。すべてのデータはメモリに保持され、プロセスが終了すると削除されます。

モックサーバーは KeenAuth そのものではありません。以下に示すフローのみを実装し、すべてのリクエストを自動的に承認し、実際のサービスよりはるかに少ない検査しか行いません。**実際の動作の基準は、ホスト型の `development` 環境だけです**。リリースする前に、その環境に対してエンドツーエンドテストを実行してください。

## サーバーの起動

```sh
keenauth mock serve
```

既定では、サーバーは `127.0.0.1:4010` で待ち受けます。これはループバックアドレスで、他のマシンからは到達できません。`--port` でポートを変更できます（`0` を指定すると空いているポートが選ばれます）。`--host` でアドレスを変更できますが、ループバック以外のアドレスを指定するとモックサーバーがネットワークに公開され、CLI は警告を表示します。Ctrl+C で停止します。

このコマンドは認証情報を読み込まず、KeenAuth へ何も送信しません。

## エンドポイント

| パス | 用途 |
|---|---|
| `/.well-known/openid-configuration` | OIDC ディスカバリードキュメント（`/.well-known/oauth-authorization-server` でも提供） |
| `/.well-known/jwks.json` | 公開テスト鍵セット（鍵 ID `keenauth-test-1`） |
| `/oauth2/authorize` | 認可エンドポイント：PKCE（`S256`）を使った認可コードフロー |
| `/oauth2/token` | トークンエンドポイント：`authorization_code` と `refresh_token` の grant |
| `/oauth2/userinfo` | UserInfo エンドポイント（スコープ `openid` を持つ Bearer アクセストークン） |
| `/saml-idp/<application_id>/metadata` | SAML 2.0 アイデンティティプロバイダーのメタデータ |
| `/saml-idp/<application_id>/sso` | SAML シングルサインオンエンドポイント（HTTP-Redirect および HTTP-POST バインディング） |
| `/scim/v2/<directory_id>/…` | SCIM 2.0 エンドポイント：`Users`、`Groups`、`ServiceProviderConfig`、`ResourceTypes`、`Schemas` |
| `/v1/…` | メモリ内の管理 API：管理 API の OpenAPI ドキュメントの全操作 |

ディスカバリードキュメントと SAML メタデータのエンドポイント URL は、サーバーが待ち受けているローカルアドレスを指します。`<application_id>` と `<directory_id>` は任意の名前です（`A-Z`、`a-z`、`0-9`、`-`、`_` からなる 1〜64 文字）。事前に登録する必要はありません。

## 動作

- **同意画面もサインイン画面もありません。** 有効な認可リクエストはすべて、固定の架空のテストユーザーとして即座に承認され、`code`、`state`、`iss` を付けてリダイレクトされます。ユーザーの `sub` は `usr_01m3vnbwg0e8mbe7h7xxp6rmkc`、メールアドレスは `test.user@example.test`（検証済み）、名前は `Test User` です。
- **登録済みクライアントはありません。** どの `client_id` も受け付けます。クライアントシークレットは検証しません。`redirect_uri` は絶対 `https` URI、またはループバックホスト（`127.0.0.1`、`[::1]`、`localhost`）上の `http` URI である必要があり、フラグメントを含めることはできません。無効な `redirect_uri` や `client_id` の欠落は、リダイレクトではなくエラーページになります。
- **PKCE は必須です。** `code_challenge_method=S256` を指定した `code_challenge` を送る必要があります。`plain` は拒否されます。トークンリクエストでは、対応する `code_verifier` を送る必要があります。
- **コードは一度しか使えず**、60 秒で期限切れになります。同じコードを二度提示すると `invalid_grant` で失敗し、そのコードから発行されたすべてのトークンが取り消されます。
- **トークン。** アクセストークンは RFC 9068 の JWT（`typ` は `at+jwt`）で、有効期間は 1 時間です。`sub`、`client_id`、`scope`、ランダムな `jti` を含みます。オーディエンスは認可リクエストの `resource` パラメーターの値で、送られていない場合は `https://test.keenauth.invalid/oauth2/userinfo` です。スコープ `openid` を指定すると ID トークンも得られます（`nonce` と `auth_time` を含み、スコープ `email` と `profile` があれば `email` と `name` のクレームも含みます）。`offline_access` を指定するとリフレッシュトークンが得られます。
- **リフレッシュトークンはローテーションされます。** リフレッシュのたびに新しいリフレッシュトークンが返されます。古いものを提示すると失敗し、チェーン全体が取り消されます。リフレッシュでスコープを絞り込むことはできますが、広げることはできません。
- **UserInfo** は、このプロセスが発行し、期限切れや取り消しになっておらず、スコープ `openid` を持つアクセストークンのみを受け付けます。

## トークンと検証器

トークンは[オフラインテストキット](/docs/sdk/testing)の公開テスト鍵で署名され、ローカルアドレスではなく発行者 `https://test.keenauth.invalid` を持ちます。トップレベルドメイン `.invalid` は予約されているため、本番の検証器がこのトークンを受け付けることは決してありません。SDK の検証器は、テストコードで明示的に有効にしたテストモードでのみこれらを受け付けます。

```go
// Go: accept tokens from the mock server in a test.
v, err := keenauthtest.NewVerifier(verify.Config{Audience: "https://test.keenauth.invalid/oauth2/userinfo"})
if err != nil {
	t.Fatal(err)
}
claims, err := v.VerifyAccessToken(ctx, accessToken, nil)
```

TypeScript（`createTestVerifier`）と Python（`keenauth.testing.verifier`）でも同じテストモードを使用します。ディスカバリードキュメントの `issuer` が取得元 URL と一致することを要求する汎用の OIDC クライアントライブラリは、モックサーバーのディスカバリードキュメントを拒否します。その場合はエンドポイント URL を直接設定するか、`development` 環境に対してテストしてください。

## 例：curl による完全なフロー

この例には `curl`、`openssl`、`jq` が必要です。先に別のターミナルでサーバーを起動してください。

```sh
BASE=http://127.0.0.1:4010
REDIRECT=http://127.0.0.1:8080/callback
VERIFIER=$(openssl rand -hex 32)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')

# 1. Authorize: the server answers with a redirect that carries the code.
LOCATION=$(curl -s -o /dev/null -w '%{redirect_url}' --get "$BASE/oauth2/authorize" \
  --data-urlencode response_type=code --data-urlencode client_id=app_local \
  --data-urlencode redirect_uri="$REDIRECT" --data-urlencode 'scope=openid email offline_access' \
  --data-urlencode state=xyz --data-urlencode code_challenge="$CHALLENGE" \
  --data-urlencode code_challenge_method=S256)
CODE=$(printf '%s' "$LOCATION" | sed -E 's/.*[?&]code=([^&]+).*/\1/')

# 2. Exchange the code for tokens.
TOKENS=$(curl -s "$BASE/oauth2/token" -d grant_type=authorization_code -d client_id=app_local \
  --data-urlencode code="$CODE" --data-urlencode redirect_uri="$REDIRECT" -d code_verifier="$VERIFIER")

# 3. Call UserInfo with the access token.
curl -s "$BASE/oauth2/userinfo" -H "Authorization: Bearer $(printf '%s' "$TOKENS" | jq -r .access_token)"

# 4. Refresh.
curl -s "$BASE/oauth2/token" -d grant_type=refresh_token -d client_id=app_local \
  --data-urlencode refresh_token="$(printf '%s' "$TOKENS" | jq -r .refresh_token)"
```

手順 3 はテストユーザーを表示します：

```json
{"email":"test.user@example.test","email_verified":true,"sub":"usr_01m3vnbwg0e8mbe7h7xxp6rmkc"}
```

## SAML アイデンティティプロバイダー

開発中の SAML サービスプロバイダーのアイデンティティプロバイダーとして、モックサーバーを使用できます。サービスプロバイダーにメタデータ URL を指定してサインインすると、モックサーバーがテストユーザー向けの署名付きレスポンスを返します。パスは、KeenAuth が後のリリースで予定しているホスト型 SAML アイデンティティプロバイダーに準拠しています。

- **メタデータ。** `GET /saml-idp/<application_id>/metadata` は、エンティティ ID `https://test.keenauth.invalid/saml-idp/<application_id>`、署名証明書、NameID 形式 `persistent` と `emailAddress`、両バインディング用の SSO エンドポイントを含む `EntityDescriptor` を返します。署名付き AuthnRequest は必須ではありません（`WantAuthnRequestsSigned="false"`）。リクエストの署名は無視されます。
- **SSO。** AuthnRequest は HTTP-Redirect バインディング（`GET`、`SAMLRequest` に DEFLATE + base64）または HTTP-POST バインディング（`POST`、base64）で送信します。サインイン画面はありません。有効なリクエストは即座にテストユーザーとして承認され、`SAMLResponse`（と `RelayState`、最大 80 バイト）をサービスプロバイダーの ACS URL へ自動的に送信する HTML ページが返されます。
- **AuthnRequest に必要な要素。** `ID`、`Version="2.0"`、`IssueInstant`、`saml:Issuer`（サービスプロバイダーのエンティティ ID）、`AssertionConsumerServiceURL`。モックサーバーには登録済みのサービスプロバイダーがないためです。ACS URL は絶対 `https` URL、またはループバックホスト上の `http` URL で、フラグメントを含めることはできません。`ProtocolBinding` を指定する場合は HTTP-POST、`Destination` を指定する場合は SSO URL でなければなりません。それ以外のリクエストは `400` を受け取り、どこにも送信されません。DTD は拒否されます。
- **レスポンス。** RSA-SHA256 と排他的正規化で 2 回署名されます。まずアサーション、次にレスポンス全体です。`InResponseTo` はリクエストの `ID`、`Destination` と Subject Confirmation の `Recipient` は ACS URL、`Audience` はリクエストの `Issuer` です。アサーションは発行時刻から 5 分間有効です。NameID は（リクエストで `emailAddress` を指定しない限り）`persistent`（アプリケーションごとに安定した値）です。属性は `email`、`given_name`、`family_name`、`name` です。

**署名鍵は公開されています。** 公開されたラベルから導出されているため、誰でもこの証明書で検証されるレスポンスに署名できます。SHA-256 フィンガープリントは `fcc2fc37b9ddb800721283156f9eb26ff1b80f099d996a65650f8cb469a9be81` で、サブジェクトには `TEST ONLY` と書かれています。サービスプロバイダーの開発環境およびテスト環境の設定にのみ登録してください。本番環境には決して登録しないでください。KeenAuth 自身の SAML 接続では、この制限を強制しています。ステージングおよび本番環境では、メタデータのインポートや証明書の追加時、さらにサインイン時にも、この鍵と `.invalid` 配下のエンティティ ID を拒否します。開発環境では受け付けます。

### 例：curl による SAML サインイン

この例には `curl` と `openssl` が必要です。先に別のターミナルでサーバーを起動してください。

```sh
BASE=http://127.0.0.1:4010
APP=app_local
ACS=http://127.0.0.1:8080/saml/acs

# 1. IdP metadata: entity ID, signing certificate and SSO URL for your service provider.
curl -s "$BASE/saml-idp/$APP/metadata" -o idp-metadata.xml

# 2. Send an AuthnRequest with the HTTP-POST binding (base64, no compression).
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
REQUEST=$(printf '<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_req1" Version="2.0" IssueInstant="%s" AssertionConsumerServiceURL="%s"><saml:Issuer>https://sp.example.com</saml:Issuer></samlp:AuthnRequest>' "$NOW" "$ACS" | openssl base64 -A)
curl -s "$BASE/saml-idp/$APP/sso" --data-urlencode SAMLRequest="$REQUEST" --data-urlencode RelayState=xyz -o form.html

# 3. The page posts SAMLResponse to the ACS URL by itself; decode it to look at the signed response.
sed -n 's/.*name="SAMLResponse" value="\([^"]*\)".*/\1/p' form.html | openssl base64 -d -A > response.xml
grep -o 'InResponseTo="[^"]*"' response.xml | head -1
```

最後のコマンドは `InResponseTo="_req1"` を表示します。サービスプロバイダーは、`idp-metadata.xml` の証明書を使って `response.xml` を検証します。

## SCIM 2.0 エンドポイント

SCIM エンドポイントは、プロビジョニングコネクターや、ユーザーとグループを作成するスクリプトなど、SCIM クライアントをネットワークなしでテストするために使用します。各 `<directory_id>` は、プロセスが動いている間だけ存在する、独立した空のディレクトリです。

- **認証。** すべてのリクエストは `Authorization: Bearer keenauth-mock-scim-token` を必要とします。それ以外は `401` と `WWW-Authenticate: Bearer` のチャレンジで拒否されます。値は固定かつ公開されたものであり、KeenAuth の SCIM トークンではありません。
- **ユーザーとグループ。** `/Users` と `/Groups` に対する `POST`、`GET`、`PUT`、`PATCH`、`DELETE`（RFC 7644）。`userName` とグループの `displayName` は必須で、大文字と小文字を区別せずディレクトリ内で一意です。重複すると `409` と `scimType` `uniqueness` が返されます。グループのメンバーは同じディレクトリのユーザーでなければなりません。ユーザーを削除すると、そのユーザーはグループから削除されます。`password` は受け付けられますが破棄されます。
- **フィルターとページ。** `filter` は `and` で連結した `eq`、`ne`、`co`、`sw`、`ew`、`pr` をサポートします（例：`userName eq "ada@example.test"`）。加えて `startIndex` と `count`（最大 200）をサポートします。`attributes` と `excludedAttributes` はトップレベル属性を選択します。`POST /Users/.search` と `/Groups/.search` は、同じパラメーターを本文で受け付けます。
- **PATCH** は、`active`、`name.givenName`、`emails[type eq "work"].value`、`members[value eq "<id>"]`、エンタープライズ拡張属性などのパスで `add`、`remove`、`replace` をサポートします。1 つのリクエストの操作は、すべて適用されるかまったく適用されないかのどちらかです。
- **バージョン。** すべてのリソースは `meta.version` と `ETag` ヘッダー（`W/"1"`、`W/"2"`、…）を持ちます。`PUT`、`PATCH`、`DELETE` の `If-Match` は、バージョンが変わっていれば `412` を返します。`GET` の `If-None-Match` は `304` を返します。
- **ディスカバリー。** `ServiceProviderConfig`、`ResourceTypes`、`Schemas` は、この動作を正確に記述します。`/Bulk` と `/Me` は、KeenAuth と同様に `501` を返します。

### 例：curl でユーザーをプロビジョニングする

この例には `curl` と `jq` が必要です。先に別のターミナルでサーバーを起動してください。

```sh
SCIM=http://127.0.0.1:4010/scim/v2/dir_local
AUTH='Authorization: Bearer keenauth-mock-scim-token'
CT='Content-Type: application/scim+json'

# 1. Create a user; the response carries the id, meta.version and an ETag header.
USER_ID=$(curl -s "$SCIM/Users" -H "$AUTH" -H "$CT" -d '{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "userName": "ada@example.test",
  "name": {"givenName": "Ada", "familyName": "Lovelace"},
  "emails": [{"value": "ada@example.test", "type": "work", "primary": true}]
}' | jq -r .id)

# 2. Find it with a filter.
curl -s -G "$SCIM/Users" -H "$AUTH" --data-urlencode 'filter=userName eq "ada@example.test"' | jq .totalResults

# 3. Deactivate it with PATCH, guarded by the version you read.
curl -s -X PATCH "$SCIM/Users/$USER_ID" -H "$AUTH" -H "$CT" -H 'If-Match: W/"1"' -d '{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [{"op": "replace", "path": "active", "value": false}]
}' | jq -c '{active, version: .meta.version}'

# 4. Create a group with the user as a member, then delete the user.
curl -s "$SCIM/Groups" -H "$AUTH" -H "$CT" -d '{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
  "displayName": "Engineering",
  "members": [{"value": "'"$USER_ID"'"}]
}' | jq -c '.members[0] | {display, type}'
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE "$SCIM/Users/$USER_ID" -H "$AUTH"
```

コマンドは `1`、`{"active":false,"version":"W/\"2\""}`、`{"display":"ada@example.test","type":"User"}`、`204` を表示します。

## 管理 API（`/v1`）

管理 API のエンドポイントは、KeenAuth の管理 API を呼び出すコード（SDK、CLI、デプロイスクリプトなど）を、ネットワークなしの CI でテストするために使用します。パスはホスト型のものと同じです。ホスト型の代わりに基底 URL `http://127.0.0.1:4010/v1` を使うだけで、他は変わりません。エンドポイントは CLI に組み込まれた管理 API の OpenAPI ドキュメントから生成されるため、そのバージョンの API の全操作に応答します。

- **認証。** すべてのリクエストは `Authorization: Bearer keenauth-mock-management-token` を必要とします。それ以外は、未知のパスであっても `401` と `WWW-Authenticate: Bearer` のチャレンジで拒否されます。値は固定かつ公開されたものであり、KeenAuth の管理キーではありません。
- **レスポンス。** 各操作は、ドキュメントが宣言する成功ステータス（`200`、`201`、`202`、`204`。2 つのダウンロード操作は、解決しない `Location` を持つ `302` を返します）で応答します。本文は、ドキュメント内の成功レスポンスの `example`、またはそれがまだない場合はレスポンススキーマから作られる最小の有効な例です。必須フィールドのみを含み、null 可能なフィールドは `null`、列挙型は最初の値、文字列は `pattern` に一致する値になります。
- **単純な状態。** レコードを作成できるコレクション（`POST /v1/users`、`/v1/organizations`、`/v1/applications`、`/v1/organizations/<org_id>/roles` など）はレコードを保持します。作成したレコードは、自身のパスと一覧の `GET` で返されます。`PATCH` と `PUT` で変更でき、`DELETE` は配下のものも含めて削除します。存在しないレコードの下のパスは `404` です。識別子は KeenAuth のプレフィックス形式（`usr_…`、`org_…`、`app_…`）で、ドキュメントのパターンに一致します。ドキュメントに指定がない場合は、パラメーター名からプレフィックスが導出されます。環境ごとに 1 つだけ存在する設定（例：`/v1/identity-settings`）は、`PATCH` または `PUT` で書き込んだ内容を保持します。API から作成できないリソース（セッションやレポートなど）は、常にサンプルで応答します。
- **一覧。** `limit`（1〜100、既定値 20）、カーソルとしてレコード識別子を指定する `after` または `before`、`order`（既定は `desc`、新しいものから）が機能します。レスポンスには `list_metadata` が含まれます。`status` などのフィルターは適用されません。
- **エラー** は、ドキュメントどおり `application/problem+json` で、`code` と、`Idp-Request-Id` ヘッダーにも含まれる `request_id` を持ちます。未知のパスは `404 not_found`、パスが持たないメソッドは `Allow` ヘッダー付きの `405 method_not_allowed`、有効な JSON（またはオブジェクト）ではない本文は `400 malformed_request`、JSON 以外の `Content-Type` はそれを列挙する操作で `415`、必須フィールドの欠落は `errors[]` 付きの `422 validation_failed`、1 MiB を超える本文は `413`、不正な `limit` やカーソルは `400` です。

**`development` 環境との違い。** モックサーバーが検証するのは、本文が必須フィールドを持つ JSON オブジェクトであることだけです。フィールドの形式、長さ、列挙値、一意性は検査されず、型の誤ったフィールドは無視されます。`If-Match`、`Idempotency-Key`、その他のヘッダーは無視され、`ETag` は返されません。権限、プランの上限、クォータ、レート制限、リージョンは検査されず、個人データはマスクされず、メールは送信されず、ジョブは実行されません（受け付けられたジョブは作成時の状態のままです）。環境は 1 つだけです。レコードはプロセスの存続期間中だけ存在し、最大 10,000 件です。実際の API の動作を確認するには、`development` 環境を使用してください。

### 例：curl で作成、読み取り、変更、削除する

この例には `curl` と `jq` が必要です。先に別のターミナルでサーバーを起動してください。

```sh
API=http://127.0.0.1:4010/v1
AUTH='Authorization: Bearer keenauth-mock-management-token'
CT='Content-Type: application/json'

# 1. Create an organization.
ORG_ID=$(curl -s "$API/organizations" -H "$AUTH" -H "$CT" -d '{"name": "Acme"}' | jq -r .id)

# 2. Read it back and list.
curl -s "$API/organizations/$ORG_ID" -H "$AUTH" | jq -c '{id: (.id | .[0:4]), name}'
curl -s "$API/organizations" -H "$AUTH" | jq -c '{count: (.data | length), list_metadata}'

# 3. Rename it with a merge patch.
curl -s -X PATCH "$API/organizations/$ORG_ID" -H "$AUTH" -H 'Content-Type: application/merge-patch+json' -d '{"name": "Acme Inc"}' | jq -r .name

# 4. Delete it; a second read is a 404 problem.
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE "$API/organizations/$ORG_ID" -H "$AUTH"
curl -s "$API/organizations/$ORG_ID" -H "$AUTH" | jq -c '{status, code}'

# 5. Without the token.
curl -s "$API/organizations" | jq -c '{status, code}'
```

コマンドは `{"id":"org_","name":"Acme"}`、`{"count":1,"list_metadata":{"after":null,"before":null}}`、`Acme Inc`、`204`、`{"status":404,"code":"not_found"}`、`{"status":401,"code":"unauthenticated"}` を表示します。

## 非対応

テスト用クロック、DPoP、PAR、リクエストオブジェクト、デバイスフロー、クライアントクレデンシャル、トークン交換、ログアウトや取り消し、ホスト型ページはありません。SAML アイデンティティプロバイダーには、シングルログアウト、暗号化アサーション、アーティファクトバインディング、IdP 起点のサインインはありません。SCIM エンドポイントには、一括操作、ソート、`/Me`、入れ子のグループはなく、フィルターは `or`、`not`、括弧、`gt`/`ge`/`lt`/`le` を扱えません。これらのテストには `development` 環境を使用してください。
