Python の最小実行可能サンプル デベロッパ

最終更新:2026年8月15日

Python の最小実行可能サンプル

このページでは、Authorization Code + PKCE の最小実行可能サンプルを 2 つ提供します。

  • パブリッククライアント(public):Python はローカルページを提供するだけです。ブラウザがディスカバリ、トークン交換、UserInfo リクエストを完了し、client_secret は使いません。
  • 機密クライアント(confidential):Python サーバーが client_secret を保持し、ディスカバリ、トークン交換、JWKS、ID Token の検証、UserInfo リクエストを行います。「取り消しとデータ削除をリクエスト」の署名付きコールバック受信もデモします。

どちらのデモも、デフォルトですべてのプロフィールスコープを要求し、明示的に prompt=consent を送ります(デモ用。本番ではこのパラメーターを省略しても、Swaymoon アカウントは毎回認可確認画面を表示します)。ユーザーは同意画面でオプションのスコープを拒否できます。プログラムはトークンで最終的に許可された scope と UserInfo を表示し、認可されていないフィールドはデフォルトのプレースホルダーのままです。

データ削除コールバックは HMAC キーに依存します。機密クライアントは作成時に発行された 平文の client_secret を使います(ライブラリ内の bcrypt ハッシュではありません)。パブリッククライアントには client_secret がなく、削除コールバックを登録すると別途 webhookSecret が発行されます。このページの public デモではそのコールバックを扱いません。連携は機密クライアントのサンプルで行ってください。

パブリッククライアント(public)

これはパブリッククライアント向けの Authorization Code + PKCE 最小サンプルです。Python はローカルページとコールバック URL を提供するだけです。OIDC ディスカバリ、ブラウザ認可、トークン交換、UserInfo リクエストはすべてブラウザが行います。

そのため、スクリプトを実行する CMD、PowerShell、または python.exe公衆ネットワークにアクセスする必要がありません。ブラウザが Swaymoon アカウントに到達できれば、サンプルはサインインを完了できます。サーバーはすでに /.well-known/**/oauth2/token/userinfo に対して資格情報なしの CORS を開いており、PKCE、完全一致の Redirect URI、アクセストークンで安全性を担保します。

機密クライアントはこの方式を使えません。client_secret をブラウザに入れてはいけません。機密クライアントは「生成 AI で機密クライアントに接続する」を読み、公衆ネットワークに実際に到達できるバックエンドでトークンを交換してください。

このデモは認可確認画面を見せるために、明示的に prompt=consent を送ります。本番ではそのパラメーターを送らなくても、サーバーは毎回認可画面でサインインの確認を求めます。スコープの増減がある場合は変更対照も表示します。すでに稼働中の旧クライアントは、このためだけにコードを変える必要はありません。新しいクライアントは最新の「スコープと同意」に従ってください。

1. クライアントを登録する

デベロッパポータルでパブリッククライアントを作成し、次を登録します。

http://127.0.0.1:8765/callback

ポータルでは必須の openid を残し、emailnamenicknamepicturebiographygenderbirthdateregionpreferred_username をすべてオプションスコープとして登録します。デモはこれらのスコープを要求し、最終的に許可された scope と UserInfo を表示します。ユーザーは同意画面で項目ごとに拒否できます。redirect_uri は 1 文字も違ってはいけません。127.0.0.1localhost[::1] は別の登録値です。

2. 実行する

Python 3.10 以降が必要です。サードパーティパッケージは不要です。次の節のコードを swaymoon_oidc_mvp.py として保存します。

Windows PowerShell

$env:SWAYMOON_CLIENT_ID = "swm_あなたのクライアントID"
py .\swaymoon_oidc_mvp.py

Windows コマンドプロンプト(CMD)

set "SWAYMOON_CLIENT_ID=swm_あなたのクライアントID"
py swaymoon_oidc_mvp.py

macOS / Linux

export SWAYMOON_CLIENT_ID='swm_あなたのクライアントID'
python3 swaymoon_oidc_mvp.py

スクリプトは http://127.0.0.1:8765/ を表示し、開こうとします。自動で開かない場合は、ブラウザでそのアドレスを開いてください。

3. 完全なコード

#!/usr/bin/env python3
"""Swaymoon アカウント: ブラウザが公衆ネットワークへのリクエストを行うパブリッククライアント PKCE MVP。"""

from __future__ import annotations

import http.server
import html
import ipaddress
import json
import os
import socket
import traceback
import urllib.parse
import webbrowser


ISSUER = os.environ.get(
    "SWAYMOON_ISSUER", "https://api-passport.swaymoon.com"
).strip().rstrip("/")
CLIENT_ID = os.environ.get("SWAYMOON_CLIENT_ID", "").strip()
REDIRECT_URI = os.environ.get(
    "SWAYMOON_REDIRECT_URI", "http://127.0.0.1:8765/callback"
).strip()
SCOPE = os.environ.get(
    "SWAYMOON_SCOPE",
    "openid email name nickname picture biography gender birthdate region preferred_username",
).strip()


def parse_callback() -> tuple[str, int, str, str]:
    parsed = urllib.parse.urlsplit(REDIRECT_URI)
    if parsed.scheme != "http" or not parsed.hostname or parsed.port is None:
        raise SystemExit(
            "SWAYMOON_REDIRECT_URI はポート付きのローカル HTTP アドレスである必要があります。"
            "例: http://127.0.0.1:8765/callback"
        )
    if parsed.query or parsed.fragment:
        raise SystemExit("このサンプルの SWAYMOON_REDIRECT_URI に query や fragment を含めてはいけません")
    try:
        loopback = ipaddress.ip_address(parsed.hostname).is_loopback
    except ValueError:
        loopback = parsed.hostname == "localhost"
    if not loopback:
        raise SystemExit("このサンプルは 127.0.0.0/8、localhost、または ::1 のみを待ち受けます")
    path = parsed.path or "/callback"
    origin = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
    return parsed.hostname, parsed.port, path, origin


CALLBACK_HOST, CALLBACK_PORT, CALLBACK_PATH, LOCAL_ORIGIN = parse_callback()


HTML = r"""<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Swaymoon アカウント · PKCE MVP</title>
  <style>
    body { font: 16px/1.55 system-ui, sans-serif; max-width: 760px; margin: 48px auto; padding: 0 20px; }
    button { font: inherit; padding: 10px 18px; cursor: pointer; }
    pre { padding: 16px; overflow: auto; background: #f5f5f7; border-radius: 10px; white-space: pre-wrap; }
    code { word-break: break-word; }
    .muted { color: #666; }
  </style>
</head>
<body>
  <h1>Swaymoon アカウント · PKCE MVP</h1>
  <p class="muted">公衆ネットワークへの OIDC リクエストはブラウザが送ります。Python はこのローカルページを提供するだけです。</p>
  <div id="home" hidden>
    <p>クライアント:<code id="client"></code></p>
    <p>スコープ:<code id="scope"></code></p>
    <button id="login" type="button">Swaymoon アカウントでサインイン</button>
  </div>
  <div id="callback" hidden>
    <p id="status">コールバックを検証し、トークンを交換しています……</p>
  </div>
  <pre id="output" hidden></pre>

  <script>
  "use strict";
  const CONFIG = __CONFIG__;
  const PAGE = __PAGE__;
  const STORAGE_KEY = "swaymoon-pkce-mvp";
  const output = document.getElementById("output");

  function showError(error) {
    output.hidden = false;
    output.textContent = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
    const status = document.getElementById("status");
    if (status) status.textContent = "サインインは完了しませんでした";
    console.error(error);
  }

  function base64url(bytes) {
    let binary = "";
    for (const byte of bytes) binary += String.fromCharCode(byte);
    return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
  }

  function randomValue(length = 32) {
    return base64url(crypto.getRandomValues(new Uint8Array(length)));
  }

  async function fetchJson(url, options = {}) {
    const response = await fetch(url, {
      credentials: "omit",
      cache: "no-store",
      ...options,
    });
    const text = await response.text();
    let value;
    try {
      value = JSON.parse(text);
    } catch {
      throw new Error(`${url} が JSON を返しませんでした(HTTP ${response.status}):${text.slice(0, 300)}`);
    }
    if (!response.ok) {
      throw new Error(`${url} が HTTP ${response.status} を返しました:${JSON.stringify(value)}`);
    }
    return value;
  }

  async function discover() {
    const metadata = await fetchJson(`${CONFIG.issuer}/.well-known/openid-configuration`);
    if (String(metadata.issuer || "").replace(/\/$/, "") !== CONFIG.issuer) {
      throw new Error(`ディスカバリドキュメントの issuer が一致しません:${metadata.issuer}`);
    }
    for (const name of ["authorization_endpoint", "token_endpoint", "userinfo_endpoint"]) {
      if (typeof metadata[name] !== "string") throw new Error(`ディスカバリドキュメントに ${name} がありません`);
    }
    return metadata;
  }

  async function startLogin() {
    const button = document.getElementById("login");
    button.disabled = true;
    button.textContent = "サインインを準備しています……";
    try {
      const metadata = await discover();
      const verifier = randomValue(64);
      const digest = await crypto.subtle.digest(
        "SHA-256", new TextEncoder().encode(verifier)
      );
      const state = randomValue(32);
      sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
        state,
        verifier,
        createdAt: Date.now(),
      }));

      const authorize = new URL(metadata.authorization_endpoint);
      authorize.search = new URLSearchParams({
        response_type: "code",
        client_id: CONFIG.clientId,
        redirect_uri: CONFIG.redirectUri,
        scope: CONFIG.scope,
        prompt: "consent",
        state,
        code_challenge: base64url(new Uint8Array(digest)),
        code_challenge_method: "S256",
      });
      location.assign(authorize);
    } catch (error) {
      button.disabled = false;
      button.textContent = "Swaymoon アカウントでサインイン";
      showError(error);
    }
  }

  async function finishLogin() {
    try {
      const query = new URLSearchParams(location.search);
      const savedText = sessionStorage.getItem(STORAGE_KEY);
      if (!savedText) throw new Error("ローカルの PKCE 状態がありません。ホームからやり直してください");
      const saved = JSON.parse(savedText);
      if (Date.now() - Number(saved.createdAt) > 10 * 60 * 1000) {
        throw new Error("PKCE 状態の有効期限が切れました。ホームからやり直してください");
      }
      if (!query.get("state") || query.get("state") !== saved.state) {
        throw new Error("state の検証に失敗しました。ホームからやり直してください");
      }
      if (query.get("error")) {
        throw new Error(`${query.get("error")}: ${query.get("error_description") || "認可は完了しませんでした"}`);
      }
      const code = query.get("code");
      if (!code) throw new Error("コールバックに code がありません");

      const metadata = await discover();
      const tokens = await fetchJson(metadata.token_endpoint, {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({
          grant_type: "authorization_code",
          code,
          redirect_uri: CONFIG.redirectUri,
          client_id: CONFIG.clientId,
          code_verifier: saved.verifier,
        }),
      });
      sessionStorage.removeItem(STORAGE_KEY);
      if (typeof tokens.access_token !== "string" || !tokens.access_token) {
        throw new Error("トークン応答に access_token がありません");
      }
      const userinfo = await fetchJson(metadata.userinfo_endpoint, {
        headers: { Authorization: `Bearer ${tokens.access_token}` },
      });
      document.getElementById("status").textContent = "サインインに成功しました";
      output.hidden = false;
      output.textContent = JSON.stringify({
        token: {
          token_type: tokens.token_type,
          expires_in: tokens.expires_in,
          scope: tokens.scope,
          has_id_token: typeof tokens.id_token === "string",
          has_refresh_token: typeof tokens.refresh_token === "string",
        },
        userinfo,
      }, null, 2);
    } catch (error) {
      showError(error);
    }
  }

  if (PAGE === "callback") {
    document.getElementById("callback").hidden = false;
    finishLogin();
  } else {
    document.getElementById("home").hidden = false;
    document.getElementById("client").textContent = CONFIG.clientId;
    document.getElementById("scope").textContent = CONFIG.scope;
    document.getElementById("login").addEventListener("click", startLogin);
  }
  </script>
</body>
</html>
"""


def javascript_json(value: object) -> str:
    return json.dumps(value, ensure_ascii=False, separators=(",", ":")).replace("<", "\\u003c")


def render(page: str) -> str:
    config = {
        "issuer": ISSUER,
        "clientId": CLIENT_ID,
        "redirectUri": REDIRECT_URI,
        "scope": SCOPE,
    }
    return HTML.replace("__CONFIG__", javascript_json(config)).replace(
        "__PAGE__", javascript_json(page)
    )


class Handler(http.server.BaseHTTPRequestHandler):
    def log_message(self, fmt: str, *args: object) -> None:
        print("[http]", fmt % args)

    def send_html(self, status: int, body: str) -> None:
        data = body.encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Cache-Control", "no-store")
        self.send_header("Referrer-Policy", "no-referrer")
        self.end_headers()
        if data:
            self.wfile.write(data)

    def do_GET(self) -> None:
        try:
            path = urllib.parse.urlsplit(self.path).path
            if path == "/favicon.ico":
                self.send_html(204, "")
            elif path == "/":
                self.send_html(200, render("home"))
            elif path == CALLBACK_PATH:
                self.send_html(200, render("callback"))
            else:
                self.send_html(404, "<h1>404</h1><p><a href='/'>ホームに戻る</a></p>")
        except BrokenPipeError:
            pass
        except Exception:
            detail = traceback.format_exc()
            print(detail)
            try:
                self.send_html(500, f"<h1>ローカルサーバーエラー</h1><pre>{html.escape(detail)}</pre>")
            except Exception:
                pass


class ThreadingServer(http.server.ThreadingHTTPServer):
    daemon_threads = True
    allow_reuse_address = True


class IPv6ThreadingServer(ThreadingServer):
    address_family = socket.AF_INET6


def main() -> None:
    if not CLIENT_ID:
        raise SystemExit("先に SWAYMOON_CLIENT_ID を設定してください")
    if os.environ.get("SWAYMOON_CLIENT_SECRET", "").strip():
        raise SystemExit("このページはパブリッククライアント専用です。client_secret をブラウザに渡さないでください")
    if "openid" not in SCOPE.split():
        raise SystemExit("SWAYMOON_SCOPE には openid が必要です")
    issuer = urllib.parse.urlsplit(ISSUER)
    if issuer.scheme not in ("http", "https") or not issuer.hostname:
        raise SystemExit("SWAYMOON_ISSUER は有効な HTTP(S) URL ではありません")

    server_class = IPv6ThreadingServer if ":" in CALLBACK_HOST else ThreadingServer
    try:
        server = server_class((CALLBACK_HOST, CALLBACK_PORT), Handler)
    except OSError as error:
        raise SystemExit(f"{LOCAL_ORIGIN} を待ち受けできません:{error}") from error

    print("クライアント種別:public")
    print("Python の公衆ネットワークアクセス:不要。OIDC リクエストはブラウザが行います")
    print("Issuer:", ISSUER)
    print("Scope:", SCOPE)
    print("ブラウザの入口:", f"{LOCAL_ORIGIN}/")
    print("リダイレクト URI:", REDIRECT_URI)
    try:
        webbrowser.open(f"{LOCAL_ORIGIN}/")
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n停止しました")
    finally:
        server.server_close()


if __name__ == "__main__":
    main()

4. 任意設定

環境変数デフォルト用途
SWAYMOON_ISSUERhttps://api-passport.swaymoon.comブラウザがその OIDC ディスカバリドキュメントを読みます
SWAYMOON_REDIRECT_URIhttp://127.0.0.1:8765/callbackローカルコールバック。ポータルの登録値と完全に一致させる必要があります
SWAYMOON_SCOPE利用可能なすべてのプロフィールスコープスペース区切りのスコープ。先にポータルへ登録してください

Windows PowerShell で openid email だけを試す例:

$env:SWAYMOON_SCOPE = "openid email"
py .\swaymoon_oidc_mvp.py

コールバック自体を IPv6 にする場合は、先にポータルへ http://[::1]:8765/callback を登録し、次を設定します。

$env:SWAYMOON_REDIRECT_URI = "http://[::1]:8765/callback"
py .\swaymoon_oidc_mvp.py

変わるのはローカルコールバックの待ち受けアドレスだけです。公衆ネットワークへのリクエストは、常にブラウザが通常の IPv4/IPv6、プロキシ、セキュリティポリシーに従って行います。

5. よくあるエラー

症状対処
ページがディスカバリドキュメントの Failed to fetch を表示する同じブラウザでディスカバリドキュメントを直接開く。拡張機能、CORS エラー、開発者ツールの Network パネルを確認する
invalid_clientポータルで作成したパブリッククライアントを使う必要があります。このページは client_secret を受け付けません
invalid_grantローカルのホームからやり直してください。認可コードは 1 回しか使えず、Redirect URI は完全に一致する必要があります
invalid_scopeSWAYMOON_SCOPEopenid に戻すか、先にポータルで該当機能を有効にしてください
state の検証に失敗しました古いコールバックページを再読み込みしたり再利用したりしないでください。ローカルのホームからやり直してください
ポートを待ち受けできない8765 を使っている古いプロセスを終了するか、別のポートを登録して使ってください

このコードはプロトコル連携用です。本番の SPA では成熟した OAuth / OIDC ライブラリを使い、信頼できるセッション、エラー処理、トークンのライフサイクル管理を実装してください。Access Token、ID Token、シークレットをログや永続ストレージに書き込まないでください。


機密クライアント(confidential)

これはそのまま実行できる Authorization Code + PKCE 機密クライアントのサンプルです。ブラウザはリダイレクトだけを担当します。OIDC ディスカバリ、認可コードのトークン交換、JWKS、ID Token の検証、UserInfo リクエストはすべて Python サーバーで行い、client_secret とトークンはブラウザに入りません。

機密クライアントの Python プロセスは Issuer に到達できる必要があります。現在のネットワークがブラウザ以外の TLS 接続をリセットする場合は、通常のネットワーク、サーバー、またはコンテナで実行してください。ネットワーク問題を避けるためにトークン交換をブラウザへ移さないでください。

1. クライアントを登録する

デベロッパポータルで機密クライアントを作成し、次を登録します。

フィールド説明
リダイレクト URIhttp://127.0.0.1:8766/callbackブラウザのリダイレクト。ローカル loopback を使えます
データ削除コールバック URLhttps://<トンネルのホスト>/privacy/data-deletionHTTPS のみhttp://127.0.0.1localhost禁止です。ngrok / Cloudflare Tunnel などで本機の 8766 を HTTPS として公開してから登録します

作成時に一度だけ表示される client_secret をすぐに安全に保存してください(削除コールバックの署名検証にも使います)。ポータルでは必須の openid を残し、emailnamenicknamepicturebiographygenderbirthdateregionpreferred_username をすべてオプションスコープとして登録します。デモはすべてのスコープを要求しますが、ユーザーは同意画面で項目ごとに拒否できます。

ログイン Redirect と削除コールバックは別物です。
Redirect はブラウザが訪れるので http://127.0.0.1 を使えます。削除コールバックは Passport サーバーからの送信 POST であり、ポータルはローカルアドレスの登録を許可しません。ローカル連携では、先にデモを起動し、トンネルで https://….ngrok-free.app を得たうえで https://….ngrok-free.app/privacy/data-deletion をポータルに入れます。「取り消しとデータ削除をリクエスト」を押すとき、トンネルとデモの両方が動いている必要があります。下記の curl で本機だけ署名を自己確認することもできます(Passport を経由しません)。メールが届くかどうかと、コールバックが成功するかどうかは独立です。同じディレクトリの「登録と設定」を参照してください。

2. 依存関係をインストールする

コード自体は Python 標準ライブラリで HTTP を扱います。ES256 署名の検証にだけ cryptography を追加で使います。

Windows PowerShell / CMD

py -m pip install cryptography

macOS / Linux

python3 -m pip install cryptography

3. 設定して実行する

次の節のコードを swaymoon_confidential_mvp.py として保存します。

Windows PowerShell

$env:SWAYMOON_CLIENT_ID = "swm_あなたのクライアントID"
$env:SWAYMOON_CLIENT_SECRET = "あなたのクライアントシークレット"
py .\swaymoon_confidential_mvp.py

Windows コマンドプロンプト(CMD)

set "SWAYMOON_CLIENT_ID=swm_あなたのクライアントID"
set "SWAYMOON_CLIENT_SECRET=あなたのクライアントシークレット"
py swaymoon_confidential_mvp.py

macOS / Linux

export SWAYMOON_CLIENT_ID='swm_あなたのクライアントID'
export SWAYMOON_CLIENT_SECRET='あなたのクライアントシークレット'
python3 swaymoon_confidential_mvp.py

http://127.0.0.1:8766/ を開き、サインインボタンを選びます。サービスを止めたら、現在のターミナルからシークレットの環境変数を消してください。本物の値をコード、ドキュメント、Git に書かないでください。

4. 完全なコード

#!/usr/bin/env python3
"""Swaymoon アカウント: サーバーでトークン交換と ES256 ID Token 検証を行う機密クライアント PKCE MVP。"""

from __future__ import annotations

import base64
import hashlib
import hmac
import html
import http.cookies
import http.server
import ipaddress
import json
import os
import secrets
import socket
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from typing import Any

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature


ISSUER = os.environ.get(
    "SWAYMOON_ISSUER", "https://api-passport.swaymoon.com"
).strip().rstrip("/")
CLIENT_ID = os.environ.get("SWAYMOON_CLIENT_ID", "").strip()
CLIENT_SECRET = os.environ.get("SWAYMOON_CLIENT_SECRET", "").strip()
REDIRECT_URI = os.environ.get(
    "SWAYMOON_REDIRECT_URI", "http://127.0.0.1:8766/callback"
).strip()
SCOPE = os.environ.get(
    "SWAYMOON_SCOPE",
    "openid email name nickname picture biography gender birthdate region preferred_username",
).strip()
DATA_DELETION_PATH = os.environ.get(
    "SWAYMOON_DATA_DELETION_PATH", "/privacy/data-deletion"
).strip() or "/privacy/data-deletion"
HTTP_TIMEOUT = 15
SESSION_TTL = 10 * 60

SESSIONS: dict[str, dict[str, Any]] = {}
SESSION_LOCK = threading.Lock()
# ローカルユーザープロフィール: UserInfo / ID Token の sub をキーにする(削除コールバックのデモ)
LOCAL_USERS: dict[str, dict[str, Any]] = {}
LOCAL_USERS_LOCK = threading.Lock()
DELETION_LOG: list[dict[str, Any]] = []
METADATA: dict[str, Any] | None = None
JWKS: dict[str, Any] | None = None


def b64url_encode(value: bytes) -> str:
    return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")


def b64url_decode(value: str) -> bytes:
    return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))


def parse_callback() -> tuple[str, int, str, str]:
    parsed = urllib.parse.urlsplit(REDIRECT_URI)
    if parsed.scheme != "http" or not parsed.hostname or parsed.port is None:
        raise SystemExit(
            "SWAYMOON_REDIRECT_URI はポート付きのローカル HTTP アドレスである必要があります。"
            "例: http://127.0.0.1:8766/callback"
        )
    if parsed.query or parsed.fragment:
        raise SystemExit("このサンプルの SWAYMOON_REDIRECT_URI に query や fragment を含めてはいけません")
    try:
        loopback = ipaddress.ip_address(parsed.hostname).is_loopback
    except ValueError:
        loopback = parsed.hostname == "localhost"
    if not loopback:
        raise SystemExit("このサンプルは 127.0.0.0/8、localhost、または ::1 のみを待ち受けます")
    path = parsed.path or "/callback"
    origin = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
    return parsed.hostname, parsed.port, path, origin


CALLBACK_HOST, CALLBACK_PORT, CALLBACK_PATH, LOCAL_ORIGIN = parse_callback()


def request_json(
    url: str,
    *,
    data: dict[str, str] | None = None,
    headers: dict[str, str] | None = None,
) -> dict[str, Any]:
    body = urllib.parse.urlencode(data).encode("utf-8") if data is not None else None
    request = urllib.request.Request(url, data=body, headers=headers or {})
    try:
        with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT) as response:
            raw = response.read()
    except urllib.error.HTTPError as error:
        detail = error.read(500).decode("utf-8", "replace")
        raise RuntimeError(f"{url} が HTTP {error.code} を返しました:{detail}") from error
    except OSError as error:
        raise RuntimeError(f"{url} に接続できません:{type(error).__name__}: {error}") from error
    try:
        value = json.loads(raw)
    except json.JSONDecodeError as error:
        raise RuntimeError(f"{url} が JSON を返しませんでした") from error
    if not isinstance(value, dict):
        raise RuntimeError(f"{url} の JSON トップレベルがオブジェクトではありません")
    return value


def discover() -> dict[str, Any]:
    global METADATA
    if METADATA is None:
        value = request_json(f"{ISSUER}/.well-known/openid-configuration")
        if value.get("issuer") != ISSUER:
            raise RuntimeError(f"ディスカバリドキュメントの issuer が一致しません:{value.get('issuer')}")
        for name in (
            "authorization_endpoint",
            "token_endpoint",
            "jwks_uri",
            "userinfo_endpoint",
        ):
            if not isinstance(value.get(name), str):
                raise RuntimeError(f"ディスカバリドキュメントに {name} がありません")
        METADATA = value
    return METADATA


def get_jwks() -> dict[str, Any]:
    global JWKS
    if JWKS is None:
        JWKS = request_json(discover()["jwks_uri"])
    return JWKS


def verify_id_token(token: str, expected_nonce: str) -> dict[str, Any]:
    try:
        encoded_header, encoded_payload, encoded_signature = token.split(".")
        header = json.loads(b64url_decode(encoded_header))
        claims = json.loads(b64url_decode(encoded_payload))
        signature = b64url_decode(encoded_signature)
    except (ValueError, UnicodeError, json.JSONDecodeError) as error:
        raise RuntimeError("ID Token の形式が無効です") from error
    if not isinstance(header, dict) or not isinstance(claims, dict):
        raise RuntimeError("ID Token の header/payload 形式が無効です")
    if header.get("alg") != "ES256" or not isinstance(header.get("kid"), str):
        raise RuntimeError("ID Token は kid 付きの ES256 である必要があります")

    keys = get_jwks().get("keys")
    if not isinstance(keys, list):
        raise RuntimeError("JWKS に keys がありません")
    jwk = next(
        (
            key
            for key in keys
            if isinstance(key, dict)
            and key.get("kid") == header["kid"]
            and key.get("kty") == "EC"
            and key.get("crv") == "P-256"
        ),
        None,
    )
    if jwk is None:
        raise RuntimeError("JWKS にこの ID Token 用の P-256 公開鍵がありません")
    try:
        public_key = ec.EllipticCurvePublicNumbers(
            int.from_bytes(b64url_decode(jwk["x"]), "big"),
            int.from_bytes(b64url_decode(jwk["y"]), "big"),
            ec.SECP256R1(),
        ).public_key()
        if len(signature) != 64:
            raise ValueError("ES256 署名の長さが正しくありません")
        der_signature = encode_dss_signature(
            int.from_bytes(signature[:32], "big"),
            int.from_bytes(signature[32:], "big"),
        )
        public_key.verify(
            der_signature,
            f"{encoded_header}.{encoded_payload}".encode("ascii"),
            ec.ECDSA(hashes.SHA256()),
        )
    except (InvalidSignature, KeyError, TypeError, ValueError) as error:
        raise RuntimeError("ID Token の ES256 署名検証に失敗しました") from error

    now = time.time()
    if claims.get("iss") != ISSUER:
        raise RuntimeError("ID Token の iss が一致しません")
    audience = claims.get("aud")
    audiences = [audience] if isinstance(audience, str) else audience
    if not isinstance(audiences, list) or CLIENT_ID not in audiences:
        raise RuntimeError("ID Token の aud に現在の client_id が含まれていません")
    if len(audiences) > 1 and claims.get("azp") != CLIENT_ID:
        raise RuntimeError("ID Token の azp が一致しません")
    if not isinstance(claims.get("exp"), (int, float)) or claims["exp"] < now - 60:
        raise RuntimeError("ID Token の有効期限切れ、または exp がありません")
    if not isinstance(claims.get("iat"), (int, float)) or claims["iat"] > now + 60:
        raise RuntimeError("ID Token の iat が無効です")
    if "nbf" in claims and (
        not isinstance(claims["nbf"], (int, float)) or claims["nbf"] > now + 60
    ):
        raise RuntimeError("ID Token はまだ有効になっていません")
    if not secrets.compare_digest(str(claims.get("nonce", "")), expected_nonce):
        raise RuntimeError("ID Token の nonce 検証に失敗しました")
    if not isinstance(claims.get("sub"), str) or not claims["sub"]:
        raise RuntimeError("ID Token に sub がありません")
    return claims


def basic_authorization() -> str:
    client = urllib.parse.quote_plus(CLIENT_ID, safe="")
    secret = urllib.parse.quote_plus(CLIENT_SECRET, safe="")
    value = base64.b64encode(f"{client}:{secret}".encode("utf-8")).decode("ascii")
    return f"Basic {value}"


def verify_data_deletion_signature(raw_body: bytes, signature_header: str | None) -> bool:
    """X-Swaymoon-Signature: sha256=<hex> を検証します。キーは作成時の平文 client_secret です。"""
    if not signature_header or not CLIENT_SECRET:
        return False
    provided = signature_header.strip()
    if provided.lower().startswith("sha256="):
        provided = provided[7:].strip()
    digest = hmac.new(
        CLIENT_SECRET.encode("utf-8"),
        raw_body,
        hashlib.sha256,
    ).hexdigest()
    return secrets.compare_digest(digest.lower(), provided.lower())


def cookie_session_id(cookie_header: str | None) -> str | None:
    if not cookie_header:
        return None
    cookie = http.cookies.SimpleCookie()
    try:
        cookie.load(cookie_header)
    except http.cookies.CookieError:
        return None
    morsel = cookie.get("swaymoon_mvp_session")
    return morsel.value if morsel else None


def page(title: str, content: str) -> str:
    return f"""<!doctype html>
<html lang="ja"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{html.escape(title)}</title><style>
body{{font:16px/1.55 system-ui,sans-serif;max-width:760px;margin:48px auto;padding:0 20px}}
a.button{{display:inline-block;padding:10px 18px;background:#111;color:#fff;text-decoration:none;border-radius:8px}}
pre{{padding:16px;background:#f5f5f7;border-radius:10px;white-space:pre-wrap;overflow:auto}}
.hint{{color:#6e6e73;font-size:14px}}
</style></head><body><h1>{html.escape(title)}</h1>{content}</body></html>"""


class Handler(http.server.BaseHTTPRequestHandler):
    def log_message(self, fmt: str, *args: object) -> None:
        print("[http]", fmt % args)

    def send_body(
        self,
        status: int,
        body: str,
        *,
        content_type: str = "text/html; charset=utf-8",
        location: str | None = None,
        cookie: str | None = None,
    ) -> None:
        data = body.encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Cache-Control", "no-store")
        self.send_header("Referrer-Policy", "no-referrer")
        if content_type.startswith("text/html"):
            self.send_header(
                "Content-Security-Policy",
                "default-src 'none'; style-src 'unsafe-inline'",
            )
        if location:
            self.send_header("Location", location)
        if cookie:
            self.send_header("Set-Cookie", cookie)
        self.end_headers()
        self.wfile.write(data)

    def redirect(self, location: str, cookie: str | None = None) -> None:
        self.send_body(302, "", location=location, cookie=cookie)

    def show_error(self, error: Exception) -> None:
        print(f"[error] {type(error).__name__}: {error}")
        body = (
            f"<p>{html.escape(type(error).__name__ + ': ' + str(error))}</p>"
            "<p><a href='/'>ホームに戻る</a></p>"
        )
        self.send_body(400, page("サインインは完了しませんでした", body))

    def do_GET(self) -> None:
        try:
            parsed = urllib.parse.urlsplit(self.path)
            if parsed.path == "/favicon.ico":
                self.send_body(204, "")
            elif parsed.path == "/":
                with LOCAL_USERS_LOCK:
                    user_count = len(LOCAL_USERS)
                    log_count = len(DELETION_LOG)
                self.send_body(
                    200,
                    page(
                        "Swaymoon アカウント · 機密クライアント PKCE MVP",
                        f"<p>クライアント:<code>{html.escape(CLIENT_ID)}</code></p>"
                        f"<p>スコープ:<code>{html.escape(SCOPE)}</code></p>"
                        f"<p>ローカルユーザー:{user_count} · 処理済みの削除リクエスト:{log_count}</p>"
                        "<p><a class='button' href='/login'>Swaymoon アカウントでサインイン</a></p>"
                        "<p><a href='/users'>ローカルユーザーを見る</a> · "
                        "<a href='/deletion-log'>削除リクエストログ</a></p>"
                        f"<p class='hint'>データ削除コールバック:<code>POST {html.escape(DATA_DELETION_PATH)}</code></p>",
                    ),
                )
            elif parsed.path == "/login":
                self.start_login()
            elif parsed.path == CALLBACK_PATH:
                self.finish_login(parsed.query)
            elif parsed.path == "/users":
                self.show_local_users()
            elif parsed.path == "/deletion-log":
                self.show_deletion_log()
            else:
                self.send_body(404, page("404", "<p><a href='/'>ホームに戻る</a></p>"))
        except BrokenPipeError:
            pass
        except Exception as error:
            self.show_error(error)

    def do_POST(self) -> None:
        try:
            parsed = urllib.parse.urlsplit(self.path)
            if parsed.path == DATA_DELETION_PATH:
                self.handle_data_deletion()
            else:
                self.send_body(
                    404,
                    json.dumps({"status": "not-found"}, ensure_ascii=False),
                    content_type="application/json; charset=utf-8",
                )
        except BrokenPipeError:
            pass
        except Exception as error:
            print(f"[deletion-error] {type(error).__name__}: {error}")
            self.send_body(
                500,
                json.dumps({"status": "error"}, ensure_ascii=False),
                content_type="application/json; charset=utf-8",
            )

    def show_local_users(self) -> None:
        with LOCAL_USERS_LOCK:
            snapshot = json.dumps(LOCAL_USERS, ensure_ascii=False, indent=2)
        self.send_body(
            200,
            page(
                "ローカルユーザー",
                "<p>サインイン成功後にプロセスメモリへ保存したプロフィールです。削除コールバックを受けると対応する <code>sub</code> を削除します。</p>"
                f"<pre>{html.escape(snapshot)}</pre><p><a href='/'>ホームに戻る</a></p>",
            ),
        )

    def show_deletion_log(self) -> None:
        with LOCAL_USERS_LOCK:
            snapshot = json.dumps(DELETION_LOG, ensure_ascii=False, indent=2)
        self.send_body(
            200,
            page(
                "削除リクエストログ",
                "<p>Swaymoon アカウントがデータ削除コールバックを <code>POST</code> したあとに書き込みます。</p>"
                f"<pre>{html.escape(snapshot)}</pre><p><a href='/'>ホームに戻る</a></p>",
            ),
        )

    def handle_data_deletion(self) -> None:
        length = int(self.headers.get("Content-Length", "0") or "0")
        raw = self.rfile.read(length) if length > 0 else b""
        signature = self.headers.get("X-Swaymoon-Signature")
        if not verify_data_deletion_signature(raw, signature):
            self.send_body(
                401,
                json.dumps({"status": "invalid-signature"}, ensure_ascii=False),
                content_type="application/json; charset=utf-8",
            )
            return
        try:
            payload = json.loads(raw.decode("utf-8"))
        except (UnicodeDecodeError, json.JSONDecodeError) as error:
            raise RuntimeError("リクエスト本体が有効な JSON ではありません") from error
        if payload.get("type") != "swaymoon.passport.data_deletion_request":
            self.send_body(
                400,
                json.dumps({"status": "unsupported-type"}, ensure_ascii=False),
                content_type="application/json; charset=utf-8",
            )
            return
        subject = payload.get("subject")
        removed = False
        if isinstance(subject, str) and subject:
            with LOCAL_USERS_LOCK:
                removed = LOCAL_USERS.pop(subject, None) is not None
                DELETION_LOG.append(
                    {
                        "request_id": payload.get("request_id"),
                        "client_id": payload.get("client_id"),
                        "subject": subject,
                        "relay_email": payload.get("relay_email"),
                        "removed_local_user": removed,
                        "received_at": int(time.time()),
                    }
                )
        print(
            f"[deletion] request_id={payload.get('request_id')} "
            f"subject={subject} removed={removed}"
        )
        self.send_body(
            200,
            json.dumps(
                {"status": "ok", "removed": removed},
                ensure_ascii=False,
            ),
            content_type="application/json; charset=utf-8",
        )

    def start_login(self) -> None:
        metadata = discover()
        session_id = secrets.token_urlsafe(32)
        state = secrets.token_urlsafe(32)
        nonce = secrets.token_urlsafe(32)
        verifier = secrets.token_urlsafe(64)
        challenge = b64url_encode(hashlib.sha256(verifier.encode("ascii")).digest())
        with SESSION_LOCK:
            now = time.time()
            for key, value in list(SESSIONS.items()):
                if now - float(value.get("created_at", 0)) > SESSION_TTL:
                    SESSIONS.pop(key, None)
            SESSIONS[session_id] = {
                "state": state,
                "nonce": nonce,
                "verifier": verifier,
                "created_at": now,
            }
        authorize = metadata["authorization_endpoint"] + "?" + urllib.parse.urlencode(
            {
                "response_type": "code",
                "client_id": CLIENT_ID,
                "redirect_uri": REDIRECT_URI,
                "scope": SCOPE,
                "prompt": "consent",
                "state": state,
                "nonce": nonce,
                "code_challenge": challenge,
                "code_challenge_method": "S256",
            }
        )
        cookie = (
            f"swaymoon_mvp_session={session_id}; Path=/; HttpOnly; SameSite=Lax; "
            f"Max-Age={SESSION_TTL}"
        )
        self.redirect(authorize, cookie)

    def finish_login(self, raw_query: str) -> None:
        query = urllib.parse.parse_qs(raw_query)
        session_id = cookie_session_id(self.headers.get("Cookie"))
        with SESSION_LOCK:
            saved = SESSIONS.pop(session_id, None) if session_id else None
        if not saved or time.time() - saved["created_at"] > SESSION_TTL:
            raise RuntimeError("サインイン状態がないか期限切れです。ホームからやり直してください")
        received_state = query.get("state", [""])[0]
        if not received_state or not secrets.compare_digest(received_state, saved["state"]):
            raise RuntimeError("state の検証に失敗しました")
        if query.get("error"):
            description = query.get("error_description", ["認可は完了しませんでした"])[0]
            raise RuntimeError(f"{query['error'][0]}: {description}")
        code = query.get("code", [""])[0]
        if not code:
            raise RuntimeError("コールバックに code がありません")

        metadata = discover()
        tokens = request_json(
            metadata["token_endpoint"],
            data={
                "grant_type": "authorization_code",
                "code": code,
                "redirect_uri": REDIRECT_URI,
                "code_verifier": saved["verifier"],
            },
            headers={
                "Authorization": basic_authorization(),
                "Content-Type": "application/x-www-form-urlencoded",
            },
        )
        id_token = tokens.get("id_token")
        access_token = tokens.get("access_token")
        if not isinstance(id_token, str) or not isinstance(access_token, str):
            raise RuntimeError("トークン応答に id_token または access_token がありません")
        claims = verify_id_token(id_token, saved["nonce"])
        userinfo = request_json(
            metadata["userinfo_endpoint"],
            headers={"Authorization": f"Bearer {access_token}"},
        )
        if userinfo.get("sub") != claims["sub"]:
            raise RuntimeError("UserInfo の sub と ID Token の sub が一致しません")

        subject = str(claims["sub"])
        with LOCAL_USERS_LOCK:
            LOCAL_USERS[subject] = {
                "sub": subject,
                "email": userinfo.get("email"),
                "name": userinfo.get("name"),
                "preferred_username": userinfo.get("preferred_username"),
                "saved_at": int(time.time()),
            }

        summary = {
            "token": {
                "token_type": tokens.get("token_type"),
                "expires_in": tokens.get("expires_in"),
                "scope": tokens.get("scope"),
                "id_token_verified": True,
                "has_refresh_token": isinstance(tokens.get("refresh_token"), str),
            },
            "id_token": {
                "iss": claims.get("iss"),
                "aud": claims.get("aud"),
                "sub": claims.get("sub"),
                "exp": claims.get("exp"),
            },
            "userinfo": userinfo,
            "local_store": "sub をキーにプロセスメモリへ書き込みました。削除コールバックのデモに使えます",
        }
        body = (
            "<p>サインインに成功しました。トークンはサーバーメモリ内でのみ扱います。</p>"
            "<p>Swaymoon アカウントの「プライバシー → アプリの許可 → 取り消しとデータ削除をリクエスト」へ進み、"
            "本機の <code>/deletion-log</code> とローカルユーザーが消えるかを確認できます。</p>"
            "<pre>"
            + html.escape(json.dumps(summary, ensure_ascii=False, indent=2))
            + "</pre><p><a href='/'>ホームに戻る</a></p>"
        )
        self.send_body(
            200,
            page("サインインに成功しました", body),
            cookie="swaymoon_mvp_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
        )


class ThreadingServer(http.server.ThreadingHTTPServer):
    daemon_threads = True
    allow_reuse_address = True


class IPv6ThreadingServer(ThreadingServer):
    address_family = socket.AF_INET6


def main() -> None:
    if not CLIENT_ID or not CLIENT_SECRET:
        raise SystemExit("先に SWAYMOON_CLIENT_ID と SWAYMOON_CLIENT_SECRET を設定してください")
    if "openid" not in SCOPE.split():
        raise SystemExit("SWAYMOON_SCOPE には openid が必要です")
    issuer = urllib.parse.urlsplit(ISSUER)
    if issuer.scheme != "https" or not issuer.hostname:
        raise SystemExit("SWAYMOON_ISSUER は有効な HTTPS URL である必要があります")
    server_class = IPv6ThreadingServer if ":" in CALLBACK_HOST else ThreadingServer
    try:
        server = server_class((CALLBACK_HOST, CALLBACK_PORT), Handler)
    except OSError as error:
        raise SystemExit(f"{LOCAL_ORIGIN} を待ち受けできません:{error}") from error
    print("クライアント種別:confidential(client_secret_basic + PKCE S256)")
    print("ブラウザの入口:", f"{LOCAL_ORIGIN}/")
    print("リダイレクト URI:", REDIRECT_URI)
    print("ローカル削除受信:", f"{LOCAL_ORIGIN}{DATA_DELETION_PATH}", "(ポータルにはトンネル HTTPS を登録。127.0.0.1 は使わない)")
    print("注意:Python サーバーは次の Issuer に到達できる必要があります", ISSUER)
    try:
        webbrowser.open(f"{LOCAL_ORIGIN}/")
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n停止しました")
    finally:
        server.server_close()


if __name__ == "__main__":
    main()

5. データ削除コールバックのデモ

ポータルはデータ削除コールバックを http://127.0.0.1 として登録することを許可しません。エンドツーエンド連携の手順:

  1. このデモを起動します(8766 を待ち受け)。
  2. HTTPS トンネルで本機の 8766 を公開します(例:cloudflared tunnel --url http://127.0.0.1:8766 または ngrok)。https://xxxx.example のような公衆 URL を得ます。
  3. デベロッパポータルで、そのアプリの「データ削除コールバック URL」を https://xxxx.example/privacy/data-deletion にします(path は SWAYMOON_DATA_DELETION_PATH と一致させます)。
  4. サインインを完了し、http://127.0.0.1:8766/users を開いて、ローカルにそのユーザーの sub があることを確認します。
  5. passport.swaymoon.com にサインインし、プライバシー → アプリの許可を開き、このデモアプリの詳細へ進み、取り消しとデータ削除をリクエストを選びます(step-up が必要です)。
  6. Swaymoon アカウントは認可を取り消し、メールを送り、トンネル URL へ署名付き JSON を POST します。デモの /deletion-log に記録があり、/users から対応する sub が消えるはずです。

Passport を経由せず署名だけ自己確認する場合でも、下記の curl を http://127.0.0.1:8766 に対して使えます。

コールバックリクエストの契約(「スコープと同意」と同じ):

項目
メソッド / パスPOST /privacy/data-deletion(環境変数でパスを変更できます)
リクエストヘッダーContent-Type: application/jsonX-Swaymoon-Signature: sha256=<hmac_hex>X-Swaymoon-Request-Id
署名生のリクエスト本体バイトに対する HMAC-SHA256。キーは作成時に保存した 平文の client_secret(bcrypt / {bcrypt} の保存文字列は使わない)
本体フィールドtype(常に swaymoon.passport.data_deletion_request)、issued_atclient_idsubjectrelay_emailrequest_id

ローカルスクリプトで署名を自己確認できます(Passport を経由する必要はありません):

# macOS / Linux の例: SECRET は作成時に発行された平文 client_secret
BODY='{"type":"swaymoon.passport.data_deletion_request","issued_at":"2026-01-01T00:00:00Z","client_id":"swm_demo","subject":"実際のsubに置き換える","relay_email":null,"request_id":"req1"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SWAYMOON_CLIENT_SECRET" | awk '{print $2}')
curl -sS -X POST "http://127.0.0.1:8766/privacy/data-deletion" \
  -H "Content-Type: application/json" \
  -H "X-Swaymoon-Signature: sha256=$SIG" \
  -H "X-Swaymoon-Request-Id: req1" \
  --data "$BODY"

6. 任意設定

環境変数デフォルト用途
SWAYMOON_ISSUERhttps://api-passport.swaymoon.comPython サーバーがディスカバリドキュメントを読み、トークンを交換し、UserInfo をリクエストします
SWAYMOON_REDIRECT_URIhttp://127.0.0.1:8766/callbackローカルコールバック。ポータルの登録値と完全に一致させる必要があります
SWAYMOON_SCOPE利用可能なすべてのプロフィールスコープスペース区切りのスコープ。openid を含め、先にポータルへ登録する必要があります
SWAYMOON_DATA_DELETION_PATH/privacy/data-deletion削除コールバックをローカルで受けるパス。ポータルの「データ削除コールバック URL」の path と一致させる必要があります

このデモは明示的に prompt=consent を送ります。本番アプリではそのパラメーターを省略できます。Swaymoon アカウントは毎回認可確認画面を表示し、スコープ変更時は増減対照を示します。ユーザーが認可を取り消したあとは再認可が必要です。

7. セキュリティ境界

  • ブラウザが受け取るのはアプリ自身のページだけです。client_secret、Access Token、ID Token、Refresh Token は受け取りません。
  • サンプルは ES256 署名、issaudazpexpiatnbfnonce を検証し、UserInfo の sub を照合します。
  • データ削除コールバックは X-Swaymoon-Signature を検証します(キーは作成時の平文 client_secret、対象は raw body)。本番ではさらに client_id の確認、送信元 IP / mTLS の制限、冪等処理も行ってください。
  • statenonce、PKCE verifier、トークンはプロセスメモリにだけ保存されます。再起動後、サインインフローは無効になります。
  • ローカル HTTP Cookie は Secure を設定していないため、loopback 連携専用です。本番では HTTPS、Secure Cookie、永続セッションストレージ、成熟した OIDC クライアントライブラリが必要です。
  • 完全なコールバック URL、認可コード、シークレット、トークンを記録しないでください。このサンプルの成功ページは、絞り込んだクレームと状態だけを表示します。

8. よくあるエラー

症状対処
接続できません ... / TLS reset機密クライアントのサーバーは Issuer に直接到達できる必要があります。通常のネットワークに切り替えるかサーバーへデプロイし、シークレットをブラウザに渡さないでください
invalid_client機密クライアントを作成したか、client_id / client_secret が正しいかを確認し、変更後に再起動してください
invalid_grantホームからやり直してください。認可コードは 1 回しか使えず、Redirect URI と PKCE verifier は一致する必要があります
invalid_scopeopenid に戻すか、先にポータルへ必要なスコープを登録してください
ID Token の検証に失敗した検証をスキップしないでください。システム時刻、Issuer、クライアント ID を確認してから、もう一度サインインしてください
認可確認画面が出ない同じ scope をカバーする同意記録がすでにあります。アプリ認可を取り消すか、新しいクライアントでやり直してください
削除コールバック invalid-signatureクライアント作成時に保存した同じ client_secret を使ってください。署名は生の body に対して計算する必要があります
「データ削除をリクエスト」を押すとメールは来るが、デモにログがないポータルにトンネル HTTPS コールバックを登録する必要があります(127.0.0.1 は禁止)。削除を押すとき、トンネルとデモの両方が動いている必要があります。先に下記の curl で署名を自己確認できます