Closed15

Bun+ElysiaJS+openid-clientでGitLab SSO用のOIDCクライアントを実装する

macropygiamacropygia

要件

  • セルフホストGitLabのアカウントを利用したSSO環境に業務用のWebアプリを組み込む
  • ユーザーは数人程度
  • GitLabのユーザーまたはグループ単位で使用可否のみの簡易的な認可を行う
  • 可能ならElysiaJSのプラグインとして実装する
macropygiamacropygia
macropygiamacropygia

GitLab

GitLabのIdPとしての仕様

https://docs.gitlab.com/ee/integration/openid_connect_provider.html

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"
  ],
}
macropygiamacropygia

用語メモ

  • IdP, OpenIDプロバイダー, OIDCプロバイダー, OpenID Connectプロバイダー, 等
    • OIDCのプロセスにおける「認可・認証する側」のこと
    • 本稿においてはGitLab
    • IdPIdentity Provider の略
  • RP, OIDCクライアント, 等
    • OIDCのプロセスにおける「認可・認証される側」のこと
    • 本稿で作ろうとしているもの
    • RPRelying Party の略
macropygiamacropygia

仕様の検討

  • クライアントサイド(ブラウザ)に持たせるのはセッションID(一意識別子)のみ
    • httpOnlyのCookieを使用する
  • トークンやユーザー情報はサーバーサイドに保管する
    • LokiJSを使用し LokiFsAdapter でファイルに保存する
    • セッションに有効期限を設定し、LokiJSが定期的に実行するファイル書き出しのタイミングで期限切れのものを削除する
    • 極論すると止まりさえしなければたまに壊れる程度は問題ない、再度ログインすればいいだけ
  • scopeopenid とする
  • response_typecode とする
    • 今回の用途では id_token で事足りるはずだがGitLabが非対応のため
  • PKCE方式を採用し statenonce はひとまず使用しない
macropygiamacropygia

ユーザー情報エンドポイントの使用有無

  • GitLabがIDトークンに含めて返してくるクレームは以下の通り
    • IDトークンクレーム(必須): iss sub aud exp iat
    • IDトークンクレーム(任意): auth_time
    • その他のクレーム(任意): sub_legacy name nickname preferred_username profile picture groups_direct
  • GitLabは主要なユーザー情報をIDトークンに含めて返してくるため、ユーザー情報エンドポイントを叩く必要はなさそう
  • GitLabにおけるユーザーの不変な一意識別子はアカウントID(整数)で、これは sub クレームとして返ってくる
    • アカウントIDは管理者であればGitLabの管理者エリアから調べることができる
  • グループは groups_direct に配列で入ってくる
macropygiamacropygia

RP側エンドポイント

前提として、Webアプリの使用にはGitLabへのログインが必須なので、Webアプリにアクセスした時点で認証されていなければ問答無用でGitLabに飛ばすようにする。
(選択肢があると選択画面が必要だが今回は決め打ち)

  • どこかでコケるとリダイレクトループに陥る問題がある

/auth/check onBeforeHandle

  • 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日程度が妥当か
macropygiamacropygia

課題1

RPのプラグイン内でCookieの処理を完結させられないか

  • Webサービス側のロジックでCookieを読んでペイロードに入れてRP側に送るのはやりたくない
    • Webサービスのサーバー上のロジックからRPにアクセスする場合、ブラウザを介さないのでRPは直接Cookieを取得できない → Webサービス側で中継する必要がある → Webサービス側のロジックに追記が必要
  • Elysiaのライフサイクルイベントでなんとかならないか
    • プラグイン側でBefore Handle用のハンドラーを用意して適宜挿入する形が適当か
    • 完全に完結させるのはスコープの都合上難しそう?
      • globalスコープ .onBeforeHandle(({ as: "global" }, () => {}) が使える
    • 今回は常時挿入でよさそうだけど汎用化する場合はどうするのがいいだろう
      • 普通に onBeforeScope のスコープを変えられるようにすればよさそう
      • デフォルトはglobalよりscopedの方が使い勝手がいいかも?
macropygiamacropygia

型定義

セッション情報

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_tokenaccess_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]
macropygiamacropygia

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秒にしておくがもっと長くてもよさそう
    • 保存時に有効期限切れのセッションを削除する
    • 削除自体は即時、削除がファイルに反映されるのは次の保存時のはず
macropygiamacropygia

課題2

汎用化の検討

  • プラグインから取り出して使いたいものが出てくると思われるためClassにした方がよさそう
  • response_typesや非PKCE、PKCE+state+nonceなどにどこまで対応するか
  • ClientMetadataとAuthorizationParametersを全量受け入れる時の処理
  • ユーザ情報への対応
  • DBに保存する内容の指定方法
macropygiamacropygia

当面の目的は果たせそうなのでひとまず完了とする。
もしかしたら汎用化してプラグインとして発行するかも。

このスクラップは2024/04/04にクローズされました