ドメイン指定Cookieと
サービス間共有Redisで作る
認証基盤サービス
Leaner Technologies, Inc.
黒曜
(@kokuyouwind)
$ whoami
-
黒曜 / @kokuyouwind
-
Leaner Technologies Inc. 所属
-
Railsエンジニア・SRE
-
今はLeanerの認証基盤サービスを
作ってます

認証基盤サービス?
シンプルに Rails アプリを作る場合、
認証(ログイン)も1つの機能として実装



認証機能
プロダクト機能

ログイン
機能利用
複数のアプリがあるとそれぞれログインが必要


認証機能
プロダクトA機能




認証機能
プロダクトB機能
ログイン
機能利用

ログイン
機能利用
ユーザー課題
開発課題
-
個別ログインが面倒
-
パスワード管理が大変
-
アカウント管理も手間
-
ログイン機能を個別に開発するコスト
-
プロダクト間の連携が難しい
認証機能を単独のサービスに切り出す


認証機能

プロダクトB機能
ログイン
機能利用

機能利用


プロダクトA機能

認証基盤サービス


認証機能

プロダクトB機能
ログイン
機能利用

機能利用


プロダクトA機能

認証基盤、どう作る?

セッション管理
アクセストークン
セキュリティ
リフレッシュトークン
JWT
OIDC
SSO
ローカルストレージ
PKCE
nonce
state
IdP
SP
Auth0
Cognito
ActiveDirectory
SAML
SCIM
弊社の認証基盤で採用した
「ドメイン指定Cookie + 共有Redis」構成の
紹介をします
アジェンダ
-
認証・認可・セッション管理
-
認証共通化のアーキテクチャ選択肢
-
Leanerの認証基盤サービス構成
-
各種Tips
-
まとめ
アジェンダ
-
認証・認可・セッション管理
-
認証共通化のアーキテクチャ選択肢
-
Leanerの認証基盤サービス構成
-
各種Tips
-
まとめ
認証・認可のイメージ(郵便受取)



1. 私は黒曜です
荷物の受取をお願いします
2. 確かに正当な本人証明書でした
あなたは黒曜さんです(認証)


3. この荷物は黒曜さん宛なので
黒曜さんに渡してOK(認可)

認証・認可のイメージ(Web)

1. 私は黒曜です
パスワードはxxxです
2. 確かに正しいパスワードでした
あなたは黒曜さんです(認証)


3. 毎回本人確認を行うと大変なので、
クッキーにあなたの情報をメモしました
クッキーの中身はサーバーからしか見えないので、
以降はこのクッキーを送ってください(セッションの記録)

認証・認可のイメージ(Web)

4. ファイルにアクセスしたいです
クッキーはこれです
5. クッキーの中には「黒曜さん」が
記録されているので、
この人は黒曜さんです(セッションの復元)

6. リクエストされたファイルの持ち主は黒曜さんなので、
黒曜さんにこのファイルを送ってもOK(認可)


リアルとWebの違い
-
HTTPはステートレス
-
認証リクエストと、認可の必要なリクエストは独立
-
認証の結果をセッションに保存することで、
以降のリクエストの認可制御が行える
-
-
認証機能を切り出そうと考えると…
-
認可は切り分けて考えやすそう
-
セッション管理は密接に関連しそう
-
ここからは、
「認証結果をどうセッションに保存するか」に
フォーカスして掘り下げます
Railsの認証
Devise
# lib/devise/controllers/sign_in_out.rb
def sign_in(resource_or_scope, *args)
# ...
warden.session_serializer.store(resource, scope)
# ...
end
# lib/warden/session_serializer.rb
def store(user, scope)
return unless user
method_name = "#{scope}_serialize"
specialized = respond_to?(method_name)
session[key_for(scope)] =
specialized ? send(method_name, user) : serialize(user)
end
session['warden.user.user.key'] = [user.id, user.salt]
セッションにユーザーID(とsalt)を保存
Rails 認証ジェネレータ
# app/controllers/concerns/authentication.rb
module Authentication
def start_new_session_for(user)
user.sessions.create!.tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] =
{ value: session.id, httponly: true, same_site: :lax }
end
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id])
end
# ...
認証結果をSessionに保存し、
Cookieにsession.idを記録
CookieからSessionを復元
(Session.user でユーザーを取得できる)
基本的には User Model のidを
session[]= で保存するだけ
Rails のセッション管理
-
session[]
とsession[]=
で読み書きできる -
config.session_store
で保存方法を決定-
デフォルトは
CookieStore
-
暗号化してCookieに丸ごと保存
-
サーバー側にデータは持たない
-
-
ミドルウェア系Store(
MemCacheStore, Redis::Store
など)-
CookieにセッションIDのみを保存
-
サーバー側ミドルウェアでセッションIDをキーにデータを保存
-
-
CookieStore イメージ



私の情報はこの金庫に入ってます


鍵を使って中身を取り出しました
黒曜さんのuser.id が書いてあるので
この人は黒曜さんですね
ミドルウェア系のStoreイメージ


私の情報は16番金庫に入ってます

16番金庫からメモを取り出しました
黒曜さんのuser.id が書いてあるので
この人は黒曜さんですね


認証・認可・セッション管理 まとめ
-
認証は「本人確認」、認可は「権限確認」
-
認可は都度判断なので認証とは切り離せる
-
認証結果はセッションに保存する必要がある
-
-
セッションの保存方法は大まかに2種類
-
CookieStoreは暗号化して全部Cookieに入れる
-
ミドルウェア系Storeはサーバー側にデータを持って
取り出し用の鍵だけCookieに入れる
-
アジェンダ
-
認証・認可・セッション管理
-
認証共通化のアーキテクチャ選択肢
-
Leanerの認証基盤サービス構成
-
各種Tips
-
まとめ
そもそも認証基盤は必要?
-
ずっと1プロダクトしか作らないならまず必要ない
-
分離するコスト・保守するコストがかかる
-
責務の分割だけなら Rails Engine 切り出しや
gemを適切に使えば十分そう
-
-
ユーザ層が被る複数プロダクトを提供するならほぼ必須
-
マルチプロダクト戦略・コンパウンド戦略
-
ユーザ視点・開発視点でメリットが多い
-
認証機能を単独のサービスに切り出す(再掲)

認証機能
プロダクトB機能
ログイン
機能利用

機能利用

プロダクトA機能

課題
ログイン
機能利用

認証した結果をどう渡す?

認証機能
プロダクトA機能

セッションをそれぞれで管理する?
なんらかの方法で共通化する?
案A. 認証結果だけ渡してセッション個別管理
ログイン
ログイン結果連携
機能利用

なんらかの手段で認証結果を渡す

認証機能
プロダクトA機能

セッションは各プロダクトで個別に管理
(それぞれのプロダクトのuser.idを保存)
(OIDCはセッション管理に責務を持たないのでこれ)
案B. 前段でセッションを集約管理
1. ログイン
3. 機能利用


認証機能
プロダクトA機能

(ALBでのCognito認証連携などはこれ)

2. 認証結果をセッションに記憶
4. 認証結果を取り出してプロダクトAに一緒に送信
案C. セッションを共有 (今回の本題)
1. ログイン
4. 機能利用
(鍵を渡す)


認証機能
プロダクトA機能


2. ユーザーIDを記録

3. 鍵を渡す

5. ユーザーIDを取得
(JWTトークンは鍵自体に情報を埋め込むが、原理はこれに近い)
どれがいいの?
-
認証情報をOIDCで受け渡す形にすると技術標準に乗れる
-
…が、事前にOAuthApplication払い出しが必要だったり
state, nonce, PKCEなどセキュリティ担保のための仕様が多く複雑 -
OIDCはサードパーティーにIDを提供する目的の仕様
-
自社サービスだけで使うのにほんとにそこまで必要?
-
-
前段で受けるのはインフラ構成の制約が大きい
-
セッションをうまく共有できれば良いのでは?
-
という方針でこの後の話をしていきます
-
アジェンダ
-
認証・認可・セッション管理
-
認証共通化のアーキテクチャ選択肢
-
Leanerの認証基盤サービス構成
-
各種Tips
-
まとめ
Leanerの認証基盤サービス構成

1. ログイン
4. 機能利用
(Cookieも送信)


認証基盤
2. ユーザーsubを記録
(via. redis-session-store)
3. Cookieにsession_idを保存
5. ユーザーsubを取得
ログイン ~ ユーザーsubを記録

1. ログイン
4. 機能利用
(Cookieも送信)


認証基盤
2. ユーザーsubを記録
(via. redis-session-store)
3. Cookieにsession_idを保存
5. ユーザーsubを取得
ログイン ~ ユーザーsubを記録
-
認証: Rodauth + Rodauth Rails
-
セキュリティ強そうなのと拡張性高そうなので選定
-
パスワードログイン: Rodauth標準
-
SAMLログイン: ruby-saml で独自Featureを実装
-
-
セッション管理: redis-session-store
-
Sidekiq とか使うかなと思ってRedisを選定
-
ログイン ~ ユーザーsubを記録
# app/misc/rodauth_main.rb
class RodauthMain < Rodauth::Rails::Auth
configure do
after_login do
# save user
session[:shared_sub] = user.sub
end
end
end


# leaner:session:2::b0db908bd1428e4638...
{
"shared_sub": "ZiJkFfmqOzSuTtvMwHflJDxWD7h1",
"leaner_id_user_id": 6,
"leaner_id_active_session_id": "q_KRmXTSw3...",
"leaner_id_authenticated_by": [
"password"
]
}
Cookieの保存 ~ 送信

1. ログイン
4. 機能利用
(Cookieも送信)

2. ユーザーsubを記録
(via. redis-session-store)
3. Cookieにsession_idを保存
5. ユーザーsubを取得

認証基盤
Cookieの保存 ~ 送信

認証基盤
auth.leaner.app
mitsumori.leaner.app
認証基盤とプロダクトはURLが異なる
(後出しの制約)
Cookie は基本的に同一ドメインにしか送らないため、
認証基盤でセッションIDをCookieに格納しても
プロダクト側に送られない!
Cookieの保存 ~ 送信
# config/application.rb
Rails.application.config.session_store :redis_session_store,
key: 'LEANER_SESSION_ID',
domain: '.leaner.app',
secure: true,
same_site: :lax,
serializer: :json,
redis: {
# ...
}

Cookie に domain を指定すると、後方一致で送信するか決まる
こうすると auth.leaner.app と mitsumori.leaner.app の
両方に同じCookieを送信する
ユーザーsubを取得

1. ログイン
4. 機能利用
(Cookieも送信)


認証基盤
2. ユーザーsubを記録
(via. redis-session-store)
3. Cookieにsession_idを保存
5. ユーザーsubを取得
ユーザーsubを取得

# leaner:session:2::b0db908bd1428e4638...
{
"shared_sub": "ZiJkFfmqOzSuTtvMwHflJDxWD7h1",
"leaner_id_user_id": 6,
"leaner_id_active_session_id": "q_KRmXTSw3...",
"leaner_id_authenticated_by": [
"password"
]
}
# app/controller/application_controller.rb
class ApplicationController < ActionController::API
def current_user
User.find_by(sub: session[:shared_sub])
end
end

Leanerの認証基盤サービス構成(再掲)

1. ログイン
4. 機能利用
(Cookieも送信)


認証基盤
2. ユーザーsubを記録
(via. redis-session-store)
3. Cookieにsession_idを保存
5. ユーザーsubを取得
アジェンダ
-
認証・認可・セッション管理
-
認証共通化のアーキテクチャ選択肢
-
Leanerの認証基盤サービス構成
-
各種Tips
-
まとめ
認証基盤の実装詳細・運用など
プロダクト側で使う独自gem
LeanerAuthenticatable gem を使って
プロダクト側処理を共通化
# app/controller/application_controller.rb
class ApplicationController < ActionController::API
# 認証基盤からユーザーを取得する
include LeanerAuthenticatable.create_module(
organization_class: Organization,
user_assoc_method: 'users',
product_code: 'mitsumori',
session_key_prefix: 'mitsumori'
)
# 以下メソッドが利用できるようになる
# def current_user: () -> User
end

組織からのユーザー取得


# leaner:session:2::b0db908bd1428e4638...
{
"shared_organization_sub": "M0qMtiOCrv5...",
"shared_sub": "ZiJkFfmqOzSuTtvMwHflJDxWD7h1",
"shared_product_licenses": [
"mitsumori"
],
...
}
# app/controller/application_controller.rb
class ApplicationController < ActionController::API
# 認証基盤からユーザーを取得する
include LeanerAuthenticatable.create_module(
organization_class: Organization,
user_assoc_method: 'users',
# Organization
# .find_by(sub: session[:shared_organization_sub])
# .users.find_by(sub: session[:shared_sub])
プロダクト利用権の判定


# app/controller/application_controller.rb
class ApplicationController < ActionController::API
# 認証基盤からユーザーを取得する
include LeanerAuthenticatable.create_module(
# ...
product_code: 'mitsumori',
# session[:shared_product_licenses]
# .member?('mitsumori')
# leaner:session:2::b0db908bd1428e4638...
{
"shared_organization_sub": "M0qMtiOCrv5...",
"shared_sub": "ZiJkFfmqOzSuTtvMwHflJDxWD7h1",
"shared_product_licenses": [
"mitsumori"
],
...
}
sessionの名前空間分割

# leaner:session:2::b0db908bd1428e4638...
{
# session[:login_method] = "leaner_auth"
"mitsumori_login_method": "leaner_auth"
# shared_ から始まるキーはそのまま読み書きできる
"shared_sub": "ZiJkFfmqOzSuTtvMwHflJDxWD7h1",
...
# app/controller/application_controller.rb
class ApplicationController < ActionController::API
# 認証基盤からユーザーを取得する
include LeanerAuthenticatable.create_module(
# ...
session_key_prefix: 'mitsumori'
# class SessionStoreWrapper
# def [](key)
# parent[shared_key?(key)
# ? key : :"#{prefix}_#{key}"]
# end

gemのリリースとバージョン管理
GitHub Packagesで配信し、
プロダクト側のGemfileで依存バージョンを管理


ローカル開発用 認証基盤コンテナ
プロダクト側のローカル開発時に使えるよう、
認証基盤をコンテナ化してGitHub Packagesで配信

# docker-compose.yml
services:
auth_server:
image: ghcr.io/.../leaner-auth/leaner-auth-api:latest # <= 認証基盤
redis:
image: redis:alpine # <= session store用のredis
proxy:
image: ghcr.io/.../leaner-auth/minica-proxy:latest # <= ???
ローカル開発用 プロキシ
ローカル開発でもCookieでセッション共有できるよう
minica を使って証明書を発行、 nginx でプロキシ
https://auth.leaner.localhost
https://mitsumori.leaner.localhost
w/Cookie


認証基盤
SetCookie domain: .leaner.localhost
データの同期
認証基盤と各プロダクトでDBが異なるため、
subをキーとしてデータの同期が必要
(現在はプロダクト側にそれぞれユーザー管理がある)
情報変更時の都度同期のほか、一括同期タスクも作成

認証基盤
POST /internal/users/[:sub]
想定 Q & A
Q. CookieStoreじゃできないの?
A. secret_key依存があるので厳しい
CookieStoreではセッション値を暗号化して設定するが、
このときの暗号化鍵が Rails の secret_key_base に依存している。
すでに稼働しているプロダクトの secret_key_base を共通化するのは厳しい。
RedisStoreではセッションIDからRedis keyを計算する際
ハッシュ関数しか使わないので、どのプロダクトで計算しても同じになる。
多分他のミドルウェア系SessionStoreでも同じ感じのはず。
Q. Rodauthどう?
A. 慣れると結構良いがおすすめはしない
めちゃくちゃ柔軟性が高くカスタム処理が書きやすいが、
独自DSLでかなり慣れが必要なうえ、
ドキュメントはRDocメインで使い方例みたいなのはほとんどない。
困ったら元コードを掘るパワーが必要。
あとAIとの相性が死ぬほど悪い。
正直素直に広く使われているdeviseとかを使うほうが
AIのサポートを受けやすくて良いと思う。
Q. 既存のIDaaS使わないの?
A. 検討したけどコスパや柔軟性でやめた
正直むずかしいのは認証というよりセッション同期
SAML使うと一気に高くなり、長期的には独自でやるほうが良いと判断
実は Google Identity Platformを認証基盤化するつもりで一部採用してたが
パスワードポリシーが弄れないなど色々しんどくて諦めた
パスワードハッシュexportしたらBcryptじゃなく独自scryptで
計算するのにC buildが必要と言われてインポートを泣く泣く諦め
ログイン時の都度migrationに倒している
アジェンダ
-
認証・認可・セッション管理
-
認証共通化のアーキテクチャ選択肢
-
Leanerの認証基盤サービス構成
-
各種Tips
-
まとめ
まとめ
-
マルチプロダクト戦略だと認証基盤サービスが欲しくなる
-
実現方法はいろいろある
-
認証結果の連携はOIDCが技術標準だが、
サードパーティーを考慮していて仕様が複雑
-
-
自社サービス内だけならセッション共有がシンプルでは
-
Cookieを親レベルにすればRailsの設定レベルで実現できる
-
共通ドメインが必要など制約も多いので、ハマる局面は限られる
-
-
これが良いやり方か自分もわからないので、色々話しましょう!
ドメイン指定Cookieとサービス間共有Redisで作る認証基盤サービス
By 黒曜
ドメイン指定Cookieとサービス間共有Redisで作る認証基盤サービス
Kaigi on Rails 2025 セッションの発表資料です https://kaigionrails.org/2025/talks/kokuyouwind/#day2
- 275