> ## Documentation Index
> Fetch the complete documentation index at: https://docs.didit.me/llms.txt
> Use this file to discover all available pages before exploring further.

# JWT検証とJWKS：Didit AuthenticationのJWTを検証する

> Didit Authenticationのトークン仕様：PKCEによるコード交換、ES256署名のアクセストークンとIDトークンのクレーム、アプリケーションごとのディスカバリーとJWKS、鍵ローテーション、リフレッシュトークンの再利用検知。

> [!WARNING]
>
> 認証機能は新しい機能です。このページのすべての呼び出しは、**エンドユーザー**の認証情報を使用するか、認証情報を一切使用しません。検証用のAPIキーやコンソールトークンは、これらのエンドポイントに絶対に送信しないでください。

各アプリケーションは、それぞれが独立したOpenID Connectの発行者(issuer)です。発行者は、アプリケーションのサインイン用オリジンです。

* `https://<name>.auth.didit.me`:本番環境のアプリケーションの場合
* `https://<name>.sandbox.auth.didit.me`:サンドボックスのアプリケーションの場合
* または、有効化された後の独自のカスタムドメイン

本番環境とサンドボックスは別々の発行者であり、キーも別々です。`DIDIT_AUTH_ISSUER`には末尾のスラッシュを付けずに発行者を設定し、`DIDIT_APPLICATION_ID`にはアプリケーションIDを設定してください。トークンの`aud`にはこのIDが含まれます。公開可能キー(publishable key)には発行者がエンコードされています。`pk_live_`または`pk_test_`に続く部分が、パディングなしのbase64urlで表した発行者です。

> [!NOTE]
>
> カスタムドメインが有効になると、デフォルトのホストに代わってそのドメインが発行者になります。新しいトークンは`iss`でそのドメインを示し、ディスカバリーでは`jwks_uri`がそのドメインを指し、公開可能キーも変わります。その後、デフォルトのホストで発行されたアクセストークンは、リフレッシュされるまで`GET /session/`で失敗します。ドメインを有効化する際に`DIDIT_AUTH_ISSUER`を更新するか、古いトークンが期限切れになるまで、最大5分間、両方の発行者を受け入れてください。

このページのサーバーエンドポイント(ディスカバリー、キー、トークン、セッション)は、[Authentication APIリファレンス](https://docs.didit.me/openapi-authentication.json)に掲載されています。このリファレンスは[openapi-authentication.json](https://docs.didit.me/ja/authentication/api-reference/get-openid-configuration.md)から生成されています。ホスト型サインインページは掲載されていません。

## ディスカバリーとキー

| リクエスト | 結果 |
| - | - |
| `GET {issuer}/.well-known/openid-configuration` | `issuer`、`authorization_endpoint`、`token_endpoint`、`jwks_uri`、および発行者がサポートする内容。 |
| `GET {issuer}/.well-known/jwks.json` | `{"keys": [...]}`、署名用公開鍵。 |

発行者は以下をサポートしています。

* レスポンスタイプ`code`とレスポンスモード`query`。
* グラントタイプ`authorization_code`と`refresh_token`。
* PKCEメソッドは`S256`のみ。クライアントはパブリッククライアントです。クライアントシークレットはなく、`client_id`はアプリケーションIDです。
* 署名アルゴリズム`ES256`(P-256キー)。
* スコープ`openid`、`email`、`phone`、`profile`、`offline_access`。

userinfo、introspection、end-sessionのエンドポイントはなく、`prompt=none`もサポートされていません。セッションの読み取りと確認には[`GET /session/`](#セッションを確認する)を使用してください。

### キャッシュとローテーション

どちらのドキュメントも`Cache-Control: public, max-age=300`を返すため、ローカルキャッシュおよび共有キャッシュで5分間保持できます。多くのバックエンドインスタンスが1つのアドレスを共有することが多いため、IPアドレスごとに1分あたり1,200リクエスト、1時間あたり12,000リクエストまで許可しており、サインインのエンドポイントを大きく上回ります。

* 各キーには`kid`があり、新しいキーIDは`k_`で始まります。すべてのトークンは`kid`ヘッダーで署名に使用したキーを示します。
* キーは30日ごとにローテーションされます。Diditは次のキーを署名開始の1日前に公開し、以前のキーは署名停止後も1日間公開し続けます。そのため、5分間キャッシュしたJWKSはローテーションをまたいでも検証を続けられます。
* キーは`kid`ごとにキャッシュしてください。手元にない`kid`をトークンが示している場合はJWKSを再取得し(最大で1分に1回)、それでもキーが見つからなければトークンを拒否してください。
* 侵害されたキーをDiditが失効させると、そのキーは直ちにJWKSから削除され、`GET /session/`はそのキーで署名されたすべてのトークンを拒否します。代替キーは1日前の事前公開なしで、直ちに署名を開始します。最大5分前にキャッシュされたJWKSは失効したキーをまだ受け入れ、代替キーもまだ認識していないため、キャッシュが更新されるまで新しいトークンはオフライン検証に失敗する可能性があります。

## サインインの開始

`authorization_endpoint`であるホスト型ページを開きます。

```text theme={null}
{issuer}/sign-in?redirect_uri=...&state=...&code_challenge=...&code_challenge_method=S256
```

| パラメーター | ルール |
| - | - |
| `redirect_uri` | [登録済みのリダイレクトURL](https://docs.didit.me/ja/authentication/overview.md#リダイレクトurl)のいずれかと完全に一致する必要があります。 |
| `state` | 最大512文字。コールバック時に確認してください。 |
| `code_challenge` | PKCE verifierのSHA-256をbase64urlで表したもの。ちょうど43文字です。 |
| `code_challenge_method` | `S256`。 |
| `client_id` | 任意。アプリケーションIDと一致する必要があります。 |
| `nonce` | 任意。IDトークンにコピーされるため、このサインインに紐付けることができます。 |
| `scope`, `login_hint`, `ui_locales`, `max_age`, `prompt` | 任意。`login_hint`はメールアドレスのフィールドを事前入力するだけです。 |

[クイックスタート](https://docs.didit.me/ja/authentication/quickstarts/javascript.md)を使うと、このリクエストが自動で構築されます。

## コードの交換

コールバックは`code`と`state`を受け取ります。`state`を確認してから、60秒以内にコードを交換してください。各コードは1回しか使用できません。リクエストボディはJSONとフォームエンコードのどちらでも構いません。

```bash theme={null}
curl --fail-with-body "${DIDIT_AUTH_ISSUER}/token/" \
  -H 'Content-Type: application/json' \
  -d '{
    "grant_type": "authorization_code",
    "code": "CODE_FROM_CALLBACK",
    "code_verifier": "YOUR_PKCE_VERIFIER",
    "redirect_uri": "https://app.example.com/auth/callback"
  }'
```

```json theme={null}
{
  "access_token": "eyJhbGciOiJFUzI1NiIs...",
  "id_token": "eyJhbGciOiJFUzI1NiIs...",
  "refresh_token": "N4yZTl6y24YKYq77X_SO...",
  "token_type": "Bearer",
  "expires_in": 299,
  "session_id": "c9abf6cc-2d64-4bf2-b31e-a4d09a070ab1"
}
```

`expires_in`は、アクセストークンの残り秒数を示します。複数セッションを無効にしている場合、この交換によってその人の他のセッションは終了します。

| レスポンス | 意味 |
| - | - |
| `400 {"error": "invalid_grant"}` | コードが不明、使用済み、または期限切れである。verifierまたは`redirect_uri`が一致しない、あるいは`redirect_uri`がすでに登録されていない。その人がブロックされている、またはメールアドレスが認証済みでなくなっている。または、その人が使用したサインイン方法を無効にしている。 |
| `400 {"error": "invalid_client"}` | `client_id`が自身のアプリケーションIDではありません。 |
| `400 {"error": "mfa_required"}` | サインインがMFAポリシーを満たしていません。 |
| `400 {"<field>": [...]}` | フィールドが欠落しているか無効です。たとえば、グラントに必須フィールドがない場合は`{"non_field_errors": ["invalid_request"]}`となります。 |
| `400 {"<field>": "unknown_field"}` | エンドポイントが受け付けないフィールド(例:`client_secret`)がボディに含まれています。パブリッククライアントはクライアントシークレットを送信しません。 |

## アクセストークンのクレーム

アクセストークンは、ヘッダーが`alg: ES256`および`typ: at+jwt`のJWTです。有効期間は最大5分で、セッションより長くなることはありません。

| クレーム | 意味 |
| - | - |
| `iss` | お客様の発行者（issuer）。 |
| `aud` | お客様のアプリケーションID。 |
| `client_id` | お客様のアプリケーションID。 |
| `sub` | このアプリケーションにおけるその人物のアイデンティティID。 |
| `sid` | セッションID。AIエージェントのトークンの場合は、その人物がエージェントに付与したアクセスのID。 |
| `jti`, `iat`, `exp` | トークンID、発行時刻、有効期限。 |
| `vendor_data` | 本人確認およびトランザクションモニタリングと共有される、その人物の永続的な参照値。 |
| `amr` | サインイン方法。例：`["passkey"]`。 |
| `org` | お客様のアプリケーションにおける、その人物の最も古い組織メンバーシップの`{"id", "role"}`。メンバーシップがない場合や組織機能が無効の場合は`null`。`GET /session/`はすべてのメンバーシップを一覧表示します。 |
| `verified` | 認証レベルと選択したクレーム、または`{}`。セッション中に承認されたチェックは、次回のリフレッシュ時にトークンに反映されます。 |
| `act` | 本人の場合は`null`。本人に代わって動作するAIエージェントの場合は`{"sub", "agent", "scopes"}`。[ステップ5](#サーバーでトークンを検証する)を参照してください。 |

IDトークンのヘッダーは`typ: JWT`です。`client_id`を除く同じクレームに加えて`auth_time`と`email_verified`を含み、値がある場合は`email`、`name`、`phone_number`も含みます。`nonce`を含むのはコード交換で取得したIDトークンのみで、サインイン時にnonceが指定されていた場合に限ります。リフレッシュで取得したIDトークンには決して含まれないため、コード交換時に確認してください。IDトークンがアクセストークンとして受け入れられることはありません。

## サーバーでトークンを検証する

クレームを信頼する前に、以下をすべて確認してください。

1. `alg`が`ES256`、`typ`が`at+jwt`であること。`none`を含め、それ以外のアルゴリズムはすべて拒否してください。
2. `kid`がお客様のアプリケーションのJWKSに含まれ、署名が検証されること。
3. `iss`が設定済みの発行者と一致し、`aud`がお客様のアプリケーションIDと一致すること。
4. 最大60秒の時刻のずれを許容したうえで`exp`と`iat`が有効であり、`sub`、`sid`、`jti`が存在すること。
5. `act`が`null`であること。AIエージェントのアクセストークンは同じ鍵で署名され、同じ`typ`、`iss`、`aud`を持つため、区別できるのは`act`だけです。エンドポイントがエージェントを受け入れる場合を除き、拒否してください。受け入れる場合は、`act.scopes`が付与する操作のみを許可してください。`GET /session/`はエージェントのトークンを常に拒否します。

発行者はトークンからではなく、必ずお客様の設定から取得してください。

```javascript Node.js theme={null}
// npm install jose
import { createRemoteJWKSet, jwtVerify } from "jose";

const ISSUER = process.env.DIDIT_AUTH_ISSUER; // https://<name>.auth.didit.me
const AUDIENCE = process.env.DIDIT_APPLICATION_ID;
// Cache keys for 5 minutes; refetch an unknown kid at most once a minute.
const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`), {
  cacheMaxAge: 5 * 60 * 1000,
  cooldownDuration: 60 * 1000,
});

export async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, jwks, {
    issuer: ISSUER,
    audience: AUDIENCE,
    algorithms: ["ES256"],
    typ: "at+jwt",
    clockTolerance: 60,
    maxTokenAge: "5m", // also rejects an iat in the future
    requiredClaims: ["sub", "sid", "jti", "exp", "iat"],
  });
  if (payload.act !== null) throw new Error("agent tokens are not accepted");
  return payload; // payload.vendor_data is the person across Didit products
}
```

```python Python theme={null}
# pip install "pyjwt[crypto]"
import os
import threading
import time

import jwt

ISSUER = os.environ["DIDIT_AUTH_ISSUER"]  # https://<name>.auth.didit.me
AUDIENCE = os.environ["DIDIT_APPLICATION_ID"]
jwks = jwt.PyJWKClient(f"{ISSUER}/.well-known/jwks.json", lifespan=300)
refresh_lock = threading.Lock()
last_refresh = 0.0


def signing_key(kid: str | None) -> jwt.PyJWK:
    # PyJWKClient refetches for every unknown kid: limit it to once a minute.
    global last_refresh
    keys = {key.key_id: key for key in jwks.get_signing_keys()}
    if kid not in keys:
        with refresh_lock:
            if time.monotonic() - last_refresh >= 60:
                last_refresh = time.monotonic()
                fresh = jwks.get_signing_keys(refresh=True)
                keys = {key.key_id: key for key in fresh}
    if kid not in keys:
        raise jwt.InvalidTokenError("unknown signing key")
    return keys[kid]


def verify_access_token(token: str) -> dict:
    header = jwt.get_unverified_header(token)
    if header.get("typ") != "at+jwt":
        raise jwt.InvalidTokenError("not an access token")
    claims = jwt.decode(
        token,
        signing_key(header.get("kid")).key,
        algorithms=["ES256"],
        audience=AUDIENCE,
        issuer=ISSUER,
        leeway=60,
        options={"require": ["iss", "aud", "sub", "sid", "jti", "exp", "iat"]},
    )
    if "act" not in claims or claims["act"] is not None:
        raise jwt.InvalidTokenError("agent tokens are not accepted")
    return claims
```

オフライン検証では、直前に終了したセッションを検知できません。たとえば支払いの前など、それが重要な場合は`GET /session/`を呼び出してください。

## セッションを確認する

```bash theme={null}
curl --fail-with-body "${DIDIT_AUTH_ISSUER}/session/" \
  -H "Authorization: Bearer $DIDIT_ACCESS_TOKEN"
```

レスポンスには`sub`、`sid`、`application_id`、`vendor_data`、`amr`、`mfa_verified`、`email`、`email_verified`、`phone`、`verified_identity`、`verified`、`expires_at`、`post_logout_redirect_uri`、`organizations`が含まれます。各組織エントリには`organization_id`、`organization__name`、`role`、`custom_role`があります。

このチェックはオフライン検証よりも厳格です。セッションが無効化された、アイドル状態になった、または有効期間を超えた場合、その人物がブロックされた、またはメールアドレスが認証済みでなくなった場合、セッションがお客様のMFAポリシーを満たさなくなった場合（たとえば、その人物が認証アプリを追加した後や組織の管理者になった後）、またはトークンがリフレッシュによって置き換えられた場合にも失敗します。失敗すると、`WWW-Authenticate: Bearer`とともに`401 {"detail": "invalid_token"}`が返されます。

`GET /session/`では、IPアドレスごとに1分あたり30リクエスト、1時間あたり300リクエストまで許可されます。この制限は`DELETE /session/`と共有されます。トークンはリクエストごとにオフラインで検証し、このエンドポイントは必要な場面に限って呼び出してください。成功した呼び出しはそれぞれアクティビティとして扱われるため、セッションの`idle_timeout`がリセットされます。

認証済みのメールアドレスは本人確認ではありません。本人確認については`verified`を参照してください。ここでは本人確認がない場合は`null`になりますが、トークンでは`{}`になります。

## リフレッシュトークン

```bash theme={null}
curl --fail-with-body "${DIDIT_AUTH_ISSUER}/token/" \
  -H 'Content-Type: application/json' \
  -d '{"grant_type": "refresh_token", "refresh_token": "YOUR_REFRESH_TOKEN"}'
```

リフレッシュを行うと新しいアクセストークン、IDトークン、リフレッシュトークンが返され、送信したリフレッシュトークンは使用できなくなります。

* リフレッシュトークンはランダムな文字列で、セッションが終了するまで有効です。
* **再利用するとセッションが終了します。**使用済みのリフレッシュトークンが再度届くと、Diditはそれを盗難の兆候とみなし、セッションを失効させて`400 {"error": "invalid_grant"}`を返します。そのセッションのトークンも、以前のリフレッシュで発行されたものを含めて使用できなくなります。それらのリフレッシュトークンによるリフレッシュは失敗し、アクセストークンを使用した`GET /session/`は直ちに失敗します。ただし、有効期限が切れるまで（5分以内）はオフラインでは引き続き検証に成功します。
* タブ、デバイス、サーバーインスタンスをまたいで、1つのセッションにつきリフレッシュは一度に1つだけ実行し、3つのトークンはまとめて置き換えてください。
* リフレッシュが`400`で失敗した場合や応答がない場合は、サインインをやり直してください。やみくもに再試行しないでください。実際には成功していたリフレッシュの後に再試行すると、再利用とみなされます。
* `503 {"error": "temporarily_unavailable"}`は、リフレッシュが実行されておらず、お客様のリフレッシュトークンが引き続き使用できることを意味します。バックオフを入れて再試行してください。
* Diditはリフレッシュの実行前に`429`を返すため、`Retry-After`の後に再試行してください。トークンエンドポイントでは、IPアドレスごとに1分あたり30リクエスト、1時間あたり300リクエストまで許可されます。さらに、お客様のアプリケーションのすべての`POST`と`DELETE`はボット対策のバケットを共有します。上限は、これらのエンドポイント全体でIPアドレスごとに1分あたり60リクエスト、デバイスごとに1分あたり30リクエスト、アプリケーション全体で1分あたり1,000リクエストで、ブラウザとサインインステップも含まれます。
* アクセストークンの有効期間が5分の場合、1時間あたり300回のリフレッシュで、アクティブな状態が続く約25人に対応できます。サーバーが1つのアドレスから多数の人物のためにリフレッシュする場合は、リクエストで新しいトークンが必要なときにのみリフレッシュし、可能な場合はブラウザやアプリでリフレッシュしてください。

## サインアウト

ユーザーの現在のアクセストークンを付けて`DELETE /session/`を呼び出すと、セッションが終了し`204`が返ります。そのセッションのリフレッシュトークンは使えなくなり、そのすべてのアクセストークンは直ちに`GET /session/`で拒否されます。ただし、オフラインで検証するトークンは有効期限が切れるまで有効なままです。その後、`post_logout_redirect_uri`を設定している場合は、ユーザーをそのURLに遷移させます。

サインアウトには、`GET /session/`が受け付けるアクセストークンが必要です。つまり、有効期限が切れておらず、リフレッシュによって置き換えられていないトークンです。`401`が返った場合、セッションは終了していません。リフレッシュしてから、新しいアクセストークンでサインアウトしてください。リフレッシュが`400`で失敗した場合、そのセッションはもう使用できません。

セッションは次の場合にも終了します。

* `session_lifetime`(デフォルトは30日)または`idle_timeout`(デフォルトは1日)を経過した場合
* 複数セッションが無効な状態で、ユーザーが再度サインインした場合
* `revoke_on_password_change`が有効な状態で、ユーザーがパスワードをリセットした場合
* チームがコンソールでセッションを取り消すか、ユーザーをブロックした場合
* 使用済みのリフレッシュトークンが再送信された場合

## 有効期間

| 項目 | 有効期間 |
| - | - |
| サインイントランザクション(`POST /authorize/`) | 10分 |
| 認可コード | 60秒、1回限り |
| アクセストークン | 最大5分 |
| IDトークン | 同時に発行されたアクセストークンと同じ`exp` |
| リフレッシュトークン | セッション終了まで、1回限り |
| セッション | `session_lifetime`と`idle_timeout` |
| ステップアップ | 30分 |
| ディスカバリーとJWKSのキャッシュ | 5分 |

## 用途に応じた認証情報の保管

* 認証用のAPIキーとコンソールトークンはサーバー上に保管してください。
* ブラウザーのアクセストークンは、可能な限りメモリ上に保持してください。サーバーサイドでレンダリングされるアプリでは、Secure属性とHttpOnly属性を付けたCookieを使用し、Cookieで認証するリクエストをCSRFから保護してください。
* モバイルのリフレッシュトークンは、プラットフォームが保護するストレージに保存してください。サインアウト後はローカルの状態を消去してください。
* トークンをURLやログに含めてはいけません。

続きは[JavaScriptクイックスタート](https://docs.didit.me/ja/authentication/quickstarts/javascript.md)、[Next.jsクイックスタート](https://docs.didit.me/ja/authentication/quickstarts/nextjs.md)、または[Authentication APIリファレンス](https://docs.didit.me/ja/authentication/api-reference/get-openid-configuration.md)をご覧ください。


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.