🕊

ElysiaJS用OpenID Connectクライアントプラグインを作った

2024/04/22に公開

https://www.npmjs.com/package/elysia-openid-client

当初の目的は「セルフホストGitLabのアカウントを使用したSSO環境に業務用のWebアプリを組み込むこと」だったが、作っている内に認証部分が汎用OIDCクライアントとして分離してしまった。

概要

  • ElysiaJSプラグイン
  • (恐らく)Bun専用npmパッケージ
    • TypeScriptファイルのみ同梱、JavaScriptにはトランスパイルしない
    • VSCodeで補完が壊れるため仕方なくtsupで型定義を出力
    • 経緯や詳細は関連記事参照
  • ESM専用
  • 大筋としてはopenid-clientのラッパー
    • ElysiaJSの機能を利用して諸々のエンドポイントとCookieベースのセッション管理機能をパッケージングしたもの
  • 複数のOP(OpenID Provider)との同時連携に対応
    • ユーザーがサービスを選んでログインできる仕組みを簡単に作れる
  • Cookie上のセッションIDを介してブラウザーとサーバーサイドのデータを紐付ける
    • 全てのトークンがサーバーサイドに隠蔽されるため比較的安全
    • Cookieは既定では httpOnly secure sameSite=lax path=/ となる
  • ElysiaJSのonBeforeHandleフックとresolveフックを使用してサーバー内部で認証・認可の情報やユーザー情報を受け渡す
    • センシティブ情報を含む場合でも表に出さなくて済む
  • サーバーサイドにおけるセッションデータの保持方法をデータアダプターによって変更可能
    • 標準でSQLite/LokiJS/Lowdb/Redis(ioredis)に対応
      • 既定ではSQLiteインメモリーモード
      • SQLiteはBunのビルトインドライバーを使用
      • 一部はインメモリー動作かファイルやデータベースで永続化するか選択可能
    • カスタムデータアダプターをフルスクラッチで作ることも可能
  • ロガーにはpinoをそのまま投入できる
    • 既定ではConsoleを使用した簡易的なロガーを使用する
    • pino互換のメソッドを用意すれば他のロガーも使用可

その他細かいことはドキュメンテーション(日本語あり)を頑張ったのでそちらを参照のこと。

https://macropygia.github.io/elysia-openid-client/index.ja.html

ランタイム/ライブラリの執筆時のバージョン
App/Package Version
Bun 1.1.3
elysia 1.0.14
openid-client 5.6.5
typescript 5.4.5
elysia-openid-client 0.1.5

OIDC RP(Relying Party)としての仕様・制限

  • Authorization Code Flow (認証コードフロー)専用
  • Confidential Client 専用
  • Client metadata:
    • client_secret 必須
    • response_types["code"] に固定される
  • Authorization parameters:
    • response_typecodeに固定される
    • response_modequery に設定するか、既定値(設定なし)である必要がある
    • code_challenge , state , nonce は自動で生成される
    • code_challenge_methodS256 に固定される
    • scope には自動で openid が追加される

動作機序

単一OPと連携する設定例を元に解説する。

single-issuer.ts
import Elysia from "elysia";
import { OidcClient } from "elysia-openid-client";

// 初期化
const rp = await OidcClient.create({
  baseUrl: "https://app.example.com", // RP(Webサイト/Webサービス)のURL
  issuerUrl: "https://issuer.exmaple.com", // OPのURL
  clientMetadata: {
    client_id: "client-id", // OPで発行する
    client_secret: "client-secret", // OPで発行する
  },
});

// OPのメタデータ出力
console.log(rp.issuerMetadata);

// プラグイン取得(↓の二つはそれぞれ個別のプラグイン)
const endpoints = rp.getEndpoints(); // エンドポイント
const hook = rp.getAuthHook(); // フック

const app = new Elysia()
  .use(endpoints) // エンドポイント適用
  .guard((app) => // この中が認証エリア
    app
      .use(hook) // フック適用
      .onBeforeHandle(({ sessionStatus, sessionClaims }) => {
        // ログイン時に得られる情報で認可を行いたい場合は更にフックを噛ませられる
        // 更にUserInfoエンドポイントを叩きに行く処理なども可
      })
      // フックの返す `sessionStatus` がnullでなければ認証済
      .get("/", ({ sessionStatus }) =>
        sessionStatus ? "Logged in" : "Restricted",
      )
      .get("/status", ({ sessionStatus }) => sessionStatus)
      .get("/claims", ({ sessionClaims }) => sessionClaims),
  )
  .get("/free", () => "Not restricted") // ここは認証不要
  .get("/logout", () => "Logout completed")
  .listen(80);
  • クライアントを初期化し、エンドポイントとフックのプラグインを取り出して適用する
  • この設定ではOPに登録するコールバックURLは https://app.example.com/auth/callback になる
  • 初期化時点でOPにアクセスしてメタデータを取得している
    • OPが対応するエンドポイントや機能、取得できる情報の内訳等が確認できる

フック

.use(hook)ElysiaJSのライフサイクルにおける onBeforeHandle に認証系の処理を挿入している。

  • このフックはguardの内側にのみ適用される、つまりguardの内側が認証エリアになる
  • 認証状態をチェックし、ログイン状態と設定によって後続の処理が分岐する
    • 既定では非ログイン状態だとLoginエンドポイントにリダイレクトされ、そこから更にOPのログイン画面にリダイレクトされる
    • ログイン状態では resolve フックが sessionStatussessionClaims に内容を入れて後続のライフサイクルに進む
    • 非ログイン状態でもリダイレクトさせない設定も可能、その場合は sessionStatussessionClaimsnull になる
  • resolveの説明に書いてある通り onBeforeHandleresolve は何度もチェーンできる
    • このプラグインの resolve の出力を自前の onBeforeHandle で受けてユーザーのグループや権限等をチェックする、といったフローが作れる

エンドポイント

.use(endpoints) で「OPのエンドポイントの使用」と「セッションデータの取得」を行う以下のエンドポイントを登録している。

  • エンドポイントは認証エリアの外に設置する必要がある
    • 中に設置するとそもそもログイン導線にアクセスできなくなる
  • パスは既定のものであり変更可能
    • /auth はインスタンス共通のprefixでその後ろがエンドポイント毎のパス
      • 複数のOPを扱う時はprefixは個別のものになる
  • ALL 表示のものは全てのメソッドで使用可能
  • 機能によってはOPが対応していない場合もある、 issuerMetadata で確認のこと

Login (GET: /auth/login )

  • openid-clientclient.authorizationUrl を呼び出す
  • OPの認証エンドポイントにリダイレクトする
    • 通常はID/パスワード入力画面に遷移する

Callback (GET: /auth/callback )

  • openid-clientclient.callbackParamsclient.callback を呼び出す
  • OPからリダイレクトされて戻ってきた後、ログイン完了ページ( callbackCompletedPath )にリダイレクトする
  • baseUrl と繋げたURLがOPに登録しておく「コールバックURL」になる
  • 個別に使用することはないはず

Logout (GET: /auth/logout )

  • openid-clientclient.endSessionUrl を呼び出す
  • OPのログアウトエンドポイントにリダイレクトする
  • OPでログアウト処理に成功するとログアウト完了ページ( logoutCompletedPath )にリダイレクトされて戻ってくる

UserInfo (ALL: /auth/userinfo )

  • openid-clientclient.userinfo を呼び出す
  • レスポンス(ユーザー情報)をそのまま返す

Introspect (ALL: /auth/introspect )

  • openid-clientclient.introspect を呼び出す
  • レスポンスをそのまま返す

Refresh (ALL: /auth/refresh )

  • openid-clientclient.refresh を呼び出す
  • ID Tokenに含まれるクレームを返す
  • ID Tokenクレームにはセンシティブ情報が含まれることがあるため仕様検討中
    • claimsエンドポイントで明示的に取得した方がいい気がしている

Resource (GET: /auth/resource?url=<resource-url>)

  • openid-clientclient.requestResource を呼び出す
  • リソースプロバイダーからのレスポンスを返す
  • (実験中)

Revoke (ALL: /auth/revoke )

  • openid-clientclient.revoke を呼び出す
  • 成功時は 204 を返す

Status (ALL: /auth/status )

  • セッションのステータスを取得する
    • フックが返す sessionStatus と同じ内容
  • OPにはアクセスしない

Claims (ALL: /auth/claims )

  • ID Tokenに含まれるクレームを取得する
    • フックが返す sessionClaims と同じ内容
  • OPにはアクセスしない

複数OPの設定例

multiple-issuer.ts
import Elysia from "elysia";
import { OidcClient } from "elysia-openid-client";
import { SQLiteAdapter } from "elysia-openid-client/dataAdapters/SQLiteAdapter";

const baseUrl = "https://app.example.com";
const dataAdapter = new SQLiteAdapter(); // データアダプターは共通

const rp1 = await OidcClient.create({
  baseUrl,
  issuerUrl: "https://issuer.exmaple.com",
  clientMetadata: {
    client_id: "client-id",
    client_secret: "client-secret",
  },
  dataAdapter, // データアダプターを指定
});
const endpoints1 = rp1.getEndpoints();

const rp2 = await OidcClient.create({
  baseUrl,
  issuerUrl: "https://another-issuer.exmaple.com",
  clientMetadata: {
    client_id: "another-client-id",
    client_secret: "another-client-secret",
  },
  dataAdapter, // 1と同じデータアダプターを指定
  settings: {
    pathPrefix: "/another", // エンドポイントが被らないようにprefixを変更
  },
});
const endpoints2 = rp2.getEndpoints();

// フックは任意の1個を使用する(どれを使ってもよい)
const hook = rp1.getAuthHook({
  loginRedirectUrl: "/select", // ユーザーがOPを選べるように選択画面に飛ばす
});

new Elysia()
  .use(endpoints1) // それぞれのエンドポイントを適用
  .use(endpoints2)
  .guard((app) =>
    app
      .use(hook) // フックは1個
      .get("/", ({ sessionStatus }) =>
        sessionStatus ? "Logged in" : "Restricted",
      )
      .get("/status", ({ sessionStatus }) => sessionStatus)
      .get("/claims", ({ sessionClaims }) => sessionClaims),
  )
  .get("/select", ({ set }) => { // OP選択画面
    set.headers["Content-Type"] = "text/html";
    return `
<html>
<body>
<p><a href="/auth/login">Issuer</a></p>
<p><a href="/another/login">Another</a></p>
</body>
</html>
    `;
  })
  .get("/free", () => "Not restricted")
  .get("/logout", () => "Logout completed")
  .listen(80);
  • 2個以上のOPと連携する場合はその分だけインスタンスが増える
  • データアダプターとフックは共通のものを使用する
  • 認証後にRPのエンドポイントを叩く場合、resolveフックやStatus/ClaimsエンドポイントからユーザーがどのOPを使用しているかを判別した上でそのOP用のエンドポイントを叩く必要がある
    • OP選択画面で行っている /auth/login/another/login の選択をセッション情報を元に自動で行うということ
    • Record<IssuerUrl, PathPrefix> を用意しておくのが妥当か(初期化にも使える)

他の設定例は気が向いたらExamplesに追加予定。

余談

  • せっかくなのでテストにはBunのビルトイン機能を使用
  • Linter/FormatterにはBiomeを使用
    • まだ荒削りな部分もあるがJS/TSのみのプロジェクトなら大丈夫そう
      • 短絡評価させたい演算子をまとめてしまったり、import文が複雑だとformatterが整形時に壊したりする
    • HTML/CSS/YAML/Markdown辺りはまだ未対応
  • 今更ながらcommitlintを導入
    • ルールはひとまず @commitlint/config-conventional
  • リリース管理にはRelease Drafterを使用
    • package.jsonveresion とReleaseで発行するtagを同期させるために四苦八苦するなどした → 関連記事
  • TypeDocの出力をGitHub ActionsでGitHub Pagesにデプロイするワークフローを導入
    • 日本語READMEを追加するためにimportして使おうとしたらBunが非対応だった
    • 幸いCLIでならビルドできるのでBun.spawnでサブプロセスからビルドして力技で合体
    • これがなかったらREADME作りで力尽きていた
  • GitHubリポジトリのcontribute導線はRenovateなどで行われているIssuesを閉じてDiscussionsに誘導する方式にした
    • 果たして使われることはあるのだろうか
  • DependabotはBunに非対応だった
    • 今回はGitHubの機能に全振りする予定なのでRenovateは使わず一旦npm-check-updatesを使ったマニュアル管理とする
  • npm publish--provenance フラグを導入
    • GitHub Actionsでデプロイしていればフラグと id-token: write の追加だけで対応できる

Discussion