Closed15
Bun+ElysiaJS+openid-clientでGitLab SSO用のOIDCクライアントを実装する
要件
- セルフホストGitLabのアカウントを利用したSSO環境に業務用のWebアプリを組み込む
- ユーザーは数人程度
- GitLabのユーザーまたはグループ単位で使用可否のみの簡易的な認可を行う
- 可能ならElysiaJSのプラグインとして実装する
実装するのは↓のOpenID Connectの部分
資料
OIDC
概念
具体的なフロー
全体的な仕様
IDトークンの仕様
response_type
の種類と仕様
クライアントの認証方式
GitLab
GitLabのIdPとしての仕様
openid-clientの Issuer.discover()
で取得したGitLabのIssuerとしてのmetadata
{
claim_types_supported: [ "normal" ],
claims_parameter_supported: false,
grant_types_supported: [ "authorization_code", "password", "client_credentials", "refresh_token"
],
request_parameter_supported: false,
request_uri_parameter_supported: true,
require_request_uri_registration: false,
response_modes_supported: [ "query", "fragment", "form_post" ],
token_endpoint_auth_methods_supported: [ "client_secret_basic", "client_secret_post"
],
issuer: "http://example.com",
authorization_endpoint: "https://example.com/oauth/authorize",
token_endpoint: "https://example.com/oauth/token",
revocation_endpoint: "https://example.com/oauth/revoke",
introspection_endpoint: "https://example.com/oauth/introspect",
userinfo_endpoint: "https://example.com/oauth/userinfo",
jwks_uri: "https://example.com/oauth/discovery/keys",
scopes_supported: [
"api", "read_api", "read_user", "create_runner", "read_repository", "write_repository", "read_observability",
"write_observability", "sudo", "admin_mode", "openid", "profile", "email"
],
response_types_supported: [ "code" ],
subject_types_supported: [ "public" ],
id_token_signing_alg_values_supported: [ "RS256" ],
claims_supported: [
"iss", "sub", "aud", "exp", "iat", "sub_legacy", "name", "nickname", "preferred_username", "email",
"email_verified", "website", "profile", "picture", "groups", "groups_direct", "https://gitlab.org/claims/groups/owner",
"https://gitlab.org/claims/groups/maintainer", "https://gitlab.org/claims/groups/developer"
],
code_challenge_methods_supported: [ "plain", "S256" ],
introspection_endpoint_auth_methods_supported: [ "client_secret_basic", "client_secret_post"
],
revocation_endpoint_auth_methods_supported: [ "client_secret_basic", "client_secret_post"
],
}
用語メモ
- IdP, OpenIDプロバイダー, OIDCプロバイダー, OpenID Connectプロバイダー, 等
- OIDCのプロセスにおける「認可・認証する側」のこと
- 本稿においてはGitLab
-
IdP
はIdentity Provider
の略
- RP, OIDCクライアント, 等
- OIDCのプロセスにおける「認可・認証される側」のこと
- 本稿で作ろうとしているもの
-
RP
はRelying Party
の略
技術スタック
- TypeScript
- Bunは直接TSを扱えるためコンパイル不要
- Bun : JavaScriptランタイム
- ElysiaJS : HTTPサーバー
- openid-client : サーバーサイドOpenID RP実装
-
LokiJS : サーバーサイドのデータ保持
- 資料: GitHub wiki, JSDoc
- Biome : Linter/Formatter
仕様の検討
- クライアントサイド(ブラウザ)に持たせるのはセッションID(一意識別子)のみ
- httpOnlyのCookieを使用する
- トークンやユーザー情報はサーバーサイドに保管する
- LokiJSを使用し
LokiFsAdapter
でファイルに保存する - セッションに有効期限を設定し、LokiJSが定期的に実行するファイル書き出しのタイミングで期限切れのものを削除する
- 極論すると止まりさえしなければたまに壊れる程度は問題ない、再度ログインすればいいだけ
- LokiJSを使用し
-
scope
はopenid
とする -
response_type
はcode
とする- 今回の用途では
id_token
で事足りるはずだがGitLabが非対応のため
- 今回の用途では
- PKCE方式を採用し
state
とnonce
はひとまず使用しない
ユーザー情報エンドポイントの使用有無
- GitLabがIDトークンに含めて返してくるクレームは以下の通り
- IDトークンクレーム(必須):
iss
sub
aud
exp
iat
- IDトークンクレーム(任意):
auth_time
- その他のクレーム(任意):
sub_legacy
name
nickname
preferred_username
profile
picture
groups_direct
- IDトークンクレーム(必須):
- GitLabは主要なユーザー情報をIDトークンに含めて返してくるため、ユーザー情報エンドポイントを叩く必要はなさそう
- GitLabにおけるユーザーの不変な一意識別子はアカウントID(整数)で、これは
sub
クレームとして返ってくる- アカウントIDは管理者であればGitLabの管理者エリアから調べることができる
- グループは
groups_direct
に配列で入ってくる
RP側エンドポイント
前提として、Webアプリの使用にはGitLabへのログインが必須なので、Webアプリにアクセスした時点で認証されていなければ問答無用でGitLabに飛ばすようにする。
(選択肢があると選択画面が必要だが今回は決め打ち)
- どこかでコケるとリダイレクトループに陥る問題がある
/auth/check
onBeforeHandle
/auth/check
- onBeforeHandleにしたことで発火タイミングがどうなるか要検証
- 認証が有効かどうかをチェックする
Cookie内のSession IDをペイロードとして受け取る必要がある
- とりあえずWebアプリを開いた際に叩く想定
- 認証が必要な場合はコールバックで戻ってきた後にもう1回叩くことになる
- ロジック
- 発火対象でなければ抜ける
- 認証系のパスやGETメソッド以外は除外
- CookieにセッションIDがあるか → なければ認証フロー
- セッションIDがDB上にあるか → なければ認証フロー
- セッションが有効期限内か → 期限外なら認証フロー
- セッションにsubフィールドがあるか → なければ認証フロー
- セッションが有効期限内なら有効期限を延長する
- リフレッシュトークンを使用して更新、失敗したら認証フロー
- 期限については
/auth/callback
参照
- 発火対象でなければ抜ける
成功時は200でsub
クレームをDBから返す失敗時(認証フローに行く場合)は401- リフレッシュは頻度を制限したり
beforeunload
で発火した方がいいかも?
/auth/login
- GET
- 認証フローの起点
- 必要な処理をしてからIdPに飛ばす
- GitLabに飛ばす際の手順
- セッションを新規作成して生成した
code_verifier
を保管-
無効な既存セッションがある場合は削除するこれはonBeforeの処理に含める
-
- OAuth2の仕様(RFC6749)によれば
A maximum authorization code lifetime of 10 minutes is RECOMMENDED
とのことなので、この時点ではセッションの有効期限を10分とする - 303でIdPに飛ばす
- セッションを新規作成して生成した
/auth/callback
- GET
-
redirect_uri
でIdPから返ってくるエンドポイント - ロジック
- Cookie/セッション関連のチェック一式
- queryパラメータを取り出し保管しておいた
code_verifier
で検証 - セッションに必要なトークンとクレームを入れる
- セッションから
code_verifier
を削除~~(不要かも?)~~ - 有効期限を再設定する
- 事実上リフレッシュトークンの有効期限として機能する
- Webアプリに302でリダイレクト
- 有効期限の検討材料
- 使用頻度としては「季節毎に数日~数週間のみ使用する」
- 30日程度が妥当か
課題1
RPのプラグイン内でCookieの処理を完結させられないか
- Webサービス側のロジックでCookieを読んでペイロードに入れてRP側に送るのはやりたくない
- Webサービスのサーバー上のロジックからRPにアクセスする場合、ブラウザを介さないのでRPは直接Cookieを取得できない → Webサービス側で中継する必要がある → Webサービス側のロジックに追記が必要
- Elysiaのライフサイクルイベントでなんとかならないか
- プラグイン側でBefore Handle用のハンドラーを用意して適宜挿入する形が適当か
- 完全に完結させるのはスコープの都合上難しそう?
- globalスコープ
.onBeforeHandle(({ as: "global" }, () => {})
が使える
- globalスコープ
- 今回は常時挿入でよさそうだけど汎用化する場合はどうするのがいいだろう
- 普通に
onBeforeScope
のスコープを変えられるようにすればよさそう - デフォルトはglobalよりscopedの方が使い勝手がいいかも?
- 普通に
型定義
セッション情報
export interface OIDCClientSession {
/** Session ID */
sessionId: string;
/** Session expired at (Unixtime, ms) */
sessionExpiredAt: number;
/** Session refreshed at (Unixtime, ms) */
sessionRefreshedAt: number;
/** OIDC Code Verifier for PKCE */
codeVerifier?: string;
/** OIDC Refresh Token */
refreshToken?: string;
/** OIDC sub Claim */
sub?: string;
}
- DB用
-
id_token
とaccess_token
は使わないのでひとまず外しておく -
sub
はクレームの保持の実験として - キャメルケースに
寄せるべき?寄せた
プラグインオプション
export interface OIDCClientOptions {
/** OpenID Provider @example "https://gitlab.com" */
issuerUrl: string;
/** Client ID */
clientId: string;
/** Client secret */
clientSecret: string;
/** Application base url @example "https://example.com" */
baseUrl: string;
/**
Path to redirect after callback is complete (use with baseUrl)
@default "/"
@example "/path/to/app"
*/
completedPath?: string;
/** Database file path @default "sessions.db" @example "/path/to/sessions.db" */
dbFile?: string;
/** Path prefix @default "/auth" */
pathPrefix?: string;
/** Login path (GET) @default "/login" */
loginPath?: string;
/** Callback path (GET) @default "/callback" */
callbackPath?: string;
/** Login expiration (ms) @default 600000 (10 minutes) */
loginExpiration?: number;
/** Refresh expiration (ms) @default 2592000000 (30 days) */
refreshExpiration?: number;
/** Refresh interval (ms) @default 1800000 (30 minutes) */
refreshInterval?: number;
/** Scope of onBeforeHandle @default "scoped" */
onBeforeScope?: LifeCycleType;
}
-
redirect_uri
は${baseUrl}${pathPrefix}${callbackPath}
で生成する-
redirect_uris
は[redirect_uri]
-
DB
let sessions: Collection<OIDCClientSession>;
const db = new loki(dbFile, {
autoload: true,
autoloadCallback: () => {
sessions = db.getCollection<OIDCClientSession>("sessions");
if (!sessions) {
sessions = db.addCollection<OIDCClientSession>("sessions", {
indices: ["sessionId", "sessionExpiredAt"],
unique: ["sessionId"],
});
}
},
autosave: true,
autosaveInterval: 10000,
autosaveCallback: () => {
sessions
.chain()
.find({ sessionExpiredAt: { $lt: Date.now() } })
.remove();
},
});
- コレクション1個
- 中身は
OIDCClientSession
の配列
- 中身は
- autoloadは非同期なので変数を先に作って入れる(LokiJSのお約束)
- 直接待つ方法がないので厳密にやるなら変数に値が入るのを待つ必要があるが、本件ではそこまでするほどではないと思われる
- 基本的にsessionIdで探すためsessionIdにインデックスを張る、もちろんunique
- 有効期限切れ判定のためにsessionExpiredAtにもインデックスを作っておく
- 保存のインターバルは標準だと5秒、とりあえず10秒にしておくがもっと長くてもよさそう
- 保存時に有効期限切れのセッションを削除する
- 削除自体は即時、削除がファイルに反映されるのは次の保存時のはず
実装
ひとまず現時点の仕様でざっくり実装。エラーハンドリングは適当。
課題2
汎用化の検討
- プラグインから取り出して使いたいものが出てくると思われるためClassにした方がよさそう
- response_typesや非PKCE、PKCE+state+nonceなどにどこまで対応するか
- ClientMetadataとAuthorizationParametersを全量受け入れる時の処理
- ユーザ情報への対応
- DBに保存する内容の指定方法
当面の目的は果たせそうなのでひとまず完了とする。
もしかしたら汎用化してプラグインとして発行するかも。
このスクラップは2024/04/04にクローズされました