🦀

RustでOIDC Providerもどきを実装してテストスイートを通してみる

2021/12/19に公開約33,500字

この記事はMakuake Advent Calendar 2021 20日目の記事になります。

こんにちは。マクアケのRe-Architectureチームに所属しているY.Matsudaです(10月1日入社)。普段はMakuakeのサービス基盤の開発・運用を行っています。

今回はOIDCの実装についてのネタです。

OIDC(OpenID Connect)と言えばよく自社サービスに「Twitterでログイン」とかそういうのを組み込みたいときにClient(RP)側で使用することが多い技術ですね。

今日は、そんないつもお世話になっているOIDCのProvider側(TwitterとかFacebookとか・・・)の実装がどうなってるんだろうというところについて、実際にOIDC Provider(認可サーバー)もどきを作ってみることで理解してみようという記事になります。

なお、諸事情でRustで実装しておりますが、ご容赦ください。

対象読者

下記のような人を本記事の読者として想定しています。

  • OIDC Providerの実装の大まかな流れを知りたい人
  • いつもClient側で使っているだけだが、なんとなくProvider側の動作も気になってきた人
  • 何かしらのWebアプリケーション実装経験がある人

逆に、下記のような人は想定していません。

  • ProductionレベルのOIDC Providerの実装が知りたい人

やること・やらないこと

大枠を捉えることが今回のゴールなので、やらないことを明確にしてできるだけ簡単な実装を目指します。

やること

  • RustでOIDC Providerの機能限定版を実装する(Authorization Code Flow)
  • サーバーとしてデプロイする
  • OpenID Conformance Suite(テストスイート)Basic OPprofileを流してみる
  • あまりに機能が足りていないことが明らかになり、絶望する

やらないこと

  • 認可エンドポイントやUserinfoエンドポイント複数method対応(GETのみ実装する)
  • Clientの複数認証方式の対応(Basic認証のみ実装する)
  • DBベースのログイン処理・Userinfo実装
  • トークン期限まわりのチェック(ここは手抜きです。すいません)
  • リフレッシュトークンの実装
  • Implicit Flow, Hybrid Flowの実装

それではやっていきましょー

仕様理解

RFC

とりあえず、RFCを覗いてみます。

https://openid.net/specs/openid-connect-core-1_0.html (英語原文)
http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html (日本語翻訳)

Section1〜3.1くらいまで読んで、リンク先をつまみ食いしつつ読んでいく感じで良いかと思います(OAuth2.0のRFCに飛ぶことが多いですが、ちゃんと読んどきましょう)。

シーケンス

雑にシーケンスを起こしてみるとこんな感じです(正常系のみ)。

DB

DB設計はマイグレーションファイルをご参照ください。

まずはできたものの紹介

今回作ったソースはGithubに公開しています。

https://github.com/ymtdzzz/oidc-rs

この記事の内容を実装すると、下記のような感じで認可コードフローを実行することができるようになります!

Client登録

http://localhost:8000/client?scope=openid&response_type=code&redirect_uri=http%3A%2F%2Fexample.comにアクセス(GET)

ここで表示されるclient_idclient_secretの値をメモしておきます。

認可フローの実行

http://localhost:8000/authenticate?scope=openid&response_type=code&client_id={client_id}&redirect_uri=http%3A%2F%2Fexample.com&state=hogestateにアクセス(GET)

username: foobar, password: 1234でログイン実行すると、認可画面に飛ぶ

Acceptを押下すると、redirect_uriに設定されたhttp://example.comにパラメーターつきでリダイレクトされるので、codeをメモしておく(stateも最初のリクエスト時に指定したhogestateになっていることを確認)

アクセストークン取得

curl -X POST -H "authorization: Basic {認証情報}" -d 'grant_type=authorization_code' -d 'code={認可コード}' -d 'redirect_uri=http://example.com' http://127.0.0.1:8000/token

{"access_token":"8e410d4ae77a17c958e078d9bfe009a17fb0e9b2c9ac1e227ca0fa70d68a8dcd","token_type":"Bearer","refresh_token":null,"expires_in":3600,"id_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwOi8vZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyaWQiLCJhdWQiOiJiNmM2MWZiZDBhOTViMmQ3Y2Y2YzczYWE0NmE4M2UxN2UzN2JlNWM5OWU4MGZlZDEyNGJlYTEwZmVjM2EwMWNkIiwiZXhwIjoxNjM5ODU3NjcwLCJpYXQiOjE2Mzk4MTQ0NzAsIm5vbmNlIjoiIn0.PrY6r6vVp2Taxgkb3nyDya1JwgAMVU6jLYaymuyDU-r7aL98nszyBeRzTtQGO8QrpHLgkOOEassxJ6CmYIxI9ejdjhZrrH1L-wQ6orCml6WfCriR7-J9RwrLp7mGMx06AKKzjNEBvnwuWa1BknI9uhr-pfxWkTtWoC-b6wqpzhoYyxBWUt7z1a6QAkF7mUteShLzeVUkQEc0LnpPFwhgEEb9pFp2c2PdZKMBZDQehMX43HRUKKzFZlvNrVg4uYZQhfAJoLIrmJrtgreq8y100by8-cbJOPMSesSA2VRC4RCojYbvKUVNzuLgIWB2-ab48aZ9FwKn3AhP5jaw3s7lC56KmIil6y_m5TVTK3robsQFnhI-KgjYM3icp2fSRNDNvGa5LlbOI_9hi5IuZnUVgYJK3H6pV8OBA5MltHuM2PatNvfFKHi93G1QIPS2f6oCWlB2kC80RX6Q01CEuoMp9TZbljTnpZPG9kyCTnIgfLV7lRXkPLHx4MRL6Cq9VqJWdgNP1K0tHkBzmhQWJjB1ikGCRGbhy6cbFFACoweno5kRcG8A_XzCeV6V7L7ofhGQ_no4wBegSmfbAfHxqPvCrYXlWW7kYcnztwEp3qroZi7FOI_q0rqp8C6VCuTxnRzteMzoqpuOw24Iyz6VH60qdrtivtQ5B5X2lNi8GAI6mt0"}
  • 認証情報: {client_id}:{client_secret}をbase64エンコードした値
  • 認可コード: 先程メモしたcodeの値

id_tokenjwt.ioとかでデコードすると、たしかにちゃんとID tokenが生成されている(情報は最低限)

ユーザー情報取得

curl -X GET -H 'Authorization: Bearer {access_token}' localhost:8000/userinfo

{"sub":"userid","name":"tarou tanaka","email":"","email_verified":false,"address":{"formatted":"","street_address":"","locality":"","region":"","postal_code":"","country":""},"phone_number":"","phone_number_verified":false}

実装

動きがわかったところで、実装のご紹介です。

環境

nightlyのRustでお願いします。

$ rustup show
active toolchain
----------------

nightly-2021-11-12-aarch64-apple-darwin (default)
rustc 1.58.0-nightly (936238a92 2021-11-11)

使用ツール・開発環境

必要なツール

  • Docker
  • Rust
  • diesel cli
    • cargo install diesel_cli --no-default-features --features "mysql"

開発環境の構築

diesel migration run --database-url mysql://oidc:oidc@127.0.0.1/oidc

ライブラリ

全貌はCargo.tomlを見ていただくとして、今回使用した主要なライブラリは下記の通りです。

ライブラリ 用途
rocket Webアプリケーションフレームワーク
diesel ORM
jsonwebtoken JWT発行
serde/serde-json JSONのシリアライズ、デシリアライズ
anyhow エラーハンドリングを手軽にする(thiserrorのマブダチ)
thiserror カスタムエラーを手軽に定義する(anyhowのマブダチ)

エラーの定義

始めに、想定されそうなエラーを一通り列挙しておきます。また、rocketのRespondertraitを実装することで、エラーをレスポンスに変換する処理も書いておきます。

src/error.rs
#[derive(Debug, Error)]
pub enum CustomError {
    #[error("Database error")]
    DatabaseError(#[from] diesel::result::Error), // DB系のエラー(500)
    #[error("Bad request")]
    BadRequest, // バリデーションエラーとか(400)
    #[error("Validation error")]
    ValidationError(Template), // バリデーションで前のページに戻したいとき
    #[error("Session error")]
    SessionError, // セッションエラーで前のページに戻したいとき
    #[error("Challenge error")]
    ChallengeError, // CSRFトークンであるchallengeパラメーターの検証失敗時(エラーページ表示)
    #[error("Unauthorized error")]
    UnauthorizedError, // 認証失敗時(401)
    #[error("JWT error")]
    JWTError(#[from] jsonwebtoken::errors::Error), // JWTの発行失敗(500)
    #[error("Authentication Error")]
    AuthenticationError(ErrorAuthenticationResponse), // 認証リクエストエラー(302でredirect先のQueryパラメーターにのせる)
}

impl<'r> Responder<'r, 'static> for CustomError {
    fn respond_to(self, request: &'r rocket::Request<'_>) -> rocket::response::Result<'static> {
        // ...
    }
}

これでとりあえず失敗しそうな関数の返り値をResult<Self, CustomError>とかにしておけば、呼び出し元で?するだけでエラー発生時よしなにレスポンス返してくれるようになりました。

リクエスト・レスポンスの定義

続いて、RFCを読んでいてわかりやすい、Message系のデータを定義していきます。

Authentication(認可フロー開始)

http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#AuthRequest

認可エンドポイントに飛ばす認証リクエスト(認可フロー開始の合図)のRequestです。
仕様を見ると他にも色々パラメーターありますが、最低限にしています。

src/message/authentication.rs
/// http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#AuthRequest
pub struct AuthenticationRequest {
    scope: Scopes,
    response_type: ResponseTypes,
    client_id: String,
    redirect_uri: String,
    state: Option<String>,
    nonce: Option<String>,
}

impl AuthenticationRequest {
    // ...省略
    /// AuthenticationRequestParamからインスタンス化する関数。
    /// その際にバリデーションチェックもついでに行う。
    pub fn from(param: AuthenticationRequestParam, client: &Client) -> Result<Self, CustomError> {
        // ...
    }
}

#[derive(FromForm, Clone)]
pub struct AuthenticationRequestParam {
    pub scope: Option<String>,
    pub response_type: Option<String>,
    pub client_id: Option<String>,
    pub redirect_uri: Option<String>,
    pub state: Option<String>,
    pub nonce: Option<String>,
}

AuthenticationRequestAuthenticationRequestParamに分かれていますが、rocketの仕様把握不足で、独自型(Scopeとか)を上手くrequestからマッピングする方法がよくわからず。。

一度HTTP request自体はAuthenticationRequestParamで受けて、内部でAuthenticationRequest::from()でバリデーションがてらインスタンス化する形にしています。

シンプルにバリデーションエラーで400とかなら分離する必要無かったんでしょうが、エラー時は302でredirect先のQueryパラメーターにエラーコードのせないといけなかったので、よくわからなかった。。多分もっとマシな方法あります。すみません。

気を取り直して、Responseです。

src/message/authentication.rs
/// https://openid.net/specs/openid-connect-core-1_0.html#AuthResponse
#[derive(Serialize)]
pub struct SuccessfulAuthenticationResponse {
    next: String,
    code: String,
    state: Option<String>,
}

impl<'r> Responder<'r, 'static> for SuccessfulAuthenticationResponse {
    /// リダイレクトで返す
    fn respond_to(self, _request: &'r Request<'_>) -> rocket::response::Result<'static> {
        let mut next = format!("{}?code={}", self.next, self.code);
        if let Some(s) = self.state {
            next = format!("{}&state={}", next, s);
        }
        Response::build()
            .status(Status::Found)
            .header(Header::new(LOCATION.as_str(), next))
            .ok()
    }
}

/// エラーコードの列挙型
/// http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#AuthError
/// https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.2.1
#[derive(Debug)]
pub enum AuthorizationError {
    InvalidRequest,
    UnauthorizedClient,
    // ...
}

/// http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#AuthResponse
#[derive(Serialize, Debug)]
pub struct ErrorAuthenticationResponse {
    next: String,
    error: AuthorizationError,
    error_description: Option<String>,
    error_uri: Option<String>,
    state: Option<String>,
}

impl<'r> Responder<'r, 'static> for ErrorAuthenticationResponse {
    /// これまたリダイレクトで返す
    fn respond_to(self, _request: &'r Request<'_>) -> rocket::response::Result<'static> {
        let mut next = format!("{}?error={}", self.next, self.error);
        if let Some(desc) = self.error_description {
            next = format!("{}&error_description={}", next, desc);
        }
        if let Some(euri) = self.error_uri {
            next = format!("{}&error_uri={}", next, euri);
        }
        if let Some(s) = self.state {
            next = format!("{}&state={}", next, s);
        }
        Response::build()
            .status(Status::Found)
            .header(Header::new(LOCATION.as_str(), next))
            .ok()
    }
}

Login&Consent(ログイン&認可)

Authentication/Authorizationと区別するため、画面遷移でエンドユーザーがログイン・認可する画面はそれぞれLogin/Consentと呼ぶことにします。

http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#Authenticates
http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#Consent

ここについての仕様はRFC側であまり言及が無く、ひとまず本人認証できて、スコープに紐付いた情報をIdpから外部サービスに連携されることの認可をエンドユーザーから取得できればOKです。

とりあえず、ハリボテのログイン画面と認可画面が必要になるのでパラメーターは定義しておきます。

src/message/login.rs
/// ログインPOST時のパラメーター
#[derive(FromForm)]
pub struct LoginParams {
    pub username: String,
    pub password: String,
    pub login_challenge: String, // hidden
    pub state: Option<String>, // hidden
}

/// Cookie付きでリダイレクトするレスポンス
pub struct RedirectWithCookie {
    pub key: String,
    pub value: String,
    pub next: String,
}

impl<'r> Responder<'r, 'static> for RedirectWithCookie {
    fn respond_to(self, _request: &'r rocket::Request<'_>) -> rocket::response::Result<'static> {
        // Session情報をSet-Cookieしてリダイレクトする処理
	// ...
    }
}
src/message/consent.rs
/// 認可画面表示リクエスト時のパラメーター(GET)
#[derive(FromForm, Debug)]
pub struct ConsentGetParams {
    pub consent_challenge: String,
    pub state: Option<String>,
}

/// 認可承認時のパラメーター(POST)
#[derive(FromForm)]
pub struct ConsentParams {
    pub consent: String,
    pub consent_challenge: String,
    pub state: Option<String>,
}

Token

認可エンドポイントから受け取った認可コードからアクセストークンを取得する際に使用されるRequest/Responseを定義します。

http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#TokenEndpoint
src/message/token.rs
/// リクエストに含まれるBasic認証情報を定義
pub struct Basic {
    pub client_id: String,
    pub client_secret: String,
}

#[async_trait]
impl<'r> FromRequest<'r> for Basic {
    type Error = anyhow::Error;

    async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
        // リクエストのAuthorizationヘッダーからBasic認証情報をデコードして取得
	// ...
    }
}

/// Token Endpointへのリクエスト(POST)
/// http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#TokenRequest
#[derive(FromForm)]
pub struct TokenRequest {
    grant_type: GrantType,
    code: String,
    redirect_uri: String,
}

/// Token Endpointの成功レスポンス
/// http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#TokenResponse
#[derive(Serialize)]
pub struct SuccessfulTokenResponse {
    pub access_token: String,
    pub token_type: String,
    pub refresh_token: Option<String>,
    pub expires_in: u64,
    pub id_token: String,
}

/// Token Endpointのエラーレスポンス
/// http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#TokenErrorResponse
#[derive(Serialize)]
pub struct ErrorTokenResponse {
    pub error: TokenError,
}

/// http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#IDToken
#[derive(Serialize, Deserialize)]
pub struct IdToken {
    pub iss: String,
    pub sub: String,
    pub aud: String,
    pub exp: usize,
    pub iat: usize,
    pub nonce: String,
}

/// https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2
pub enum TokenError {
    InvalidRequest,
    InvalidClient,
    InvalidGrant,
    UnauthorizedClient,
    UnsupportedGrantType,
    InvalidScope,
}

// ...

Userinfo

Token Endpointから取得したアクセストークンからUserinfoを取得する際に使用されるRequest/Responseを定義します。

http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#UserInfo
src/message/userinfo.rs
/// Userinfo Endpointへのリクエスト(GET)
/// http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#UserInfoRequest
pub struct UserinfoRequest {
    pub bearer: String,
}

#[async_trait]
impl<'r> FromRequest<'r> for UserinfoRequest {
    type Error = anyhow::Error;

    async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
        // AuthorizationヘッダーからBearerトークンを取得
    }
}

/// http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#AddressClaim
#[derive(Serialize)]
pub struct Address {
    pub formatted: String,
    pub street_address: String,
    pub locality: String,
    pub region: String,
    pub postal_code: String,
    pub country: String,
}

/// http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#UserInfoResponse
#[derive(Serialize)]
pub struct SuccessfulUserinfoResponse {
    pub sub: String,
    pub name: String, // sample data
    pub email: String,
    pub email_verified: bool,
    pub address: Address,
    pub phone_number: String,
    pub phone_number_verified: bool,
}

/// http://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#UserInfoError
#[derive(Serialize)]
pub struct UserinfoErrorResponse {
    pub error: String,
    pub error_description: String,
}

Client

Clientの登録方法は何でも良いので、今回GETとかで必要事項を送信して、レスポンスでclient_idclient_secretを返すAPIを作ります。

そのためのRequestを定義しておきます。

src/message/client.rs
#[derive(FromForm)]
pub struct ClientParams {
    pub scope: String,
    pub response_type: String,
    pub redirect_uri: String,
}

Repository/Modelの定義

Messageの定義が完了したので、DBアクセスに用いられるRepositoryとModelを定義しておきます。

Model

ORMで使用される、各テーブルを構造体として定義したものです。
ORMは、クエリの結果をModelにマッピングして返してくれます(逆も然り)。

基本的にテーブルの内容をそのままおこしただけなので、特筆すべき点のみ記載します。
(ソースコード全文はsrc/models.rsを参照)

src/models.rs
impl Client {
    pub fn check_scopes(&self, scopes: &Scopes) -> anyhow::Result<()> {
        let s = Scopes::from_str(&self.scope)?;
        for scope in &scopes.scopes {
            if !s.scopes.contains(scope) {
                return Err(anyhow::anyhow!("invalid scope"));
            }
        }
        Ok(())
    }

    pub fn check_restypes(&self, restypes: &ResponseTypes) -> anyhow::Result<()> {
        let r = ResponseTypes::from_str(&self.response_type)?;
        for restype in &restypes.types {
            if !r.types.contains(restype) {
                return Err(anyhow::anyhow!("invalid response type"));
            }
        }
        Ok(())
    }
}

認証リクエストに含まれるscoperesponse_typeは、Client登録時に許可されたものでないといけない(RFCはこちら)ため、そのバリデーションを行う処理を定義しておきます。

今回Service層などは特に定義しないので、Modelでやっちゃっています。

Repository

こちらも各テーブルへのCRUD処理を実装しただけなので、Githubのソースコードをご参照ください。

View(template)の定義

画面についてはteraというテンプレートエンジンに対応した形式で記述することとします(rocketが標準で対応)。

必要な画面は下記の通りです。

  • ログイン画面
  • 認可画面
  • エラー画面(エラーが発生した場合にビューを返す際に使用)

templateはtemplates/配下に配置します。

ログイン画面

templates/login.html.tera
<html>
  {% if error_msg %}
    <font color="red">{{ error_msg }}</font>
  {% endif %}
  <form action="/authenticate" method="POST">
    <label for="username">username</label>
    <input name="username" id="username" value="">
    <label for="password">password</label>
    <input name="password" id="password" type="password" value="">
    <input name="login_challenge" type="hidden" value="{{ login_challenge }}">
    {% if state %}
      <input name="state" type="hidden" value="{{ state }}">
    {% endif %}
    <p>username: foobar, password: 1234</p>
    <button type="submit">Login</button>
  </form>
</html>

いくつかplaceholderを定義しています。

  • error_msg: バリデーションエラーメッセージを表示
  • login_challenge: provider側のCSRF対策で使用されるlogin_challengeパラメーターをhiddenに入れる
  • state: client側のCSRF対策で使用されるstateパラメーターをhiddenに入れる

認可画面

templates/consent.html.tera
<html>
  <form action="/authorization" method="POST">
    <p>TODO: scopes checkbox</p>
    <input name="consent_challenge" type="hidden" value="{{ consent_challenge }}"><br>
    {% if state %}
      <input name="state" type="hidden" value="{{ state }}"><br>
    {% endif %}
    <button name="consent" type="submit" value="ok">Accept</button>
  </form>
</html>

こちらもplaceholderがあります。

  • consent_challenge: provider側のCSRF対策で使用されるconsent_challengeパラメーターをhiddenに入れる
  • state: client側のCSRF対策で使用されるstateパラメーターをhiddenに入れる

エラー画面

templates/error.html.tera
<html>
  <font color="red">{{ error_msg }}</font>
</html>

placeholderはerror_msgのみで、何かエラー画面を表示したいときにとりあえずこれをレンダリングしときます。

handler/routerの定義

諸々要素が出揃ったところで、最後にメインとなるhandler/routerの実装をしていきます。

例によってソースコード全文はsrc/server.rsをご参照ください。

router定義

紙面の都合上先にrouterの定義を見ておきます。

src/server.rs
#[launch]
pub fn run() -> _ {
    let db: Map<_, Value> = map! {
        "pool_size" => 10.into(),
    };
    let figment = rocket::Config::figment().merge(("databases", map!["oidc_db" => db]));

    rocket::custom(figment)
        .mount(
            "/",
            routes![
                index,
                get_client,
                get_authenticate,
                post_authenticate,
                get_authorization,
                post_authorization,
                post_token,
                get_userinfo,
            ],
        )
        .attach(DBPool::fairing())
        .attach(Template::fairing())
}
        .attach(DBPool::fairing())
        .attach(Template::fairing())

attach()により、DBのコネクションプールとTemplateを各handlerで適切なタイミングで使えるようになります。

以下、handlerの定義です。一部src/utils.rsの関数を使用しています(generate_challenge()など)。

Client登録

src/server.rs
#[get("/client?<clientparam..>")]
async fn get_client(
    clientparam: Option<ClientParams>,
    conn: DBPool,
) -> Result<String, CustomError> {
    conn.run(move |c| match clientparam {
        Some(param) => {
            let client_id = generate_challenge();
            let client_secret = generate_challenge();
            repository::create_client(
                Client {
                    client_id: client_id.clone(),
                    client_secret: client_secret.clone(),
                    scope: param.scope,
                    response_type: param.response_type,
                    redirect_uri: param.redirect_uri,
                },
                c,
            )?; // エラーにResponder traitが実装されているのでそのままreturnできる
            Ok(format!(
                "client_id: {}, client_secret: {}",
                client_id, client_secret
            ))
        }
        None => Err(CustomError::BadRequest),
    })
    .await
}

認可エンドポイント - 認証リクエスト

認可エンドポイントの認証リクエストを実装します。

src/server.rs
#[get("/authenticate?<authparam..>")]
async fn get_authenticate(
    authparam: AuthenticationRequestParam,
    conn: DBPool,
) -> Result<Template, CustomError> {
    conn.run(move |c| {
        let client =
            repository::find_client(&authparam.clone().client_id.unwrap_or("".to_string()), c)?;
        let authparam = AuthenticationRequest::from(authparam, &client)?;
        let state = authparam.state().clone();
        let challenge = generate_challenge();
        repository::create_auth_challenge(
            AuthChallenge::from_auth_request(&challenge, authparam),
            c,
        )?;
        Ok(Template::render(
            "login",
            &LoginContext {
                error_msg: None,
                login_challenge: challenge,
                state,
            },
        ))
    })
    .await
}

ここでは以下の処理を行います。

  • パラメーターのバリデーション
  • challengeの生成&リクエストの保存
  • ログイン画面の返却

ログイン

ログイン画面からPOSTされた際の処理を実装します。

src/server.rs
#[post("/authenticate", data = "<loginparam>")]
async fn post_authenticate(
    loginparam: Form<LoginParams>,
    conn: DBPool,
) -> Result<RedirectWithCookie, CustomError> {
    conn.run(move |c| {
        if find_auth_challenge(&loginparam.login_challenge, c).is_err() {
            // エラー画面の返却
        }
        if loginparam.username == "foobar".to_string() && loginparam.password == "1234" {
            let session_id = generate_challenge();
            create_session(
                Session {
                    session_id: session_id.clone(),
                },
                c,
            )?;
            let mut next = format!(
                "/authorization?consent_challenge={}",
                &loginparam.login_challenge
            );
            if let Some(s) = &loginparam.state {
                next = format!("{}&state={}", next, s);
            }
            Ok(RedirectWithCookie {
                key: String::from("session_id"),
                value: session_id,
                next,
            })
        } else {
            // エラー画面の返却
        }
    })
    .await
}

ここでは以下の処理を行います。

  • challengeが一致することを確認(CSRF対策)
  • ログイン認証を実行(今回はダミー処理)
  • 認可画面へのリダイレクトレスポンス返却

認可

認可画面表示のGETリクエスト処理を実装します。

src/server.rs
#[get("/authorization?<consentgetparam..>")]
async fn get_authorization<'a>(
    consentgetparam: Option<ConsentGetParams>,
    jar: &'a CookieJar<'_>,
    conn: DBPool,
) -> Result<Template, CustomError> {
    let mut session_id: Option<String> = None;
    if let Some(session) = jar.get("session_id") {
        session_id = Some(session.value().to_string());
    }
    conn.run(move |c| {
        let mut is_session_error = false;
        // login check
        if let Some(id) = session_id {
            if find_session(&id, c).is_err() {
                is_session_error = true;
            }
        } else {
            is_session_error = true;
        }
        if is_session_error {
            return Err(CustomError::SessionError);
        }
        // challenge check
        match consentgetparam {
            Some(param) => {
                if find_auth_challenge(&param.consent_challenge, c).is_err() {
                    return Err(CustomError::ChallengeError);
                }
                Ok(Template::render(
                    "consent",
                    &ConsentContext {
                        consent_challenge: param.consent_challenge,
                        state: param.state,
                    },
                ))
            }
            None => Err(CustomError::BadRequest),
        }
    })
    .await
}

ここでは以下の処理を行います。

  • sessionの確認(未ログインならエラー画面返却)
  • challengeが一致することを確認
  • 認可画面の返却

また、認可画面でボタン押下時に飛ぶ認可承諾POSTリクエストの処理も実装します。

src/server.rs
#[post("/authorization", data = "<consentparam>")]
async fn post_authorization<'a>(
    consentparam: Form<ConsentParams>,
    jar: &'a CookieJar<'_>,
    conn: DBPool,
) -> Result<SuccessfulAuthenticationResponse, CustomError> {
    // セッション取得
    // ...省略(GETの方と同じ)
    conn.run(move |c| {
        let mut is_session_error = false;
        // login check
        // ...省略(GETの方と同じ)
        // challenge check
        // ...省略(GETの方と同じ)
        let challenge = challenge.unwrap();
        let auth_code = generate_challenge();
        create_auth_code(
            AuthCode {
                code: auth_code.clone(),
                client_id: challenge.client_id.clone(),
                user_id: String::from("userid"), // dummy user id
                scope: challenge.scope.clone(),
                nonce: challenge.nonce.unwrap_or("".to_string()),
            },
            c,
        )?;
        Ok(SuccessfulAuthenticationResponse::new(
            &challenge.redirect_uri,
            &auth_code,
            &consentparam.state,
        ))
    })
    .await
}

ここでは以下の処理を行います。

  • sessionの確認(未ログインならエラー画面返却)
  • challengeが一致することを確認
  • 認可コードの発行&保存
  • SuccessfulAuthenticationResponseをリダイレクトレスポンスとして返却

Tokenエンドポイント

Token Requestの処理を実装します。

src/server.rs
#[post("/token", data = "<tokenparam>")]
async fn post_token(
    tokenparam: Form<TokenRequest>,
    basic: Basic,
    conn: DBPool,
) -> Result<Json<SuccessfulTokenResponse>, CustomError> {
    conn.run(move |c| {
        // check auth code
        let auth_code = repository::find_auth_code(tokenparam.code(), c)?;
        let client = repository::find_client(&auth_code.client_id, c)?;
        // check client credential
        if basic.client_id != client.client_id || basic.client_secret != client.client_secret {
            return Err(CustomError::UnauthorizedError);
        }
        let access_token = generate_challenge();
        repository::create_token(
            NewToken {
                access_token: access_token.clone(),
                user_id: auth_code.user_id,
                scope: auth_code.scope,
            },
            c,
        )?;
        let now = Utc::now();
        let exp = now + Duration::hours(12);
        let claim = IdToken {
            iss: "http://example.com".to_string(),
            sub: "userid".to_string(), // dummy id
            aud: auth_code.client_id,
            exp: exp.timestamp() as usize,
            iat: now.timestamp() as usize,
            nonce: auth_code.nonce,
        };
        let jwt_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
        let id_token = jsonwebtoken::encode(
            &jwt_header,
            &claim,
            &jsonwebtoken::EncodingKey::from_rsa_pem(include_bytes!("private-key.pem")).unwrap(),
        )?;
        Ok(Json(SuccessfulTokenResponse {
            access_token,
            token_type: "Bearer".to_string(),
            refresh_token: None,
            expires_in: 3600,
            id_token,
        }))
    })
    .await
}

ここでは以下の処理を行います。

  • 認可コードのチェック(本来は有効期限のチェックなども行います)
  • Basic認証情報のチェック
  • トークン情報の生成&保存
  • IDトークン生成
  • Jsonでレスポンス返却

なお、IDトークン生成時に指定しているJWTキーの生成方法については後述します。

Userinfoエンドポイント

最後に、Userinfo Requestの処理を実装します。

src/server.rs
#[get("/userinfo")]
async fn get_userinfo(
    inforeq: UserinfoRequest,
    conn: DBPool,
) -> Result<Json<SuccessfulUserinfoResponse>, CustomError> {
    conn.run(move |c| {
        let token = repository::find_token(&inforeq.bearer, c)?;
        if token.is_valid() && token.access_token == inforeq.bearer {
            let scopes = Scopes::from_str(&token.scope).unwrap();
            let default_address = Address {
                formatted: "".to_string(),
                // ...
            };
            let mut res = SuccessfulUserinfoResponse {
                sub: "userid".to_string(),
                // ...
            };
            for s in scopes.scopes.iter() {
                match s {
                    &Scope::Phone => {
                        res.phone_number = String::from("111-1234-5678");
                        res.phone_number_verified = true;
                    }
                    // ...
                    _ => {}
                }
            }
            return Ok(Json(res));
        }
        Err(CustomError::UnauthorizedError)
    })
    .await
}

ここでは以下の処理を行います。

  • アクセストークン検証
  • scopeに合わせてユーザー情報を取得(今回はダミー情報をセット)
  • Jsonでレスポンス返却

JWT用の鍵ペア作成&JWKアップロード

JWTの署名に使用した鍵ペアについては、opensslで生成します。

openssl genrsa -out private-key.pem 4096
openssl rsa -in private-key.pem -pubout -out public-key.pem

このコマンドを実行すると、カレントディレクトリにprivate-key.pempublic-key.pemが出力されます。アルゴリズムはRS256です。

生成されたprivate-key.pemsrc/に配置すればOKです。

※Gitにコミットしないよう注意してください

また、JWKについても作成してS3のpublicバケットに配置しておきます。

sh make_jwk.sh
aws s3 cp ./jwks.json s3://{作成したバケット名}/jwks.json

なお、make_jwk.shを実行するためにはpem-jwkが必要ですので、npm install -g pem-jwkとかでインストールしておきます。

動作確認

docker-compose up -d && cargo runで動作確認します。

動作確認結果は冒頭の「# まずはできたものの紹介」をご参照ください。

デプロイ

デプロイ方法は自由ですが、ECS Fargateを使用した方法を軽くご紹介します。

MySQLと本体のWebサーバーをそれぞれコンテナ化して、それをFargateの同一タスクとして起動して疎通する形がお手軽でした(デプロイの度にDBリセットされますが、動作確認程度なら問題無い)。

Dockerコンテナのビルド

Dockerfileについてはリポジトリ参照(Dockerfile/Dockerfile.mysql)。

docker build -t oidc-test-app .
docker build . -f ./Dockerfile.mysql -t oidc-test-db

※ビルド時、COPYprivate-key.pemをコンテナに内包する形にしていますが、publicリポジトリだったり商用環境の場合はKMS等を使用することを推奨します。

ECRへのPush

※ECRのリポジトリは各自作成

aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin xxxxxxxxxxxx.dkr.ecr.us-east-1.amazonaws.com
# WebサーバーのコンテナPush
docker tag oidc-test-app:latest xxxxxxxxxxxx.dkr.ecr.us-east-1.amazonaws.com/oidc-test-app:latest
docker push xxxxxxxxxxxx.dkr.ecr.us-east-1.amazonaws.com/oidc-test-app:latest
# MySQLのコンテナPush
docker tag oidc-test-db:latest xxxxxxxxxxxx.dkr.ecr.us-east-1.amazonaws.com/oidc-test-db:latest
docker push xxxxxxxxxxxx.dkr.ecr.us-east-1.amazonaws.com/oidc-test-db:latest

ネットワーク(VPC)作成

  • 適当なVPCを作成
  • 適当なサブネットを作成
  • Internet Gatewayをアタッチ
  • セキュリティグループのインバウンドルールに以下の穴を開ける
    • 自分のIPの3306ポート
    • 全てのIPの80ポート

※テスト用なので直接疎通できるようにしちゃってます

クラスタ作成&タスク定義

  • 適当なクラスタ作成
  • タスク定義作成
    • 一つのタスク定義内にoidc-test-dboidc-test-appのコンテナを同居させる
    • ポートマッピングは特に不要なハズ

タスク実行

ネットワークは先程作成したものを指定(セキュリティグループも同様)。

デプロイして、両方RunningになればOK

migration

3306を開けておいたので直接migration実行できます。
(実際はプライベートサブネットに配置して踏み台経由とかにしましょう)

diesel migration run --database-url mysql://oidc:oidc@xxx.xxx.xxx.xxx/oidc
# Running migration 2021-12-01-124326_auth_challenges
# Running migration 2021-12-02-233221_session
# Running migration 2021-12-04-044410_auth_code
# Running migration 2021-12-04-051844_client
# Running migration 2021-12-04-092738_tokens

動作確認

URLがhttp://{public ip}になるだけで、ローカルで動作確認するときと同じです!

テストスイート実行

それではいよいよテストスイートを実行してみます。

テスト設定

ログインページはこちら。

https://www.certification.openid.net/login.html

ログイン後、「Create a new test plan」を選択してテスト設定のページに遷移します。

設定例はこんな感じ。

テスト実行

プラン作成ができたら、「Run test」でそれぞれのテストを実行します。

テストが始まるとログイン画面へのリダイレクトで操作待ちになるので、URLをクリックしてログインを実行します(username: foobar, password: 1234)。また、認可もAcceptを押下します。

リダイレクト先がテストスイート側に準備されたURLになっており、そこまで来た時点でおそらくテスト完了になってるかと思います。

テストページ見てみると、PASSしていました。

こんな感じで各テストケースひたすら流していきます(毎回手動でログインするのがめんどくさい...)

テスト結果

テスト結果こんな感じになりました。

半分PASSしており、あとはERRORとWARNINGがちらほらといった感じでした。

手抜き&ガバ実装なので当然の結果と言えます。

とはいえ、基本的なフローは問題無く通っており、あとは細かい部分をRFC通りに実装すればちゃんとしたOIDC Providerが作成できそうです!

また、今回作成したフローは認可コードフローのみですが、これを応用すればImplicit Flow、Hybrid Flowについても実装できそうです。

まとめ

かなり思いつきでガリガリ書き始めてしまいましたが、実際に実装、デプロイ、テストスイート実行してみることで、OIDCの実装についての理解が深まった気がします!

ProductionレベルのOIDC Providerを実装する場合は、これよりもはるかに多くの考慮すべき点が出てきますが、ベースとなる流れを理解できたので、スムーズに設計&実装に入ることができそうです。

最後に、マクアケでは様々な技術領域にチャレンジしたいエンジニアを募集しています!
ご興味のある方はぜひご連絡ください!

https://www.wantedly.com/companies/makuake/projects

Discussion

ログインするとコメントできます