はじめに
この方法はまだ未検証の部分が多いですが、とりあえず管理者としては無事にログインできるようになったので、ここまでの作業を記事にまとめることにします。
最終的に2024年時点で稼動させたDiscourseではPluginを利用してOIDC Providerを利用しています。Qiita.com - DiscourseをHelmで導入してみた + API経由でのサーバー設定
この記事以外の成果物
解決したい問題・問題になりそうな環境要因
- バックエンドにLDAPがあるので個別にパスワードを発行せずに、Discourseを利用したい。
- 既にdexidp/dexはLDAPと連携しているため、これを利用したい。前回の投稿
- Discourse標準のSSOを利用するためdiscourse-sso-oidc-bridge(Bridge)を利用したい。
- dexidp/dex, Bridgeはプライベートネットワークで稼動しているkubernetes(k8s)クラスターで稼動させる。
- dexidp/dex, BridgeへのアクセスはTLS(https)を有効にしているNginxのproxy_passを経由させる。
- バックエンドのdexidp/dex, Bridgeはnon-TLS(http)で稼動している。ingressは使用しない。
解決案の概要
DiscourseでOpenID Connectを利用したい場合の方法は、大枠で次の2つ。
- Pluginを利用する OpenID Connect Authentication Pluginなど
- 標準のSSO(SingleSignOn)機能を利用する
この2つの比較は、リンクしたmeta.discourse.orgの記事中にあるErik Sundellさんのコメントが参考になると思います。
SSOの仕組みを利用した方がよりシームレスに使えそうという事で、SSOを利用したいと思いました。
そこで、このBridgeというアイデアに乗ったのですが、dexに接続したところうまく動かない状況になったので、解決するまでの顛末をまとめます。最終的な変更点は2行ぐらいですが、先頭のリンク先(github)に掲載しています。
対応の詳細
標準的な方法によるdiscourseのデプロイ
ベアメタルなサーバーを準備して、標準的な方法(Beginner Docker install guide)でDiscourseを動かしています。
KubernetesへのBridgeのデプロイ
あらかじめnamespaceを準備してから、次の2つのファイルをapplyします。
---
apiVersion: v1
kind: Service
metadata:
namespace: discourse
name: sso
spec:
type: LoadBalancer
loadBalancerIP: "10.1.200.135"
ports:
- port: 80
targetPort: 8080
protocol: TCP
selector:
app: sso
apiVersion: apps/v1
kind: Deployment
metadata:
namespace: discourse
name: sso
labels:
app: sso
spec:
replicas: 1
selector:
matchLabels:
app: sso
strategy:
type: Recreate
template:
metadata:
labels:
app: sso
spec:
imagePullSecrets:
- name: dockerhubreg
containers:
- name: sso
image: _yourid_/discourse-sso-oidc-bridge:1.0
ports:
- containerPort: 8080
name: sso
env:
- name: DEBUG
value: "true"
- name: SERVER_NAME
value: "proxy.example.org"
- name: SECRET_KEY
value: "de78cc50b47a1de6d9a8d83bbed6deb26e63529694a5f2dae20a62666b8e87d1"
- name: OIDC_ISSUER
value: "https://proxy.example.org/dex"
- name: OIDC_CLIENT_ID
value: "discourse-test"
- name: OIDC_CLIENT_SECRET
value: "de4c6e9941a7ae4a6cc4ba518a1fcd132166e8aebd7d3b22da7c425ef7172e81"
- name: OIDC_SCOPE
value: "openid,profile,email"
- name: DISCOURSE_URL
value: "http://discourse.example.org"
- name: DISCOURSE_SECRET_KEY
value: "eb553c1ad5d2e4321e87a89f0ed97550f23b460707986eb6fe0d4f005a76ef87"
- name: USERINFO_SSO_MAP
value: "{ \"sub\": \"external_id\", \"name\": \"username\", \"email\": \"email\" }"
SECRET_KEY、DISCOURSE_SECRET_KEY等は、openssl rand -hex 32
で生成しています。
この中で、_yourid_/discourse-sso-oidc-bridge:1.0
はPrivateに設定しているためアクセスできません。
先のgithubをclone & buildして、自分の適当なdockerhubアカウントにpushしておきます。
そのdockerhubアカウントにアクセスするための情報をsecret/dockerhubregに登録しておきます。
準備ができたら作成したファイルを適応させます。
ファイルとコマンドラインの*yourid*, youremail, yourpassword と書かれた計4箇所は適宜変更してください。
$ kubectl create ns sso
$ kubectl -n sso create secret docker-registry dockerhubreg --docker-username=_yourid_ --docker-email=_youremail_ --docker-password=_yourpassword_
$ kubectl -n sso apply -f 01-service.yaml
$ kubectl -n sso apply -f 02-deploy-discourse-sso-bridge.yaml
nginxの構成
nginxは次のようなconfigで稼動しています。
server{
listen 80;
server_name proxy.example.org;
return 301 https://$host$request_uri;
}
server{
listen 443 ssl;
server_name proxy.example.org;
ssl_certificate /etc/ssl/certs/proxy.example.org.cer;
ssl_certificate_key /etc/ssl/private/proxy.example.org.nopass.key;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
## dex
location /dex {
proxy_pass http://web36.k8snet.example.org:5556/dex;
}
## discourse-sso
location /sso/ {
proxy_pass http://web35.k8snet.example.org/sso/;
}
location /redirect_uri {
proxy_pass http://web35.k8snet.example.org/redirect_uri;
}
}
dexidp/dex のconfig-ldap.yamlへの追加点
staticClientsにidを追加しています。
staticClients:
- id: discourse-test
redirectURIs:
- 'http://proxy.example.org/redirect_uri'
name: 'Discourse Test'
secret: de4c6e9941a7ae4a6cc4ba518a1fcd132166e8aebd7d3b22da7c425ef7172e81
redirectURIs:の中で、"http://"とnon-TLSを指定しているのはわざとです。
Discourse管理者画面での変更点
あらかじめ管理者として登録しているユーザーでログインしておきます。
http://discourse.example.org/admin/site_settings/category/login に進み、"sso url"に *https://proxy.example.org/sso/login*を指定します。
たぶんこの設定が一番分かりにくいのかもしれません。Bridgeのリポジトリを作ったSundellさんが参考にした https://github.com/fmarco76/DiscourseSSO のREADME.rst の中で説明されています。
次に、"sso secret"にDISCOURSE_SECRET_KEY環境変数に指定した値を設定してから、"enable sso"のチェックを有効にしています。
discourse-sso-oidc-bridgeへの変更点
https://github.com/YasuhiroABE/discourse-sso-oidc-bridge は次のような問題がありました。
- OpenID ConnectのIssuer(この場合ではdexidp/dex)が、cookieに"userinfo"をキーにして情報を返してくる点
- この"userinfo"に含まれる情報として、"email"、"external_id"の2つは最低限必要としているが、Issuerは"sub"にユニークなIDを格納してくる点
前者はapp.pyを変更して、"userinfo"を"id_token"に書き換えています。
後者については、k8sのconfigファイルの中でUSERINFO_SSO_MAP環境変数でマッピングを指定する事で解決しています。
さらに"nonce"という変数がdiscourseとWebアプリが利用するFlaskフレームワークの両方のCookieに設定されるため、両方を一つのqueryに展開する箇所で、Flaskのnonceは除いて、discourseに戻しています。
差分
diff --git a/discourse_sso_oidc_bridge/app.py b/discourse_sso_oidc_bridge/app.py
index 154778b..6a5437d 100644
--- a/discourse_sso_oidc_bridge/app.py
+++ b/discourse_sso_oidc_bridge/app.py
@@ -181,7 +181,7 @@ def create_app(config=None):
attribute_map = app.config.get('USERINFO_SSO_MAP')
sso_attributes = {}
- userinfo = session['userinfo']
+ userinfo = session['id_token']
# Check if the provided userinfo should be used to set information to be
# passed to discourse. Do it by checking if the userinfo field is...
@@ -212,7 +212,7 @@ def create_app(config=None):
# Check if we got the required attributes
for required_attribute in REQUIRED_ATTRIBUTES:
if not sso_attributes.get(required_attribute):
- app.logger.info(f'/sso/auth -> 403: {required_attribute} not found in userinfo: ' + json.dumps(session['userinfo']))
+ app.logger.info(f'/sso/auth -> 403: {required_attribute} not found in userinfo: ' + json.dumps(session['id_token']))
abort(403)
# All systems are go!
@@ -221,6 +221,9 @@ def create_app(config=None):
# Construct the response inner query parameters
query = session['discourse_nonce']
for sso_attribute_key, sso_attribute_value in sso_attributes.items():
+ # key:'nonce' was already registered by session['discourse_nonce'].
+ if sso_attribute_key == "nonce":
+ continue
query += f'&{sso_attribute_key}={quote(str(sso_attribute_value))}'
app.logger.debug('Query string to return: %s', query)
以上