コンテンツにスキップ

認証の設計

auth.mode には 4 つの値があります。うち 3 つは人がブラウザでログインする話で、 しかも OIDC、パスキー、その併用という単純なログイン方式の選択ではありません。 たとえば oidc_passkey では、普段のログインにはパスキーを使い、OIDC プロバイダが 登場するのは復旧時だけです。

その 3 つを選ぶときは、普段のログイン画面だけでなく、アカウントを誰が作り、誰が 復旧できるのかを基準にします。4 つめの jwt_only は、そもそもこの軸の上にありません。 誰もログインしない API のためのモードです。

パスキーはアカウントを作れません。作れない理由は単純で、最初のクレデンシャルを結びつける先が無いからです。公開鍵を受け取っても、それが誰のものかを言えるものが他に無ければ、サービス側にできることはありません。

つまり、どのモードも「誰かがログインする前に、そのアカウントを何が存在させたか」という問いに答える必要があります。4つのモードはその答えです。

auth.mode アカウントの出どころ 日常のログイン 復旧の権威
oidc_only プロバイダ プロバイダ プロバイダ
oidc_passkey プロバイダ パスキー プロバイダ
passkey_only 管理者が発行するログイン ID と使い捨てシークレット パスキー 管理者、または別のパスキー
jwt_only トークンを発行する authorization server 無し。リクエストごとの Bearer トークン このアプリケーションのものではないし、尋ねられる立場にもない

右端の列が実運用でいちばん重い列で、最終行はそれが効かなくなる場所です。

プロバイダに寄せきる構成です。アカウントの作成も、日常のログインも、パスワードを忘れた人の救済も、全部あちらで起きます。こちらが持つのはセッションだけ。

得られるもの。 保管すべきクレデンシャルがありません。漏洩する対象が無いので、漏洩対策も要りません。退職や解約でプロバイダ側のアカウントが止まれば、こちらのログインも自動的に止まります。B2B ではこれが決定的な価値になります。

引き受けるもの。 プロバイダが落ちればログインも落ちます。プロバイダのポリシーがそのまま自社のポリシーになります。そして、そのプロバイダにアカウントを持たない人は、どうやっても入れません。

初回だけプロバイダを通り、以後はパスキーで入ります。プロバイダは日常の導線から外れて、復旧手段として後ろに控えます。

得られるもの。 ログインが速くなります。プロバイダへの往復が消えるので、ネットワークもプロバイダの可用性も日常の経路から外れます。それでいて、パスキーを失った人には oidc_only と同じ救済路が残っている。

引き受けるもの。 認証方法が2つある状態を、設計として意識し続ける必要があります。どちらが強いのか、片方を消せるのは誰なのか、両方失った人はどうなるのか。auth.recovery.policy を明示的に決めるまで起動しないのは、この問いを黙って先送りさせないためです。

プロバイダが存在しません。アカウントは管理者が作り、ログイン ID と使い捨てシークレットを本人に渡します。本人はそれを引き換えて、自分のパスキーを登録します。

得られるもの。 外部依存がゼロになります。共有秘密も保管しません。閉じたネットワークや、IdP を持たない組織でも成立します。

引き受けるもの。 復旧が全部こちらの仕事になります。「メールアドレスを知っていること」は復旧の根拠になりません(それは誰でも知りうるので、既定で禁止しています)。使えるのは、別のパスキー、管理者による再発行、あるいはアプリケーションが自前で用意した検証済みの手段だけです。

誰もログインしません。リクエストは毎回 Authorization: Bearer … にアクセストークンを載せて来て、アプリケーションはそれを検証する。儀式はそれで全部です。ログインエンドポイントもコールバックもセッションレコードも Cookie もありません。呼び出し側は authorization server が発行したクレデンシャルをすでに持っていて、このアプリケーションはそれを検査するリソースサーバーだからです。

だから上の3つの問いは、ここでは黙ります。アカウントを作ったのは authorization server が言うどこかですし、日常のログインはトークンを取得した何かの担当、そして復旧は呼び出し側と authorization server のあいだの話で、こちらは関与しません。

得られるもの。 クレデンシャルの保管もセッションの保管も無く、リクエスト間で持ち越す状態もありません。何も持たない配備は、ゼロまで縮んでまた戻れます。

引き受けるもの。 トークンは期限が来るまで有効なので、アクセスを早めに終わらせたいなら失効の戦略を自分で選ぶことになります。revocation.mode に緩い既定値は無く、決めるまで起動しません。Bearer トークンを失効させるを参照してください。

ブラウザのフローと Bearer の API は信頼モデルが別物なので、このモードは両者を兼ねません。両方が要るアプリケーションは、API を別の配備として出します。pw init --auth の候補に jwt_only が無いのもそのためで、代わりに API サーバー向けのプリセットが雛形を書きます。設定は 認証の組み込みにあります。

モード単体ではなく、admission(誰を通すか)と組み合わせて決まります。

その前に決まる問いが1つあります。ブラウザはあるのか、です。呼び出してくるのが他のサービス、スクリプト、あるいはトークンを持ったモバイルクライアントなら jwt_only で、以下の場合分けは当てはまりません。置くべきアカウント作成も、設計すべき復旧路も無く、決めるのは admission の規則と失効の戦略だけです。ここから先は、ブラウザの前に人がいることを前提にしています。

利用者は Google や Apple のアカウントを持ってやってきます。oidc_onlyoidc_passkey、admission は authenticatedauto_provision = true。誰でも、初回ログインでアカウントができます。

考えておくべきことが1つあります。利用者がその Google アカウントを失ったとき、あなたのサービスからも同時に締め出されるということです。oidc_passkey にしてパスキーを登録させておけば、そこが二重化されます。

導入先企業の IdP が権威です。oidc_only に admission registered を組み合わせて、ログインより先に入れる人を登録しておきます。従業員番号のような、ディレクトリ側が発行する安定した識別子を auth.oidc.identity_claim に指定できるので、まだ一度もログインしていない人を先に登録できます。

退職処理は先方の IdP で起きます。そこでアカウントが止まれば、こちらのログインも止まる。こちら側に退職者リストを持たなくて済むことが、この構成の実利です。

受付、工場のライン、店舗の端末。ブラウザが人と1対1に対応しません。

auth.shared_device = true を立てます。これは3つの設定を束ねたもので、どれか1つだけでは効果がありません。ローカルの記憶を消しても、IdP のセッションが生きていれば、次の人はプロバイダのアカウント選択画面で前の人の名前を見ます。名前を出しているのは IdP の方だからです。

そして共用端末で一番多いのは、明示的なサインアウトではなく放置です。auth.assurance.presence を併せて有効にしてください。詳しくは後述します。

IdP がない、あるいは外部に出せない。passkey_only に admission registeredauth.registration.policy = "administrator"。管理者が発行したブートストラップ資格情報が、そのままアカウント開設の儀式になります。

キーボードを持たない TinyGo デバイスは、ブラウザ向けログイン経路をたどれません。 パスワードを安全に入力できず、パスキーのUIも出せず、認可コードのリダイレクトも 受け取れないためです。これは auth.mode とは別の軸です。mode は人がWebアプリへ入る 方法を選び、入力に制約のあるデバイスはOIDCの公開クライアントとして RFC 8628 Device Authorization Grantを使います。

デバイスはプロバイダへ認可を要求し、短いuser codeとverification URI (または VerificationURIComplete のQRコード)を表示します。利用者は別のスマートフォンや PCで承認し、その間デバイスがpollします。

device, err := oidc.NewDeviceClient(provider, oidc.DeviceConfig{
ClientID: "display-controller",
}, oidc.DeviceOptions{})
if err != nil {
return err
}
authorization, err := device.Begin(ctx, oidc.DeviceBeginOptions{
Scopes: []string{"display.read"},
})
if err != nil {
return err
}
show(authorization.VerificationURI, authorization.UserCode)
tokens, identity, err := device.Poll(ctx, authorization)

公開クライアントとして登録し、ファームウェアへclient secretを埋め込まないでください。 ライブラリはプロバイダのpoll間隔と slow_down 応答に従います。画面を離れる、設定を やり直す、デバイスを終了するときにpollを止められるよう、キャンセル可能なcontextを 使います。DeviceCode は意図的に公開されません。このbearer資格情報をログや画面へ 出してはいけません。

承認でデバイスが得るのはtokenであり、ブラウザセッションでもパスキーでもありません。 どのAPIがaccess tokenを受け取るか、必要なscope、refreshまたは再設定の方法、その ハードウェアでtokenをどこへ保存できるかは別に決めます。安全な永続ストレージがないなら、 短命で最小権限のtokenを優先します。 開発用IdPは、公開クライアント、user code、承認、 pollingまで同じ経路をローカルテスト用に実装しています。

ここまではログインの話でした。ログインが終わったあと、セッションは有効か無効かの2状態しか持っていない、というのが素朴な設計です。

その設計には問題があります。盗まれたセッションクッキーが、決済にも、クレデンシャル変更にも、エクスポートにも、フル権限で届いてしまう。パスキーや MFA でログインの瞬間を固めるほど、攻撃者は認証を突破するのをやめて、認証後のクッキーを盗む方に移ります。実際、2025年以降のアカウント乗っ取りの主流はそちらです。

対策として有効期限を短くすると、今度は普通の利用者が何度もログインさせられます。ログインを稀にするダイヤルと、盗難の被害範囲を絞るダイヤルが、同じ1本のつまみになっている。これが2状態モデルの限界です。

セッションが取りうる状態は4つですが、一直線には並びません。ログインで active に入り、重い操作の前に confirmed へ上がり、時間が経つと下がっていく。循環します。

anonymous からログインで active へ、Ensure の要求で confirmed へ、時間経過で戻る状態遷移図

identified を破線にしてあるのは、既定ではこの状態が存在しないからです。ヒントを設定していなければ active から anonymous へ直行します。前回の記憶を残すかどうかは明示的に有効にする設定で、共有端末では禁止されます。

このうち ハンドラが分岐するのは2つだけ です。

  • 認証済みかどうか → auth.protection.include に書くだけで、ハンドラのコードは1行も要りません
  • 最近証明したかどうか → 操作ごとにハンドラで宣言します

identified(前回の記憶)は、ログイン画面の見た目の話であって、ハンドラが分岐する対象ではありません。anonymous はログインの問題で、パスの保護が先に答えます。

「最近証明したか」の他に「どれくらい強い方法で証明したか」という軸を置くこともできます。置いていません。

フレームワークがログイン方法に順序を付けられないからです。oidc_onlypasskey_only はそれぞれ1つの方法しか生まないので、順序を付ける対象がありません。oidc_passkey は2つ生みますが、どちらが強いかは一般には決まらない。ハードウェアキーを使う IdP はローカルのパスキーより強く、ユーザー検証なしのパスキーは90日 SSO セッションを通す IdP より強い。順序はデプロイがそのプロバイダについて主張することであって、ラベルの性質ではありません。

そういうわけで、ハンドラが書くのは1つの述語と1つのパラメータだけです。

app.HandleFunc("GET /admin", auth.Ensure(adminPage, auth.Policy("admin")))
app.HandleFunc("POST /api/admin/drop", auth.EnsureAPI(drop, auth.Policy("danger")))
[[auth.assurance.policy]]
name = "admin"
max_age = "15m"
[[auth.assurance.policy]]
name = "danger"
max_age = "0"
confirm = true # この操作のために、いま確認しろ

名前で引くようにしてあるのは、同じハンドラのコードが、窓の広い B2C デプロイと窓の狭い社内デプロイの両方で動くようにするためです。

ログインは確認の代わりにならない

Section titled “ログインは確認の代わりにならない”

ここまでの窓は「最後に証明してから何分か」を見ています。すると、ログイン直後のセッションはあらゆる窓を最初から満たしています。ダッシュボードを見るためにサインインした人が、そのまま送金画面まで一度も問われずに到達する。

サインインと、ある操作の確認は、別の行為です。ログインはその人自身の都合で起きたことで、いま送金しようとしている人について何も言っていません。ステップアップはこの操作が要求したから起きた。鮮度だけで見ると、この2つが同じものになります。

そこで要求は2種類あります。

auth.MaxAge(15 * time.Minute) // 直近の証明なら何でもよい。ログインも数える
auth.Confirmed(5 * time.Minute) // このガードが要求した再証明のみ。ログインは数えない
[[auth.assurance.policy]]
name = "transfer"
max_age = "5m"
confirm = true # ログインでは満たされない

管理画面に入るだけなら MaxAge で足ります ── 午後ずっと開きっぱなしのセッションを弾きたいのであって、たった今サインインした人を疑いたいわけではないからです。送金、テナント削除、顧客リストのエクスポートには Confirmed を使います。

Confirmed(0) は「この操作のために、いま確認しろ」です。ゼロは経過時間では決して満たされません。プロバイダへの往復が必ず0秒より長くかかるので、時刻の比較で判定すると再証明の直後にもう一度弾いて永久に収束しない。完了したステップアップが残す通行許可だけがこれを満たします。窓を正の値にすると、1回の確認で数分ぶんの操作をまとめて通せます。

鮮度をどこから測るか。到着時刻ではありません。

IdP は max_age を受け取っても、自分の SSO セッションからそれを満たして返すことがあります。返ってきたトークンが「たった今届いた」ことは、「たった今認証した」ことを意味しない。auth_time クレームが本当の証明時刻で、鮮度はそこから測ります。

だから max_age を送ったのに auth_time が返らなければ、それは再証明の失敗として扱います。OpenID Connect は max_age を送った場合に auth_time を必須にしているので、無いということはプロバイダが問いに答えなかったということです。

prompt=login の方は検証できません。仕様上は SHOULD で、しかも「従いました」と報告するクレームが存在しない。だから prompt は対話を増やす助けであって、証明にはなりません。セキュリティ判断は max_ageauth_time に載せます。

ブラウザ・Popcorn Web・IdP の3者でリダイレクトとコード交換が進む通常ログインのシーケンス図

最後のローテートは、ブラウザが持っていたセッションを必ず失効させます。ログイン前のセッションが生き残らないので、固定化攻撃が成立しません。

古い auth_time を検出して再認証させ、4つの検査を経てセッションをローテートするシーケンス図

図の 1 が抜けると、ステップアップはアカウントのすり替えになります。前のアカウントの機微な操作がステージされたまま、別人のログインでそれが通ってしまう。issuer と識別クレームと値を、いま持っているセッションと突き合わせて、一致しなければ何も書かずに失敗します。

3 があるのは、その間に権限を失った人が再証明で復活しないようにするためです。

POST の再開には限界があります。コールバックからの戻りはリダイレクト、つまり GET なので、フレームワークが自動で再現できるのは安全なメソッドだけです。だから読み取り側に実効的な窓を、書き込み側は境界として置き、書き込み側の窓を少し緩くします。フォームを長く書いている間に読み取り側の証明が切れて、送信で入力を失うのを避けるためです。

ログアウトが IdP に対して何をするかは、3通りありえます。うち1つは採用していません。

reconfirm はプロバイダに何も送らず次回のログインに prompt を積み、global は end_session_endpoint を叩くシーケンス図

reconfirm は名前を付けただけで、規格ではありません。ログアウト時にプロバイダへ送るリクエストは存在しません。次の普通の認可リクエストにパラメータを1つ足すだけです。

そもそも「自分の RP の分だけ IdP から忘れてもらう」という手段は仕様に存在しません。OP のセッションは RP 横断で共有される1つのもので、RP-Initiated Logout は全部消すか何もしないかの二択です。reconfirm は OP に何も頼まないことでこれを回避します。

global を選ぶのは、共有端末、キオスク、そして「サインアウトとは全部から出ることだ」と定義しているデプロイです。利用者に両方を出したいなら auth.oidc.allow_global_logout_request を立てます。リクエストからは昇格だけでき、降格はできません。

アイドルタイムアウトは、最後のリクエストからの時間を測っています。人からの時間ではありません。

この差が実害になる場面があります。ライブ接続を持つページは自分で再接続を繰り返すので、誰も居なくてもセッションが延び続けます。逆に、1つのページを40分読んでいる人はリクエストを出さないので、作業中に切れます。

auth.assurance.presence を有効にすると、ブラウザが1ティックあたり1ビットを報告します。前回から入力イベントがあったかどうか、それだけです。座標も打鍵内容も間隔もブラウザから出ません。

信頼の向きは対称ではありません。

  • 「離席した」という報告はそのまま採用します。誤検知のコストはログイン1回です
  • 「まだ居る」という報告はサーバの境界内でのみ有効です。スクリプトから偽装できるので、絶対期限は動かせません
  • ビーコンが届かないこと自体が離席の報告です。クライアントは「そこに居ない」を能動的に主張できません

スリープ復帰を直接知る API はありません。一定間隔で発火するはずのタイマーが大きく飛んだことから推定し、それは在席ではなく離席として扱います。

セッションが切れたあと、ブラウザに「前回サインインしたのは誰か」を残せます。既定では残しません。

残す価値があるのは、プロトコルが供給できない情報があるからです。IdP は prompt=select_account で「自分のところのどのアカウントか」を教えられますが、そのデプロイが他にどんな IdP を並べているかは知りません。複数の IdP に対応している画面が毎回ピッカーを出すか出さないかは、ローカルの記憶にしか決められません。パスキー専用構成には、そもそも尋ねる相手がいません。

残す場合、封緘クッキーに入るのでブラウザからは読めません。だからログイン ID を入れられます。守るべきなのは中身ではなく、ログイン画面に描画したものの方です。共有端末で次に座る人が読むのはそちらなので、auth.MaskIdentifier を通します。

issuer はマスクできません。「Microsoft で続ける」ボタンを出すか出さないかの二択で、部分的な形がない。だから issuer を覚えてよいかどうかは表示の工夫ではなく、有効にするかどうかと寿命の設定です。ttl = "0" は正当な答えで、そのときブラウザは active から anonymous へ直行します。

設定キーの一覧、エンドポイントの仕様、パスキーの儀式、セッションのバックエンド選択は 認証の組み込み にあります。セッションの保存先そのものについては セッションストレージ を参照してください。