コンテンツにスキップ

誰を通すか

アカウントを作れるのは、同じ世帯の人だけにしてください。 denpa は自分の世帯の中で私的に観るための ものです。OIDC のグループ (OIDC_GROUP) に入れる人、ペアリングするテレビ、TRUSTED_NETWORKS に入れる ネットワークも、同じ世帯の人と機器に限ります。考え方は Discussion #760 にあります。

エンドポイントによって守り方が違います。

エンドポイント 守り方
/api/live/socket 使い捨てのチケット (下記)。ここだけ SvelteKit に届きません
それ以外 (画面・録画のファイル・外から使う API) OIDC (設定してあれば)
どのエンドポイントも TRUSTED_NETWORKS に当たれば素通し (下記)
どのエンドポイントも アクセストークン (Authorization: Bearer denpa_…) があれば、どこから来ても通す (下記)
/login* /logout /api/health /manifest.webmanifest /api/device/code /api/device/token 素通し (auth.ts の OPEN_PATHS)

OIDC も TRUSTED_NETWORKS も設定していなければ、認証なしで通すエンドポイントのほかは全部断ります (403、 理由を本文に書いて返す。下記)。全部開けたいなら TRUSTED_NETWORKS=0.0.0.0/0 です (公開時の注意)。

/api/live/socket だけは入口の server.js が SvelteKit の手前で受けます。 SvelteKit の 仕組みでは WebSocket の握手を扱えないためで、hooks.server.ts に届かず、ここに書いた守り方は効きません。

ブラウザは WebSocket の握手に Authorization を付けてくれないので、画面が先に POST /api/live/ticket で使い捨てのチケットを取り、URL に付けて繋ぎます。チケットの発行は 普通の HTTP なので、OIDC も信頼したネットワークもそのまま効きます。チケットは1回使ったら 消し、寿命は30秒 (src/lib/server/tickets.ts、詳しくは stream.md)。

/api/health を守ると Pod が再起動を繰り返します (Kubernetes の livenessProbe が呼ぶため)。 出しているのは「生きているか・動いている版と新しい版・局が何件か・録画が何本か」だけです。

/manifest.webmanifest を守るとホーム画面の名前が付きません。 ブラウザは マニフェストを資格情報を付けずに取得するので、401 で落ちます。 中身は表示名・色・アイコンの場所だけです (watch.md)。

録画のファイル (/api/recordings/<id>/file) も、ほかの API と同じ守り方です。 観る画面の <video> も 端末に保存もログインの控え (Cookie) を、テレビのアプリはアクセストークンを付けます。ログインせずに開ける URL を 渡す仕組み (前の「再生リンク」) はやめました (config.md)。

ログイン方法が無ければ、全部断る

Section titled “ログイン方法が無ければ、全部断る”

何も設定しないまま立てると、認証なしで通すエンドポイント (上の OPEN_PATHS) のほかは 403 で断ります (fail-closed)。 起動ログに1度だけ案内を出します。

[boot] ログイン方法が設定されていないため、すべてのアクセスを断ります。
OIDC (docs/auth.md) か TRUSTED_NETWORKS (CIDR のカンマ区切り) を設定してください。
すべて開けるなら TRUSTED_NETWORKS=0.0.0.0/0 (公開時の注意は docs/auth.md)

前の版のベーシック認証 (起動時に自動生成) は廃止しました。自動で掛かる方式は「設定を忘れた」と区別が付かないためです。

3つ揃ったときだけ有効になります。揃っていなければ、ログイン方法は TRUSTED_NETWORKS だけです。

変数
OIDC_ISSUER 例 https://login.microsoftonline.com/<tenant>/v2.0
OIDC_CLIENT_ID アプリ登録のアプリケーションID
OIDC_CLIENT_SECRET クライアントシークレット
OIDC_GROUP このグループにいるか、このロールを持つ人だけ通す。空なら入れた人は全員
OIDC_SESSION_TTL ログインの有効期間(ms)。既定30日

秘密を含むので環境変数だけから読み、設定画面には出しません。

使うエンドポイントは discovery・authorize・token・jwks の4つだけで、どれも素の HTTP と WebCrypto で足ります。認証まわりで「中で何をしているか分からない」を抱えるより、 200行書くほうを選びました (src/lib/server/oidc.ts)。

OIDC の標準しか使っていないので、Keycloak でも Google でも同じはずです (実機で当てたのは Entra ID だけ)。

  • 認可コードフロー + PKCE (S256)。state と nonce も使います
  • ID トークンは署名まで見ます。 認可コードフローでは TLS 越しに相手から直に 受け取るので仕様上は省けますが (OIDC Core 3.1.3.7)、省くと安全が 「トークンをどこから受け取ったか」に乗ります。WebCrypto で数行です
  • 受ける署名方式は RS256 だけ。 alg: none を受けると署名を見ない抜け道ができます
  • 発行元・宛先・期限・合言葉 (nonce) を確かめます。時計のずれは60秒まで許します

誰がログインしたかでは決めません。 人が増えても denpa 側を触らずに済みます。

見るのは groups と roles の両方で、どちらかに OIDC_GROUP があれば通します。 グループを載せると ID トークンが大きくなる (前段の oauth2-proxy などの Cookie が壊れることがある) ので、 アプリロールに移す人もいます。両方受ければ、どちらの形式でも、切り替えの途中でも入れます。

Entra ID でグループを載せるには、アプリ登録の「トークン構成」で groupMembershipClaims を有効にしてください。既定で載るのは グループのオブジェクトID (GUID) なので、OIDC_GROUP にもそれを書きます。

OIDC_GROUP=6f1b2c3d-4e5f-6789-abcd-ef0123456789

ロールで決めるなら、アプリ登録のアプリロールを作って人に割り当て、OIDC_GROUP にはそのロールの値を書きます。

断るときは理由を画面に出します。黙って弾くと「なぜか自分だけ入れない」になるためです。

出るもの 意味
... のグループにもロールにも入っていません そのとおり
ID トークンに groups も roles もありません groupMembershipClaims がまだ無効で、アプリロールも割り当てていない
グループが多すぎて ID トークンに載っていません Entra が groups の代わりに _claim_names を返し、roles も無かった。アプリロールに移すか、グループを減らすか、OIDC_GROUP を空にして Entra 側のアプリ割り当てで決める

テレビのアプリ (danything/denpa-tv) は、QR で ペアリングしてアクセストークンを持ちます (device-auth.ts)。家の外から OIDC 越しに使うためで、 信頼するネットワークの中ならペアリングは要りません (アプリはアクセストークンなしで API を呼び、 401 の JSON が返ったときだけペアリングに進む。api.md)。

方式は OAuth のデバイス認可 (RFC 8628) と同じです (API の形式は api.md)。 テレビがコードを貰って device?code=… を QR に出し、スマホで開くと、いつものログイン方法 (信頼するネットワーク、無ければ OIDC のログイン) を通ったうえでそのまま済む。テレビは待っていたアクセストークンを1度だけ受け取り、 以後は Authorization: Bearer denpa_… でどのエンドポイントにも入る (/device で別の端末を済ませることだけは断る。下記)。

「許す / 断る」は聞きません。 家で使う前提で、QR を読めるのはテレビの前にいる人だからです。 その代わり:

  • コードは10分で切れ、1度しか使えない。 アクセストークンを渡したらコードは使い終わり (consumed_at)
  • コードは画面に出さない。 QR を読んだ時点でそのテレビと結び付いていて、コードは URL に入って自動で渡る。人が打つことも見比べることも無いので、出しても読む人がいない
  • 開いただけでは済ませない。 /device は開くと自分でフォームを送る (POST。CSRF の守りが効く)。 GET のまま済ませると、ブラウザの先読みやリンクの下見で黙って済んでしまう。 読み直してもアクセストークンは2つにならない (作るのはテレビが受け取るとき)
  • 有効なコードは20まで (MAX_PENDING)。コードを発行するエンドポイントは誰でも呼べるので溜め込ませない。 埋められても10分でペアリングできるようになり、入れるようにはならない
  • 秘密は DB に置かない。 deviceCode もアクセストークンも SHA-256 だけ持つ (device_codes / api_tokens)。 アクセストークンは許した瞬間ではなく渡すときに作るので、生のアクセストークンを預かっておく場所が要らない
  • 無効にした・知らないアクセストークンは 401 ({"error":"invalid_token"})。信頼するネットワークの中でも、 ほかの認証へは回さない — 黙って通すと、アプリは取り消されたことに気付けず、外へ出た途端に 使えなくなる。denpa_ で始まらない Bearer (前段の SSO などが付けるもの) は見ずに、いつもの認証へ回す
  • 取り消しは設定の「連携」の「テレビのアプリ」から (/settings/integrations#tv。行は消さずに revoked_at。いつ・誰が許したかを残す)。 アプリの「サーバーから外す」は POST /api/device/logout で自分のアクセストークンを無効にする
  • 最後に使った時刻は1分に1度だけ書く (映像は Range ごとに来るので、毎回書くと DB に書き続けることになる)
  • アクセストークンで入った相手は /device で済ませられない (403。approve が断る)。盗まれたアクセストークンで 別のアクセストークンを作れると、元のものを取り消しても作ったものが生き残るため

確かめずに済ませることで残る危うさ (承知のうえ)。 コードを発行するエンドポイントは誰でも呼べるので、 悪意のある人が自分の端末でコードを作り、その device?code=… を、denpa に入れる人に開かせると (メッセージのリンク・QR を差し替えるなど)、開いた瞬間に相手の端末へアクセストークンが渡ります (デバイス認可のフィッシング)。家で使う前提で、確かめの手順を挟まないことを選んでいます。 コードが10分で切れて1度しか使えない (上記) ほかに:

  • 完了の画面に端末の名前を出す。テレビの前にいないのにこの画面が出たら、それはおかしい (名前は端末の自己申告で、だれでも「テレビ」と名乗れる)
  • 渡したアクセストークンは「テレビのアプリ」の節に名前・ペアリングした時刻・最後に使った時刻つきで並び、 その場で取り消せる。覚えのない端末が無いか、ときどき見てください
  • 人から送られてきた device?code= のリンクは開かない (QR はテレビに出ているものだけ読む)

ネットワークの中なら、何も聞かない

Section titled “ネットワークの中なら、何も聞かない”

TRUSTED_NETWORKS に当たると、OIDC も掛かりません。 LAN のテレビのアプリ (denpa-tv) に、ログインもペアリングもなしで使わせるためのものです。

TRUSTED_NETWORKS=10.10.0.0/16
# いくつでも並べられる
TRUSTED_NETWORKS=10.10.0.0/16,10.20.0.0/16,192.168.1.5
# 全部開ける (家の中だけで完結する構成向け。公開時の注意は下)
TRUSTED_NETWORKS=0.0.0.0/0

0.0.0.0/0 はインターネットに向けて開くのと同じです。 録画も設定も誰でも触れます。 家の外に出す構成では使わず、OIDC にします。

見るのはアドレスだけで、どの名前で来たかは問いません。 外向きと LAN 用の2つの名前で出していても、 LAN から外向きの名前を開けば通ります (install.md)。

前段の後ろに置くとき (TRUSTED_PROXIES)

Section titled “前段の後ろに置くとき (TRUSTED_PROXIES)”

前段 (Gateway やリバースプロキシ) があるなら、前段のアドレスを TRUSTED_PROXIES に書くこと。 書かないと接続元が前段のアドレスになり、TRUSTED_NETWORKS が誰にも当たりません (=全員が認証を 求められます。開いてしまう向きには倒れません)。前段が無い (ブラウザが直に denpa へ来る) なら 何も要りません。

# Kubernetes の Gateway (Cilium の Envoy など hostNetwork で動くもの) は Pod のネットワークから繋いでくる
TRUSTED_PROXIES=10.42.0.0/16,fd42::/64
# 同じ compose のネットワークにいるリバースプロキシ
TRUSTED_PROXIES=172.16.0.0/12

決め方は nginx の set_real_ip_from + real_ip_recursive on と同じです (src/lib/server/address.ts の resolveClient。入口の server.js が使う)。

  • 直の相手が TRUSTED_PROXIES に当たらなければ、相手そのものが接続元。X-Forwarded-For は 誰でも付けられるので読みません。denpa のポートへ前段を通らずに届く経路があっても、ヘッダで TRUSTED_NETWORKS を名乗ることはできません
  • 当たれば X-Forwarded-For を右から辿り、前段のアドレスを飛ばして、最初に出てきた 前段でないアドレスを接続元にします。前段を何段重ねても、全部 TRUSTED_PROXIES に入っていれば 段数を数える必要はありません。全部が前段なら左端を使います
  • 読めない項目に当たったら、そこで止めます (その先は信じない)
  • ::ffff:10.0.0.1 のような IPv4 を写した IPv6 は IPv4 として扱います
  • 前段の網を広く書くと、その網の中の誰もが前段を名乗れます。 Pod の網を入れると、 クラスタの中の Pod は X-Forwarded-For を付けて denpa に直に来れば好きな接続元を名乗れます。 クラスタの中を信じられないなら、前段のアドレスだけに絞ってください

決めたアドレスは adapter-bun へ x-denpa-remote で渡します (外から同じ名前で付けて 来ても上書きで消えます)。アドレスを読めなかったときは通しません。

X-Forwarded-Proto は前段かどうかを問わず読みます。 付けられて変わるのは、付けた本人に 返る origin の見た目だけです (CSRF の判定はブラウザが付ける Origin と比べるので、他人の ブラウザからは付けられない)。前段だけに絞ると、TRUSTED_PROXIES を書き忘れた https の構成で POST が全部断られるようになるだけで、守れるものがありません。

前の版の ADDRESS_HEADER=x-forwarded-for は廃止しました (読みません)。上げるときは docs/config.md。

ネットワークの書き方は CIDR (10.10.0.0/16・fd00::/8) かアドレスそのまま。IPv4 と IPv6 の どちらも書けます。

DBに持ちます (sessions)。署名した Cookie に中身を入れる手もありますが、それだと 「この端末だけ切る」ができません。Cookie に入るのは推測できない32バイトだけです。

  • httpOnly / SameSite=Lax / https のときは Secure
  • 切れたものは RECONCILE_INTERVAL ごとに片付けます (読む側は先に無視しています)
  • ログアウトはこちらの控えを消すだけで、Entra 側からは出しません。 end_session_endpoint へ送ると、同じ Entra で入っている他のものまで巻き添えで 切れるためです
  • ログアウトは設定の「その他」の「ログイン」から (/settings/general#login。POST /logout)。ヘッダーには置いていません
  • 同じ「その他」の**「ほかの端末からもログアウト」**は、いま使っている控えのほかを誰のものでも全部消します (2回押し。session.destroyOthers)。なくした端末・手放した端末を止めるためのものです。 消した数を画面に出し、消された端末は次に開いたときにログインし直しになります。 テレビのアプリのアクセストークン (api_tokens) はここでは止めません (「テレビのアプリ」の節で1台ずつ取り消します)
  • 素通し (TRUSTED_NETWORKS) で入っているときは控えが無いので、「その他」にはその旨の1行だけ出します
  1. アプリの登録を作る (シングルテナントでよい)
  2. リダイレクト URI を「Web」で登録する。denpa の名前ごとに1つずつ要ります (https://<denpa の名前>/login/callback)
  3. クライアントシークレットを作る
  4. トークン構成で groupMembershipClaims を有効にする (グループで絞るなら)。 ロールで絞るなら、代わりにアプリロールを作って割り当てる

Helm では、chart の外で作った Secret (キーは issuer / client-id / client-secret / group) の名前を denpa.oidcSecretName に渡します。denpa は Secret を環境変数で読むので、値を変えたら Pod を入れ替えます。

前段の認証 (oauth2-proxy の forward-auth など) は要りません。 前段で掛けていたものを外すときは 「denpa 側を設定 → 実機で入れることを確かめる → 前段から外す」の順にします。逆にすると、 OIDC の設定を間違えていたときに、誰も入れないのではなく誰でも入れる状態になります。