2024年1月19日金曜日

ORDS 23.3で追加されたOAUTH.CREATE_JWT_PROFILEを使ってRESTサービスを保護する

Oracle REST Data Services 23.3から、サードパーティのIdPが生成したOAuth2のアクセス・トークンによる保護が実装されています。

ドキュメントの以下に説明が記載されています。

2.8 JWT Bearer Token Authentication and Authorization Using JWT Profile

保護を設定するために、OAUTH.CREATE_JWT_PROFILEを呼び出します。

Oracle APEXのアプリケーションの認証スキームをOpen ID Connectで構成して、動作を確認してみました。

認証プロバイダとしては、Okta、Microsoft Entra IDそれとOracle IDCSを試してみました。Oracle IDCSとOktaは動作させることができましたが、Microsoft Entra IDはできませんでした。Microsoft Entra IDはそもそも、ドキュメントの2.8.4 JWT Identity Provider Detailsに記載がないので、仕方ないのかもしれません。

以下より、動作確認の手順を紹介します。この保護はどのように使うものか、確認手順から理解していきます。

最初に保護の対象とするORDSのRESTサービスを作成します。このRESTサービスは、このRESTサービスを呼び出すAPEXアプリケーションとは異なるインスタンスに作成します。APEXアプリケーションと同じインスタンス(厳密にいうとワークスペース・スキーマ)のRESTサービスであれば、こちらの記事で紹介しているApex-Sessionヘッダーによる認証を使うことができます。

以下のRESTfulデータ・サービスを作成します。

モジュール名: print
モジュール・ベース・パス: /print/
URLテンプレート: env
メソッド: GET
ソース・タイプ: PL/SQL

ソースにとして以下を記述します。

最初にバインド変数current_userの値を表示しています。current_userの値はJWTのサブジェクト(属性sub)の値が設定されることになっています。また、この値はCGI変数のREMOTE_IDENTにも設定されます。

続けて、RESTサービスが受け取ったHTTPリクエストのヘッダーを出力します。
begin
    htp.p('<p>' || coalesce(:current_user, 'no_current_user') || '</p>');
    owa_util.print_cgi_env;
end;

完全なURLをブラウザで開いて、HTTPヘッダーの情報が表示されることを確認します。

認証が必要な保護がかけられていないため、バインド変数current_userはNULLになっています。同様にCGI変数REMOTE_IDENTも空白になっています。


権限を作成し、作成したRESTサービスprintを保護します。

権限の名前openidとします。権限の名前はOAuth2のアクセス・トークン(=JWT)のスコープ(属性名としてはscope)に含まれている必要があります。本来であれば独自のスコープとなる名前にすべきですが、そうするとIdPに、この権限をカスタム・スコープとして作成しなければなりません。今回はRESTサービスの保護が確認できればよいので、Open ID Connectのアクセス・トークンに必ず含まれているopenidを権限の名前にします。

タイトルは任意の文字列です。

ロールとしてRESTful Services保護されたモジュールprintを選択します。

以上の設定で権限を作成します。


再度、完全なURLにアクセスします。HTTP 401、Unauthorizedのエラーが返されます。


Oracle IDCSにアプリケーションを登録します。

OCIコンソールのアイデンティティとセキュリティよりフェデレーションを開きます。


OracleIdentityCloudServiceを開きます。


Oracle Identity Cloud Service Consoleを開きます。

このコンソールのURLに含まれるホスト部分は、Open ID Connectの検出URLやJSON Web Key (JWK) URL - JWTの署名の検証に使う公開鍵を取得するURL - のホスト部分になるため、メモしておきます。


IDCSの画面が開きます。


最初に設定デフォルト設定を開き、署名証明書へのアクセスをOracle Identity Cloud Serviceにログインせずにできるように、スイッチをオンに切り替えます。IDCSのデフォルトでは、JWK URLへのアクセスは保護されています。

Microsoft Entra IDやOktaでは、JWK URLはデフォルトで保護なしでした。


アプリケーション追加を行います。認証プロトコルにはOpen ID Connectを使います。

あらかじめAPEX側を呼び出すコールバックURLを確認しておきます。Open ID Connectの場合は末尾がapex_authentication.callbackになります。

https://ホスト名/パス/apex_authentication.callback


機密アプリケーションを選択します。


動作確認に必要な、最低限の設定を行います。

アプリケーションの名前ORDS JWT Testとします。

へ進みます。


認可許可される権限付与タイプ認可コードチェックを入れます。

リダイレクトURLを入力し、へ進みます。


リソース・サーバーの設定はスキップします。

へ進みます。


Web層ポリシーの構成もスキップします。

へ進みます。


認可についても設定せず、終了をクリックします。


アプリケーションが追加されます。クライアントIDクライアント・シークレットが表示されるので、これをコピーします。APEX側でWeb資格証明を作成するときに使用します。


作成したアプリケーションがアクティブ化されていない場合は、右端のメニューよりアクティブ化をします。


アプリケーションがアクティブ化されれば、IdP側の準備は完了です。


APEX側の作業を行います。

IDCSが発行するトークンを保存するWeb資格証明を作成します。

名前Oracle IDCS JWT Test静的IDORACLE_IDCS_JWT_TESTとします。

認証タイプとしてOAuth2クライアント資格証明フローを選択し、クライアントIDまたはユーザー名およびクライアント・シークレットまたはパスワードとして、IDCSへのアプリケーション登録時に表示されたクライアントIDクライアント・シークレットを設定します。


空のAPEXアプリケーションを作成します。名前ORDS JWT Testとします。

ホーム・ページに、IdPが返すレスポンスとREST APIのレスポンスを表示します。

ホーム・ページは以下のように表示されます。IdPが返すaccess_tokenと、REST APIのレスポンスに含まれるAuthorizationヘッダに含まれるBearerトークンは、同じ文字列になります。


作成したアプリケーションには記事「Open ID ConnectまたはOAuth2による認証の応答を印刷する」で紹介している、デバッグのための仕組みを組み込みます。ファンクションdump_signon_responseが作成済みとします。

ファンクションdump_signon_responseの出力を保存するアプリケーション・アイテムとしてG_USER_INFOを作成します。


アプリケーション定義置換置換文字列としてG_REST_URLを設定します。置換値は、別インスタンスに作成したRESTサービスprint/envを呼び出す完全なURLです。


Oracle IDCSをIdPとした認証スキームを作成します。

名前Oracle IDCSとします。スキーム・タイプソーシャル・サインインです。

設定資格証明ストアとしてOracle IDCS JWT Test認証プロバイダOpenID Connectプロバイダを選択します。

Oracle IDCSのテナント識別子を含む、以下の形式のURLを検出URLとして設定します。

https://[IDCSテナント識別子].identity.oraclecloud.com/.well-known/openid-configuration

有効範囲profileユーザー名#sub#を設定します。有効範囲はスコープのことですが、openidはデフォルトで追加されるため、有効範囲に含める必要はありません。


IdPのレスポンスをアプリケーション・アイテムG_USER_INFOに保存するコードを、ソースPL/SQLコードに記述し、ログイン・プロセス認証後のプロシージャ名post_authを設定します。
procedure post_auth is
begin
  :G_USER_INFO := dump_signon_response;
end post_auth;

以上で、Oracle IDCSをIdPとして使用する認証スキームの設定ができました。

ホーム・ページにIdPのレスポンスを表示するページ・アイテムを作成します。

名前P1_USER_INFOタイプとしてテキスト領域を選択します。外観高さ20を設定し、テキスト領域の高さを大きくしておきます。

ソースタイプアイテムを選択し、アイテムとしてG_USER_INFOを指定します。データ型CLOBを選択します。


REST APIのレスポンスを表示するリージョンを作成します。

識別タイトルREST API Responseタイプとして動的コンテンツを選択します。

ソースCLOBを返すPL/SQLファンクション本体に、以下のコードを記述します。
declare
    l_static_id varchar2(80);
    l_response clob;
begin
    /* get static id of web credentials of current authentication scheme */
    select c.static_id into l_static_id
    from APEX_APPLICATION_AUTH a join APEX_WORKSPACE_CREDENTIALS c on a.attribute_01 = c.credential_id
    where a.application_id = :APP_ID and a.is_current_authentication = 'Y';
    apex_web_service.clear_request_headers();
    l_response := apex_web_service.make_rest_request(
        p_url => :G_REST_URL
        ,p_http_method => 'GET'
        ,p_credential_static_id => l_static_id
    );
    return l_response;
end;
表示されるデータ量が多いため、テンプレート・オプションBody Heightとして320pxにしています。


以上でアプリケーションは完成です。

アプリケーションを実行すると、Oracle Cloudへのサインインが要求されます。


ユーザー認証に成功すると、作成したアプリケーションのホーム・ページが開きます。

User Infoのページ・アイテムにaccess_tokenが含まれています。REST API ResponseにはUnauthorizedのエラーが表示されています。


https://jwt.ioデバッガを開き、User Infoに含まれているaccess_tokenをデコードします。Encodedaccess_tokenの値をペーストします。

HEADERに含まれるkidは、認証がうまくいかないときに確認することがあります。


PAYLOADからOAUTH.CREATE_JWT_PROFILEの呼び出しに使用する値を見つけます。

属性issは引数p_issuerの値になります。属性audは引数p_audicenceの値になります。

今回は権限の名前openidとしているため気にする必要はありませんが、属性scopeに権限の名前が含まれていることも確認します。


引数p_jwk_urlに与える値は、認証スキームOracle IDCSの検出URLにアクセスして、レスポンスに含まれる属性jwks_uriから取得できます。

通常は以下の形式になります。

https://[IDCSテナント識別子].identity.oraclecloud.com:443/admin/v1/SigningCert/jwk


RESTサービスを実装したインスタンスのSQLコマンドを開いて、OAUTH.CREATE_JWT_PROFILEを実行します。
begin
    oauth.create_jwt_profile(
        p_issuer => 'issの値'
        ,p_audience => 'audの値'
        ,p_jwk_url => 'jwks_uriの値'
    );
    commit;
end;

OAUTH.CREATE_JWT_PROFILEを実行した後に、APEXアプリケーションのホーム・ページを再読み込みすると、JWTによる認証に成功してREST API Responseが正常に表示されます。


認証がうまくいかない場合は、引数p_jwk_urlとして与えたJWK URLを直接開いてみます。IDCSの設定署名証明書へのアクセスオンにするのを忘れていると、認証エラーが発生して公開キーの取得に失敗します。

Oracle IDCSの場合はkidが固定値のSIGNING_KEYであるため気にする必要はありませんが、他のIdPではJWKのkidと、access_tokenのヘッダーに含まれるkidが一致していることも確認すべき点です。


以上が、ORDS 23.3で追加されたOAUTH.CREATE_JWT_PROFILEを使う設定の紹介になります。

Oracle IDCSでは属性subの値がメール・アドレスであるため、current_userもメール・アドレスになりますが、IdPによってはsubの値がGUIDのような数値の場合もあります。APEXのAPP_USERは大体メール・アドレスとするため、属性emailAPP_USERにするように設定しますが、OAUTH.CREATE_JWT_PROFILEによる保護では今のところcurrent_userを属性emailの値にすることはできなさそうです。


Microsoft Entra IDでの確認


認証できなかったですが、Microsoft Entra IDでの作業を記録しておきます。

Microsoft Entra IDでアプリケーションを登録します。アプリケーションの名前ORDS JWT Testとしました。リダイレクトURIはOracle IDCSでの作業と同じ値を与えます。


アプリケーション(クライアント)IDは、APEXのWeb資格証明の作成時にクライアントIDとして与える値になります。コピーして保存しておきます。

証明書またはシークレットの追加をクリックし、シークレットを作成します。


新しいクライアントシークレットをクリックします。


説明を入力し、追加をクリックします。


作成されたシークレットは、APEXのWeb資格証明の作成時にクライアント・シークレットとして与える値になります。コピーして保存しておきます。


概要に戻り、エンドポイントを開きます。

OpenID Connectメタデータドキュメントの値をコピーして保存します。この値が、APEXアプケーションの認証スキームを作成する際の検出URLになります。


Microsoft Entra IDでの作業は以上です。

Microsoft Entra IDが発行するトークンを保存するWeb資格証明を作成します。

名前Microsoft Entra ID JWT Test静的IDMS_ENTRA_JWT_TESTとします。

認証タイプとしてOAuth2クライアント資格証明フローを選択し、クライアントIDまたはユーザー名およびクライアント・シークレットまたはパスワードとして、Microsoft Entra IDのアプリのアプリケーション(クライアント)IDシークレットを設定します。


作成済みの認証スキームOracle IDCSをコピーして、Microsoft Entra IDをIdPとする認証スキームを作成します。

共有コンポーネント認証スキームを開き、作成をクリックします。


スキームの作成として既存の認証スキームのコピーとしてを選びます。

へ進みます。


アプリケーションからコピーに、このアプリケーション(ORDS JWT Test)を選択します。

へ進みます。


コピー元Oracle IDCSコピー先Microsoft Entra IDとし、コピーはいに変更します。

スキームのコピーをクリックします。


認証スキームMicrosoft Entra IDがOracle IDCSのコピーとして作成されました。

編集画面を開いて、Microsoft Entra IDをIdPとするように変更します。


資格証明ストアを先ほど作成したWeb資格証明Microsoft Entra ID JWT Testに変更し、検出URLもMicrosoft Entra IDのOpenID Connectメタデータドキュメントの値に変更します。

有効範囲profile,emailユーザー名#email#に変更します。

以上で変更の適用を行い、その後にカレント・スキームに変更します。


以上で認証スキームをMicrosoft Entra IDを使うように切り替えました。

Oracle IDCSに紐づいたクッキーを取り消すため、ウィンドウを閉じるだけではなく、ブラウザを起動し直します。

APEXアプリケーションを実行すると、Microsoft Entra IDによるサインインが求められます。


サインインに成功すると、APEXアプリケーションの画面が開きます。

IDCSのときと同様にaccess_tokenをjwt.ioのデバッガにかけて、PAYLOADからissaudの値を取得します。


私の環境に依存しているのか不明なのですが、属性issは以下の値でした。

https://sts.windows.net/テナントID/

属性audは以下の値でした。

00000003-0000-0000-c000-000000000000

Microsort Entra IDのOpenIDメタデータカタログ検出URLから確認できるjwks_uriは以下のような形式です。しかしこのURLのレスポンスには、access_tokenのヘッダーに含まれるkidと一致するkidが含まれていません。

https://login.microsoftonline.com/テナントID/discovery/v2.0/keys

access_tokenのヘッダーに含まれるkidに一致するkidが得られるjwks_uriは以下でした。

https://sts.windows.net/テナントID/discovery/keys

JWTによる保護を切り替えます。最初に以下のコードを実行し、今までの保護を削除します。
begin
    oauth.delete_jwt_profile;
end;

Microsoft Entra IDのアクセス・トークンから得たissaudjwks_uriの値を使って、OAUTH.CREATE_JWT_PROFILEを呼び出し、JWTによる保護をかけます。


以上で設定は完了ですが、REST APIのアクセスは許可されません。

Microsoft Entra IDが生成するアクセス・トークンでは、スコープの値が属性scopeではなく、属性scpになっている点が気になります。RFC8693 4.2. "scope" (Scopes) Claimの例は以下になっています。

"scope":"email profile phone address"


Oktaでの確認


サイド・メニューのApplicationsからApplicationを開き、アプリケーションを作成します。

Create App Integrationをクリックします。


Sign-in methodOIDC - OpenID Connectを選択します。Application typeWeb Applicationを選択します。

Nextに進みます。


App integration nameORDS JWT Testとします。

Sign-in redirect URIsにはAPEXを呼び出すコールバックURLを設定します。Sign-out redirect URIsX をクリックして削除し、無設定にします。

今回は単にテストなので、AssignmentsControlled accessAllow everyone in your organization to accessを選択します。

以上でSaveします。


アプリケーションORDS JWT Testが登録されます。

Client IDSecretをコピーします。APEX側でWeb資格証明を作成するときに使います。


Oktaに場合、defaultのAuthorization Serverではアクセス・トークンのkidとJWK URLのkidが一致しませんでした。そのため、SecurityAPIよりAuthorization Serverを追加しています。


追加したAuthorization ServerNameORDSAudienceordsとしています。Metadata URIはOpenID Connectの検出URLになるため、コピーして保存しておきます。


Access Policiesのタブを開き、Add New Access Policyをクリックして、Assigned toAll Clientsとするポリシーを作成します。以下ではNameDescriptionともにAll Clientとしたポリシーを作成しています。


ほとんどすべてを許可するルールを追加しています。


以上でOkta側の準備は完了です。

APEX側でWeb資格証明を作成します。名前Okta JWT Testとします。

クライアントIDまたはユーザー名およびクライアント・シークレットまたはパスワードには、Oktaに作成したアプリケーションのClient IDSecretを設定します。


認証スキームOracle IDCSをコピーして、OktaをIdPとする認証スキームOktaを作成します。

資格証明ストアOkta JWT Test検出URLAuthorization ServerMetadata URIに変更します。以下のような形式になります。

https://[Oktaの認証サーバー]/[認証サーバーのID]/.well-known/oauth-authorization-server

変更を適用した後、カレント・スキームに変更します。


以上でOktaをIdPとした認証スキームへの切り替えが完了しました。

ブラウザを再起動し、APEXアプリケーションを実行します。

Oktaへのサインインが求められます。


APEXアプリケーションの画面が開きます。サインインには成功しているため、access_tokenは表示されますが、REST APIはUnauthorizedになります。


今までの作業と同様に、access_tokenをhttps://jwt.ioのデバッガにかけてissaudを取得します。

これらの値は、Authorization ServerAudienceIssuer URIの値でした。


Authorization ServerのMetadata URIをアクセスし、jwks_uriの値を取得します。以下のような形式になります。

https://[認証サーバーのID].okta.com/oauth2/[Authorization ServerのID]/v1/keys

これらの値を与えて、OAUTH.CREATE_JWT_PROFILEを実行します。


OktaでもREST APIは認証されません。

Oktaのアクセス・トークンのスコープも属性scpで渡されている上、スコープが空白区切りではなく、JSON配列になっています

Oktaの開発者フォーラムにscope claimを追加する方法が紹介されていました。
Scope/scp and space delimited string in access token

Authorization ServerClaimsタブを開き、Add Claimを実行します。


追加するClaimのNamescopeになります。Include into token typeAccess TokenValue typeExpressionを選択し、Valueとして以下を記述します。

String.replace(Arrays.toCsvString(access.scope),","," ")

Saveをクリックします。


上記の手順にてscopeを追加すると、REST APIの認証に成功しました。


今回の検証で使用したAPEXアプリケーションのエクスポートを以下に置きました。OpenID Connectの検出URLと呼び出すREST APIのURLは伏せ字にしているため、環境に合わせて設定し直す必要があります。
https://github.com/ujnak/apexapps/blob/master/exports/ords-jwt-test.zip

以上になります。

Oracle APEXのアプリケーション作成の参考になれば幸いです。