diff --git a/.gitignore b/.gitignore index 8343a32..7fe8bd0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ .settings/ target/ -local.authlete.properties +.DS_Store .classpath .project - +local.authlete.properties +local.federations.json +nohup.out +.idea/ +java-oauth-server.iml +*~ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ad65111 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM maven:3.9-eclipse-temurin-25 +EXPOSE 8080 + +RUN mkdir -p /authlete/app + +ADD . /authlete/app + +WORKDIR /authlete/app + +RUN mvn -s /usr/share/maven/ref/settings-docker.xml clean install && \ + # Import the root certificate of Open Banking Brasil Sandbox + certs/import-certificate.sh certs/Open_Banking_Brasil_Sandbox_Root_G2.pem + +CMD ["mvn", "-s", "/usr/share/maven/ref/settings-docker.xml", "clean", "jetty:run"] diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..5a81245 --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,25 @@ +# Production-friendly Dockerfile with multi-stage build and decent layer caching + +FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-25 AS builder + +WORKDIR /build +COPY pom.xml . +RUN mvn -Dmaven.test.skip=true -Dmaven.javadoc.skip=true dependency:go-offline +COPY src/ /build/src/ +RUN mvn -Dmaven.test.skip=true -Dmaven.javadoc.skip=true package + + +FROM jetty:12.1.10-jdk25-eclipse-temurin + +USER root +COPY certs/ certs/ +RUN certs/import-certificate.sh certs/Open_Banking_Brasil_Sandbox_Root_G2.pem +USER jetty + +# Jetty 12 deploys WARs through an EE environment module. This app targets +# Jakarta EE 10 (Servlet 6.0 / Jersey 3.1), so enable the ee10 deployer, +# annotation scanning and JSP support (used by the Jersey MVC pages). +RUN java -jar "$JETTY_HOME/start.jar" --add-modules=ee10-deploy,ee10-annotations,ee10-jsp + +ENV JAVA_OPTIONS="$JAVA_OPTIONS -Djetty.httpConfig.requestHeaderSize=65536" +COPY --from=builder /build/target/*.war /var/lib/jetty/webapps/ROOT.war diff --git a/README.ja.md b/README.ja.md index a6f9b09..0b94c61 100644 --- a/README.ja.md +++ b/README.ja.md @@ -6,13 +6,20 @@ [OAuth 2.0][1] と [OpenID Connect][2] をサポートする認可サーバーの Java による実装です。 -この実装は JAX-RS 2.0 API と [authlete-java-jaxrs][3] ライブラリを用いて書かれています。 -JAX-RS は _The Java API for RESTful Web Services_ です。 JAX-RS 2.0 API は -[JSR 339][4] で標準化され、Java EE 7 に含まれています。 一方、authlete-java-jaxrs -は、認可サーバーとリソースサーバーを実装するためのユーティリティークラス群を提供するオープンソースライブラリです。 -authlete-java-jaxrs は [authlete-java-common][5] ライブラリを使用しており、こちらは +この実装は Jakarta RESTful Web Services API (_Jakarta REST_、旧称 JAX-RS) と +[authlete-java-jakarta][3] ライブラリを用いて書かれています。 Jakarta REST は +[Jakarta EE][4] の一部です。 この認可サーバーは **Jakarta EE 10** スタック +(Jakarta REST 3.1 / Servlet 6.0、Jersey 3.1) を対象とし、**Java 25** でビルド・実行されます。 +一方、authlete-java-jakarta は、認可サーバーとリソースサーバーを実装するためのユーティリティークラス群を提供するオープンソースライブラリです。 +authlete-java-jakarta は [authlete-java-common][5] ライブラリを使用しており、こちらは [Authlete Web API][6] とやりとりするためのオープンソースライブラリです。 +> **注:** この認可サーバーは以前、レガシーな Java EE スタック +> (JAX-RS 2.0 / `javax.*`、Jersey 2、Servlet 3、Java 8) の上に構築されていました。 +> 現在は **Jakarta EE 10** (`jakarta.*`) に移行しており、Tomcat 10+ や Jetty 12 などの +> 最新のサーブレットコンテナにそのままデプロイできます。 また、デフォルトの Authlete API +> バージョンも **Authlete 3.0** になりました (「設定ファイル」の節を参照)。 + この実装は「DB レス」です。 これの意味するところは、認可データ (アクセストークン等) や認可サーバー自体の設定、クライアントアプリケーション群の設定を保持するためのデータベースを用意する必要がないということです。 これは、[Authlete][7] をバックエンドサービスとして利用することにより実現しています。 @@ -20,8 +27,7 @@ authlete-java-jaxrs は [authlete-java-common][5] ライブラリを使用して この認可サーバーにより発行されたアクセストークンは、Authlete をバックエンドサービスとして利用しているリソースサーバーに対して使うことができます。 [java-resource-server][40] はそのようなリソースサーバーの実装です。 -[OpenID Connect Core 1.0][13] で定義されている[ユーザー情報エンドポイント][41]をサポートし、 -保護リソースエンドポイントの実装例も含んでいます。 +保護リソースエンドポイントの実装例を含んでいます。 ライセンス @@ -29,6 +35,11 @@ authlete-java-jaxrs は [authlete-java-common][5] ライブラリを使用して Apache License, Version 2.0 + `src/main/resources/ekyc-ida` 以下の JSON ファイル群は + https://bitbucket.org/openid/ekyc-ida/src/master/examples/response/ + からコピーしたものです。それらのライセンスについては、OpenID Foundation の + eKYC-IDA ワーキンググループにお尋ねください。 + ソースコード ------------ @@ -65,29 +76,53 @@ API クレデンシャルズを取得する手順はとても簡単です。 $ vi authlete.properties -3. [http://localhost:8080][38] で認可サーバーを起動します。 +3. [maven][42] と **JDK 25** (以降) がインストールされていること、 `JAVA_HOME` が適切に設定されていることを確認します。 + +4. [http://localhost:8080][38] で認可サーバーを起動します。 $ mvn jetty:run & +#### Docker を利用する + +Docker を利用する場合は, ステップ 2 の後に以下のコマンドを実行してください. + + $ docker-compose up + +#### 設定ファイル + `java-oauth-server` は `authlete.properties` を設定ファイルとして参照します。 他のファイルを使用したい場合は、次のようにそのファイルの名前をシステムプロパティー `authlete.configuration.file` で指定してください。 $ mvn -Dauthlete.configuration.file=local.authlete.properties jetty:run & +デフォルトでは `authlete.properties` は **Authlete 3.0** (API `V3`) 向けに設定されています。 +サービスのクラスタの `base_url` (例: `https://jp.authlete.com`)、`service.api_key`、 +および `service.access_token` を設定してください。 Authlete 2.x を引き続き使用する場合は、 +ファイル内にコメントアウトされた「Authlete 2.x (legacy)」ブロックがあり、そちらに切り替えられます +(`https://api.authlete.com` 上で API キー + API シークレットを使用)。 エンドポイント -------------- この実装は、下表に示すエンドポイントを公開します。 -| エンドポイント | パス | -|:-----------------------|:------------------------------------| -| 認可エンドポイント | `/api/authorization` | -| トークンエンドポイント | `/api/token` | -| JWK Set エンドポイント | `/api/jwks` | -| 設定エンドポイント | `/.well-known/openid-configuration` | -| 取り消しエンドポイント | `/api/revocation` | +| エンドポイント | パス | +|:-----------------------------------------------|:----------------------------------------| +| 認可エンドポイント | `/api/authorization` | +| トークンエンドポイント | `/api/token` | +| JWK Set エンドポイント | `/api/jwks` | +| ディスカバリーエンドポイント | `/.well-known/openid-configuration` | +| 取り消しエンドポイント | `/api/revocation` | +| イントロスペクションエンドポイント | `/api/introspection` | +| ユーザー情報エンドポイント | `/api/userinfo` | +| 動的クライアント登録エンドポイント | `/api/register` | +| PAR エンドポイント | `/api/par` | +| グラント管理エンドポイント | `/api/gm/{grantId}` | +| フェデレーション設定エンドポイント | `/.well-known/openid-federation` | +| フェデレーション登録エンドポイント | `/api/federation/register` | +| クレデンシャルイシュアメタデータエンドポイント | `/.well-known/openid-credential-issuer` | +| JWT イシュアメタデータエンドポイント | `/.well-known/jwt-issuer` | 認可エンドポイントとトークンエンドポイントは、[RFC 6749][1]、[OpenID Connect Core 1.0][13]、 [OAuth 2.0 Multiple Response Type Encoding Practices][33]、[RFC 7636][14] ([PKCE][15])、 @@ -104,6 +139,25 @@ JWK Set エンドポイントは、クライアントアプリケーションが 取り消しエンドポイントはアクセストークンやリフレッシュトークンを取り消すための Web API です。 その動作は [RFC 7009][21] で定義されています。 +イントロスペクションエンドポイントはアクセストークンやリフレッシュトークンの情報を取得するための +Web API です。 その動作は [RFC 7662][32] で定義されています。 + +ユーザー情報エンドポイントはユーザーの情報を取得するための Web API です。その動作は +[OpenID Connect Core 1.0][13] の [Section 5.3. UserInfo Endpoint][41] で定義されています。 + +動的クライアント登録エンドポイントは、クライアントアプリケーションの登録・更新をおこなうための +Web API です。 その動作は [RFC 7591][43] および [RFC 7592][44] で定義されています。 + +PAR エンドポイントは、認可リクエストを事前に登録し、リクエスト URI の発行を受けるための +Web API です。 その動作は [RFC 9126][45] で定義されています。 + +グラント管理エンドポイントは、グラント ID の情報取得や失効をおこなうための Web API です。 +その動作は [Grant Management for OAuth 2.0][46] で定義されています。 + +フェデレーション設定エンドポイントは、認可サーバーのエンティティコンフィギュレーションを +JWT 形式で返す Web API です。その動作は [OpenID Federation 1.0][OIDFED] +で定義されています。 + 認可リクエストの例 ------------------ @@ -124,9 +178,20 @@ ID で置き換えてください。 クライアントアプリケーション |:-----------:|:----------:| | john | john | | jane | jane | +| max | max | +| inga | inga | もちろんこれらのログイン情報はダミーデータですので、ユーザーデータベースの実装をあなたの実装で置き換える必要があります。 +アカウント `max` は [OpenID Connect for Identity Assurance 1.0][IDA] (IDA) +の古いドラフト用のものです。当アカウントは verified claims を古いフォーマットで保持しています。 +Authlete 2.2 は古いフォーマットを受け付けますが、Authlete 2.3 以降は拒否します。 + +アカウント `inga` は IDA 仕様の実装者向けドラフト第三版以降のためのものです。 +最新の IDA 仕様をテストする際は `inga` を利用してください。 +ただし、実装者向けドラフト第三版以降がサポートされるのは Authlete 2.3 からということにご留意ください。 +古い Authlete は最新の IDA 仕様はサポートしません。 + カスタマイズ ------------ @@ -142,8 +207,8 @@ Authlete はユーザーアカウントを管理しないので、基本的に ---------------- この実装では、認可ページを実装するために `Viewable` クラスを使用しています。 -このクラスは [Jersey][18] (JAX-RS の参照実装) に含まれているものですが、JAX-RS -2.0 API の一部ではありません。 +このクラスは [Jersey][18] (Jakarta REST の参照実装) に含まれているものですが、Jakarta REST +API の一部ではありません。 関連仕様 @@ -162,8 +227,11 @@ Authlete はユーザーアカウントを管理しないので、基本的に - [RFC 7521][28] - Assertion Framework for OAuth 2.0 Client Authentication and Authorization Grants - [RFC 7522][29] - Security Assertion Markup Language (SAML) 2.0 Profile for OAuth 2.0 Client Authentication and Authorization Grants - [RFC 7523][30] - JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants +- [RFC 7591][43] - OAuth 2.0 Dynamic Client Registration Protocol +- [RFC 7592][44] - OAuth 2.0 Dynamic Client Registration Management Protocol - [RFC 7636][31] - Proof Key for Code Exchange by OAuth Public Clients - [RFC 7662][32] - OAuth 2.0 Token Introspection +- [RFC 9126][45] - OAuth 2.0 Pushed Authorization Requests - [OAuth 2.0 Multiple Response Type Encoding Practices][33] - [OAuth 2.0 Form Post Response Mode][34] - [OpenID Connect Core 1.0][13] @@ -177,55 +245,67 @@ Authlete はユーザーアカウントを管理しないので、基本的に - [Authlete][7] - Authlete ホームページ - [authlete-java-common][5] - Java 用 Authlete 共通ライブラリ -- [authlete-java-jaxrs][3] - JAX-RS (Java) 用 Authlete ライブラリ +- [authlete-java-jakarta][3] - Jakarta (Java) 用 Authlete ライブラリ - [java-resource-server][40] - リソースサーバーの実装 -サポート --------- +コンタクト +---------- -[Authlete, Inc.](https://www.authlete.com/)
-support@authlete.com +| 目的 | メールアドレス | +|:-----|:---------------------| +| 一般 | info@authlete.com | +| 営業 | sales@authlete.com | +| 広報 | pr@authlete.com | +| 技術 | support@authlete.com | -[1]: http://tools.ietf.org/html/rfc6749 -[2]: http://openid.net/connect/ -[3]: https://github.com/authlete/authlete-java-jaxrs -[4]: https://jcp.org/en/jsr/detail?id=339 +[1]: https://www.rfc-editor.org/rfc/rfc6749.html +[2]: https://openid.net/connect/ +[3]: https://github.com/authlete/authlete-java-jakarta +[4]: https://jakarta.ee/specifications/restful-ws/ [5]: https://github.com/authlete/authlete-java-common -[6]: https://www.authlete.com/documents/apis +[6]: https://docs.authlete.com/ [7]: https://www.authlete.com/ -[8]: https://www.authlete.com/documents/overview +[8]: https://www.authlete.com/ja/developers/overview/ [9]: https://so.authlete.com/accounts/signup -[10]: https://www.authlete.com/documents/getting_started -[11]: http://tools.ietf.org/html/rfc6749#section-3.1 -[12]: http://tools.ietf.org/html/rfc6749#section-3.2 -[13]: http://openid.net/specs/openid-connect-core-1_0.html -[14]: http://tools.ietf.org/html/rfc7636 -[15]: https://www.authlete.com/documents/article/pkce -[16]: http://tools.ietf.org/html/rfc6749#section-4.2 -[17]: https://www.authlete.com/documents/cd_console +[10]: https://www.authlete.com/ja/developers/getting_started/ +[11]: https://www.rfc-editor.org/rfc/rfc6749.html#section-3.1 +[12]: https://www.rfc-editor.org/rfc/rfc6749.html#section-3.2 +[13]: https://openid.net/specs/openid-connect-core-1_0.html +[14]: https://www.rfc-editor.org/rfc/rfc7636.html +[15]: https://www.authlete.com/ja/developers/pkce/ +[16]: https://www.rfc-editor.org/rfc/rfc6749.html#section-4.2 +[17]: https://www.authlete.com/ja/developers/cd_console/ [18]: https://jersey.java.net/ -[19]: http://tools.ietf.org/html/rfc6750 -[20]: http://tools.ietf.org/html/rfc6819 -[21]: http://tools.ietf.org/html/rfc7009 -[22]: http://tools.ietf.org/html/rfc7033 -[23]: http://tools.ietf.org/html/rfc7515 -[24]: http://tools.ietf.org/html/rfc7516 -[25]: http://tools.ietf.org/html/rfc7517 -[26]: http://tools.ietf.org/html/rfc7518 -[27]: http://tools.ietf.org/html/rfc7519 -[28]: http://tools.ietf.org/html/rfc7521 -[29]: http://tools.ietf.org/html/rfc7522 -[30]: http://tools.ietf.org/html/rfc7523 -[31]: http://tools.ietf.org/html/rfc7636 -[32]: http://tools.ietf.org/html/rfc7662 -[33]: http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html -[34]: http://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html -[35]: http://openid.net/specs/openid-connect-discovery-1_0.html -[36]: http://openid.net/specs/openid-connect-registration-1_0.html -[37]: http://openid.net/specs/openid-connect-session-1_0.html +[19]: https://www.rfc-editor.org/rfc/rfc6750.html +[20]: https://www.rfc-editor.org/rfc/rfc6819.html +[21]: https://www.rfc-editor.org/rfc/rfc7009.html +[22]: https://www.rfc-editor.org/rfc/rfc7033.html +[23]: https://www.rfc-editor.org/rfc/rfc7515.html +[24]: https://www.rfc-editor.org/rfc/rfc7516.html +[25]: https://www.rfc-editor.org/rfc/rfc7517.html +[26]: https://www.rfc-editor.org/rfc/rfc7518.html +[27]: https://www.rfc-editor.org/rfc/rfc7519.html +[28]: https://www.rfc-editor.org/rfc/rfc7521.html +[29]: https://www.rfc-editor.org/rfc/rfc7522.html +[30]: https://www.rfc-editor.org/rfc/rfc7523.html +[31]: https://www.rfc-editor.org/rfc/rfc7636.html +[32]: https://www.rfc-editor.org/rfc/rfc7662.html +[33]: https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html +[34]: https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html +[35]: https://openid.net/specs/openid-connect-discovery-1_0.html +[36]: https://openid.net/specs/openid-connect-registration-1_0.html +[37]: https://openid.net/specs/openid-connect-session-1_0.html [38]: http://localhost:8080 [39]: doc/CUSTOMIZATION.ja.md [40]: https://github.com/authlete/java-resource-server -[41]: http://openid.net/specs/openid-connect-core-1_0.html#UserInfo +[41]: https://openid.net/specs/openid-connect-core-1_0.html#UserInfo +[42]: https://maven.apache.org/ +[43]: https://www.rfc-editor.org/rfc/rfc7591.html +[44]: https://www.rfc-editor.org/rfc/rfc7592.html +[45]: https://www.rfc-editor.org/rfc/rfc9126.html +[46]: https://openid.net/specs/fapi-grant-management.html +[IDA]: https://openid.net/specs/openid-connect-4-identity-assurance-1_0.html +[OIDFED]: https://openid.net/specs/openid-federation-1_0.html + diff --git a/README.md b/README.md index 3550435..e21a8d5 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,21 @@ Overview This is an authorization server implementation in Java which supports [OAuth 2.0][1] and [OpenID Connect][2]. -This implementation is written using JAX-RS 2.0 API and [authlete-java-jaxrs][3] -library. JAX-RS is _The Java API for RESTful Web Services_. JAX-RS 2.0 API has -been standardized by [JSR 339][4] and it is included in Java EE 7. On the other -hand, authlete-java-jaxrs library is an open source library which provides utility -classes for developers to implement an authorization server a resource server. -authlete-java-jaxrs in turn uses [authlete-java-common][5] library which is -another open source library to communicate with [Authlete Web APIs][6]. +This implementation is written using the Jakarta RESTful Web Services API +(_Jakarta REST_, formerly known as JAX-RS) and the [authlete-java-jakarta][3] +library. Jakarta REST is part of [Jakarta EE][4]. This server targets the +**Jakarta EE 10** stack (Jakarta REST 3.1 / Servlet 6.0, Jersey 3.1) and builds +and runs on **Java 25**. On the other hand, authlete-java-jakarta library is an +open source library which provides utility classes for developers to implement an +authorization server and a resource server. authlete-java-jakarta in turn uses +[authlete-java-common][5] library which is another open source library to +communicate with [Authlete Web APIs][6]. + +> **Note:** This server was previously built on the legacy Java EE stack +> (JAX-RS 2.0 / `javax.*`, Jersey 2, Servlet 3, Java 8). It has been migrated to +> **Jakarta EE 10** (`jakarta.*`), so it now deploys cleanly to modern servlet +> containers such as Tomcat 10+ and Jetty 12. The default Authlete API version +> is also now **Authlete 3.0** (see [Configuration File](#configuration-file)). This implementation is _DB-less_. What this means is that you don't have to have a database server that stores authorization data (e.g. access tokens), @@ -22,9 +30,8 @@ This is achieved by using [Authlete][7] as a backend service. Access tokens issued by this authorization server can be used at a resource server which uses Authlete as a backend service. [java-resource-server][40] -is such a resource server implementation. It supports a [userinfo endpoint][41] -defined in [OpenID Connect Core 1.0][13] and includes an example implementation -of a protected resource endpoint, too. +is such a resource server implementation. It includes an example implementation +of protected resource endpoint. License @@ -32,6 +39,10 @@ License Apache License, Version 2.0 + JSON files under `src/main/resources/ekyc-ida` have been copied from + https://bitbucket.org/openid/ekyc-ida/src/master/examples/response/ . + Regarding their license, ask the eKYC-IDA WG of OpenID Foundation. + Source Code ----------- @@ -66,31 +77,57 @@ How To Run $ vi authlete.properties -3. Start the authorization server on [http://localhost:8080][38]. +3. Make sure that you have installed [maven][42] and a **JDK 25** (or later) and + set `JAVA_HOME` properly. + +4. Start the authorization server on [http://localhost:8080][38]. $ mvn jetty:run & +#### Run With Docker + +If you would prefer to use Docker, just hit the following command after the step 2. + + $ docker-compose up + +#### Configuration File + `java-oauth-server` refers to `authlete.properties` as a configuration file. If you want to use another different file, specify the name of the file by the system property `authlete.configuration.file` like the following. $ mvn -Dauthlete.configuration.file=local.authlete.properties jetty:run & +By default, `authlete.properties` is configured for **Authlete 3.0** (API `V3`): +set your service's cluster `base_url` (e.g. `https://jp.authlete.com`), the +`service.api_key` and a `service.access_token`. If you still use Authlete 2.x, +the file contains a commented "Authlete 2.x (legacy)" block you can switch to +(API key + API secret on `https://api.authlete.com`). + Endpoints --------- This implementation exposes endpoints as listed in the table below. -| Endpoint | Path | -|:-----------------------|:------------------------------------| -| Authorization Endpoint | `/api/authorization` | -| Token Endpoint | `/api/token` | -| JWK Set Endpoint | `/api/jwks` | -| Configuration Endpoint | `/.well-known/openid-configuration` | -| Revocation Endpoint | `/api/revocation` | - -The authorization endpoint and the token point accept parameters described +| Endpoint | Path | +|:-------------------------------------|:----------------------------------------| +| Authorization Endpoint | `/api/authorization` | +| Token Endpoint | `/api/token` | +| JWK Set Endpoint | `/api/jwks` | +| Discovery Endpoint | `/.well-known/openid-configuration` | +| Revocation Endpoint | `/api/revocation` | +| Introspection Endpoint | `/api/introspection` | +| UserInfo Endpoint | `/api/userinfo` | +| Dynamic Client Registration Endpoint | `/api/register` | +| Pushed Authorization Request Endpoint| `/api/par` | +| Grant Management Endpoint | `/api/gm/{grantId}` | +| Federation Configuration Endpoint | `/.well-known/openid-federation` | +| Federation Registration Endpoint | `/api/federation/register` | +| Credential Issuer Metadata Endpoint | `/.well-known/openid-credential-issuer` | +| JWT Issuer Metadata Endpoint | `/.well-known/jwt-issuer` | + +The authorization endpoint and the token endpoint accept parameters described in [RFC 6749][1], [OpenID Connect Core 1.0][13], [OAuth 2.0 Multiple Response Type Encoding Practices][33], [RFC 7636][14] ([PKCE][15]) and other specifications. @@ -105,6 +142,27 @@ OpenID Provider in the JSON format defined in [OpenID Connect Discovery 1.0][35] The revocation endpoint is a Web API to revoke access tokens and refresh tokens. Its behavior is defined in [RFC 7009][21]. +The introspection endpoint is a Web API to get information about access +tokens and refresh tokens. Its behavior is defined in [RFC 7662][32]. + +The userinfo endpoint is a Web API to get information about an end-user. +Its behavior is defined in [Section 5.3. UserInfo Endpoint][41] of +[OpenID Connect Core 1.0][13]. + +The dynamic client registration endpoint is a Web API to register and update +client applications. Its behavior is defined in [RFC 7591][43] and [RFC 7592][44]. + +The pushed authorization request endpoint (a.k.a. PAR endpoint) is a Web API +to register an authorization request in advance and obtain a request URI. +Its behavior is defined in [RFC 9126][45]. + +The grant management endpoint is a Web API to get information about a grant ID +and revoke a grant ID. Its behavior is defined in [Grant Management for OAuth 2.0][46]. + +The federation configuration endpoint is a Web API that publishes the entity +configuration of the authorization server in the JWT format. Its behavior is +defined in [OpenID Federation 1.0][OIDFED]. + Authorization Request Example ----------------------------- @@ -125,10 +183,22 @@ one of the following as login credentials. |:--------:|:--------:| | john | john | | jane | jane | +| max | max | +| inga | inga | Of course, these login credentials are dummy data, so you need to replace the user database implementation with your own. +The account `max` is for the old draft of +[OpenID Connect for Identity Assurance 1.0][IDA] (IDA). The account holds +_verified claims_ in the old format. Authlete 2.2 accepts the old format +but Authlete 2.3 onwards will reject it. + +The account `inga` is for the third Implementer's Draft of [IDA][IDA] onwards. +Use `inga` for testing the latest IDA specification. However, note that +the third Implementer's Draft onwards is supported from Authlete 2.3. +Older Authlete versions do not support the latest IDA specification. + Customization ------------- @@ -136,7 +206,7 @@ Customization How to customize this implementation is described in [CUSTOMIZATION.md][39]. Basically, you need to do programming for _end-user authentication_ because Authlete does not manage end-user accounts. This is by design. The -architecture of Authlete carefully seperates authorization from authentication +architecture of Authlete carefully separates authorization from authentication so that you can add OAuth 2.0 and OpenID Connect functionalities seamlessly into even an existing web service which may already have a mechanism for end-user authentication. @@ -146,8 +216,8 @@ Implementation Note ------------------- This implementation uses `Viewable` class to implement the authorization page. -The class is included in [Jersey][18] (the reference implementation of JAX-RS), -but it is not a part of JAX-RS 2.0 API. +The class is included in [Jersey][18] (the reference implementation of Jakarta +REST), but it is not a part of the Jakarta REST API. Related Specifications @@ -166,8 +236,11 @@ Related Specifications - [RFC 7521][28] - Assertion Framework for OAuth 2.0 Client Authentication and Authorization Grants - [RFC 7522][29] - Security Assertion Markup Language (SAML) 2.0 Profile for OAuth 2.0 Client Authentication and Authorization Grants - [RFC 7523][30] - JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants +- [RFC 7591][43] - OAuth 2.0 Dynamic Client Registration Protocol +- [RFC 7592][44] - OAuth 2.0 Dynamic Client Registration Management Protocol - [RFC 7636][31] - Proof Key for Code Exchange by OAuth Public Clients - [RFC 7662][32] - OAuth 2.0 Token Introspection +- [RFC 9126][45] - OAuth 2.0 Pushed Authorization Requests - [OAuth 2.0 Multiple Response Type Encoding Practices][33] - [OAuth 2.0 Form Post Response Mode][34] - [OpenID Connect Core 1.0][13] @@ -181,55 +254,66 @@ See Also - [Authlete][7] - Authlete Home Page - [authlete-java-common][5] - Authlete Common Library for Java -- [authlete-java-jaxrs][3] - Authlete Library for JAX-RS (Java) +- [authlete-java-jakarta][3] - Authlete Library for Jakarta (Java) - [java-resource-server][40] - Resource Server Implementation -Support +Contact ------- -[Authlete, Inc.](https://www.authlete.com/)
-support@authlete.com +| Purpose | Email Address | +|:----------|:---------------------| +| General | info@authlete.com | +| Sales | sales@authlete.com | +| PR | pr@authlete.com | +| Technical | support@authlete.com | -[1]: http://tools.ietf.org/html/rfc6749 -[2]: http://openid.net/connect/ -[3]: https://github.com/authlete/authlete-java-jaxrs -[4]: https://jcp.org/en/jsr/detail?id=339 +[1]: https://www.rfc-editor.org/rfc/rfc6749.html +[2]: https://openid.net/connect/ +[3]: https://github.com/authlete/authlete-java-jakarta +[4]: https://jakarta.ee/specifications/restful-ws/ [5]: https://github.com/authlete/authlete-java-common -[6]: https://www.authlete.com/documents/apis +[6]: https://docs.authlete.com/ [7]: https://www.authlete.com/ -[8]: https://www.authlete.com/documents/overview -[9]: https://so.authlete.com/accounts/signup -[10]: https://www.authlete.com/documents/getting_started -[11]: http://tools.ietf.org/html/rfc6749#section-3.1 -[12]: http://tools.ietf.org/html/rfc6749#section-3.2 -[13]: http://openid.net/specs/openid-connect-core-1_0.html -[14]: http://tools.ietf.org/html/rfc7636 -[15]: https://www.authlete.com/documents/article/pkce -[16]: http://tools.ietf.org/html/rfc6749#section-4.2 -[17]: https://www.authlete.com/documents/cd_console +[8]: https://www.authlete.com/developers/overview/ +[9]: https://console.authlete.com/register +[10]: https://www.authlete.com/developers/getting_started/ +[11]: https://www.rfc-editor.org/rfc/rfc6749.html#section-3.1 +[12]: https://www.rfc-editor.org/rfc/rfc6749.html#section-3.2 +[13]: https://openid.net/specs/openid-connect-core-1_0.html +[14]: https://www.rfc-editor.org/rfc/rfc7636.html +[15]: https://www.authlete.com/developers/pkce/ +[16]: https://www.rfc-editor.org/rfc/rfc6749.html#section-4.2 +[17]: https://www.authlete.com/developers/cd_console/ [18]: https://jersey.java.net/ -[19]: http://tools.ietf.org/html/rfc6750 -[20]: http://tools.ietf.org/html/rfc6819 -[21]: http://tools.ietf.org/html/rfc7009 -[22]: http://tools.ietf.org/html/rfc7033 -[23]: http://tools.ietf.org/html/rfc7515 -[24]: http://tools.ietf.org/html/rfc7516 -[25]: http://tools.ietf.org/html/rfc7517 -[26]: http://tools.ietf.org/html/rfc7518 -[27]: http://tools.ietf.org/html/rfc7519 -[28]: http://tools.ietf.org/html/rfc7521 -[29]: http://tools.ietf.org/html/rfc7522 -[30]: http://tools.ietf.org/html/rfc7523 -[31]: http://tools.ietf.org/html/rfc7636 -[32]: http://tools.ietf.org/html/rfc7662 -[33]: http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html -[34]: http://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html -[35]: http://openid.net/specs/openid-connect-discovery-1_0.html -[36]: http://openid.net/specs/openid-connect-registration-1_0.html -[37]: http://openid.net/specs/openid-connect-session-1_0.html +[19]: https://www.rfc-editor.org/rfc/rfc6750.html +[20]: https://www.rfc-editor.org/rfc/rfc6819.html +[21]: https://www.rfc-editor.org/rfc/rfc7009.html +[22]: https://www.rfc-editor.org/rfc/rfc7033.html +[23]: https://www.rfc-editor.org/rfc/rfc7515.html +[24]: https://www.rfc-editor.org/rfc/rfc7516.html +[25]: https://www.rfc-editor.org/rfc/rfc7517.html +[26]: https://www.rfc-editor.org/rfc/rfc7518.html +[27]: https://www.rfc-editor.org/rfc/rfc7519.html +[28]: https://www.rfc-editor.org/rfc/rfc7521.html +[29]: https://www.rfc-editor.org/rfc/rfc7522.html +[30]: https://www.rfc-editor.org/rfc/rfc7523.html +[31]: https://www.rfc-editor.org/rfc/rfc7636.html +[32]: https://www.rfc-editor.org/rfc/rfc7662.html +[33]: https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html +[34]: https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html +[35]: https://openid.net/specs/openid-connect-discovery-1_0.html +[36]: https://openid.net/specs/openid-connect-registration-1_0.html +[37]: https://openid.net/specs/openid-connect-session-1_0.html [38]: http://localhost:8080 [39]: doc/CUSTOMIZATION.md [40]: https://github.com/authlete/java-resource-server -[41]: http://openid.net/specs/openid-connect-core-1_0.html#UserInfo +[41]: https://openid.net/specs/openid-connect-core-1_0.html#UserInfo +[42]: https://maven.apache.org/ +[43]: https://www.rfc-editor.org/rfc/rfc7591.html +[44]: https://www.rfc-editor.org/rfc/rfc7592.html +[45]: https://www.rfc-editor.org/rfc/rfc9126.html +[46]: https://openid.net/specs/fapi-grant-management.html +[IDA]: https://openid.net/specs/openid-connect-4-identity-assurance-1_0.html +[OIDFED]: https://openid.net/specs/openid-federation-1_0.html diff --git a/authlete.properties b/authlete.properties index 805fe09..851bd57 100644 --- a/authlete.properties +++ b/authlete.properties @@ -11,32 +11,65 @@ # Source: https://github.com/authlete/authlete-java-common # JavaDoc: http://authlete.github.io/authlete-java-common/ # +# This file is configured for Authlete 3.0 (API V3) by default. Authlete 3.0 +# is the latest generation of Authlete and the version used for upcoming +# releases and updates. If you still rely on Authlete 2.x, see the +# "Authlete 2.x (legacy)" block near the bottom of this file. +# #================================================================================ +# api_version +# +# The Authlete API version. "V3" selects Authlete 3.0, which is the default +# for this authorization server. +# +api_version = V3 + + # base_url # -# The base URL of the Authlete server. If you are using the shared server, -# set "https://api.authlete.com" to this parameter. On the other hand, if -# you are using a dedicated server, please contact "Authlete, Inc." +# The base URL of the Authlete server. For the Authlete 3.0 Shared Cloud, +# choose the URL of your service's cluster region: +# +# https://us.authlete.com - 🇺🇸 US Cluster +# https://jp.authlete.com - 🇯🇵 Japan Cluster +# https://eu.authlete.com - 🇪🇺 Europe Cluster +# https://br.authlete.com - 🇧🇷 Brazil Cluster +# +# If you are using a dedicated server, please contact "Authlete, Inc." # about the URL of your dedicated Authlete server. # -base_url = https://api.authlete.com +base_url = https://us.authlete.com # service.api_key -# service.api_secret +# service.access_token # -# API credentials of one of your services. You can find API credentials of -# your services in Service Owner Console. The location of the management -# console is "https://so.authlete.com/" if you are using the shared server. -# On the other hand, if you are using a dedicated server, please contact -# "Authlete, Inc." about the location of the -# management console of your dedicated Authlete server. +# Credentials of one of your services. With Authlete 3.0 (API V3), a service +# is identified by its API key and authenticated with an access token. You +# can find these in the Authlete management console +# (https://console.authlete.com/). +# +service.api_key = +service.access_token = + + +#-------------------------------------------------------------------------------- +# Authlete 2.x (legacy) +# +# Earlier versions of this authorization server used Authlete 2.x. To use +# Authlete 2.x instead of 3.0, comment out the "api_version = V3" block above +# and uncomment the lines below. +# +# Authlete 2.x uses the shared server at https://api.authlete.com and +# authenticates a service with an API key + API secret pair. You can find +# these credentials in the Service Owner Console (https://so.authlete.com/). # # You can use "service.api_secret.encrypted" instead of "service.api_secret" # to avoid writing a plain secret key in this configuration file. See the # JavaDoc of AuthletePropertiesConfiguration for details. -# -service.api_key = 5593494639 -service.api_secret = AAw0rner_-y1A6J9s20wjRCpkBvez3GxEBoL9jOJVR0 +#-------------------------------------------------------------------------------- +#base_url = https://api.authlete.com +#service.api_key = +#service.api_secret = diff --git a/certs/Open_Banking_Brasil_Sandbox_Root_G2.pem b/certs/Open_Banking_Brasil_Sandbox_Root_G2.pem new file mode 100644 index 0000000..11971ba --- /dev/null +++ b/certs/Open_Banking_Brasil_Sandbox_Root_G2.pem @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFvDCCA6SgAwIBAgIUKhBUxL5Dt4w3xH1V3X1n+x/e3hcwDQYJKoZIhvcNAQEN +BQAwdjELMAkGA1UEBhMCQlIxHDAaBgNVBAoTE09wZW4gRmluYW5jZSBCcmFzaWwx +HTAbBgNVBAsTFE9wZW4gRmluYW5jZSBzYW5kYm94MSowKAYDVQQDEyFPcGVuIEZp +bmFuY2Ugc2FuZGJveCBSb290IENBIC0gRzIwHhcNMjMwMzA5MTQzNjAwWhcNMzgw +MzA1MTQzNjAwWjB2MQswCQYDVQQGEwJCUjEcMBoGA1UEChMTT3BlbiBGaW5hbmNl +IEJyYXNpbDEdMBsGA1UECxMUT3BlbiBGaW5hbmNlIHNhbmRib3gxKjAoBgNVBAMT +IU9wZW4gRmluYW5jZSBzYW5kYm94IFJvb3QgQ0EgLSBHMjCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBALJFBgmKj3iDF3C8+8smNNQDxLFA9kCcca1iaxQf +vMI/FKGW2ullHhH+W3EGEajn39QOlccGyrfCONHLqMW53+HAMtwiIvcrJgj72V7D +nYflO3aaCFwoC31PL1+pMBo88F6jvezZ8BlRnheT7urCs6+onhz8pm0cVNc77U5X +pi8IOJz1QFKecJnFLjG0NiLCzOzjLSo0A8Rue9K/H5fjq+PqKdXG94AoQyFUwlsU +y4aXbylDz1kRiOSnOlRNPcY/su95pFKQbGgaZZ1fLf89i5PtHAUu92FbD7H7OyYX +XpZ97La0d7cUH0A4nb+LpOd/f0c8lAN2Ya1B8aKvWiF23q3c8EoAJyhrdkHKe6yI +YvLC5G5jkbJefeQcp34IgD8T+Tn3aBF7YgmlYUMbnsuokBG28Hr50EAklCclA0bb +4pmf8nE6y7VQ0CM/bNZd1F9AjxLukkyvkrOD6A6uv+c5KeC+da245dBzsyhiKe9R +RX5Gkf7NSkDu9b9YkdDaWV6LXxpUA0SmdT9M16yK3XPDKOWBI4gVSB1KuwrOcal/ +dMwFUlxvqdGugRbxDNNukxa0l3JztGg6XYLjEiiYrQ+7HChbUVNlM0l0VZAz4eON +gYrq2ExUDWlQXiES389/Qz3R3yDk9ib1YsMHwAux2ulCssFVn4EgtnYFfgf3o01J +3CedAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0G +A1UdDgQWBBQVb9vsej3AhKXXgbWRvfmoWNM++jANBgkqhkiG9w0BAQ0FAAOCAgEA +VPrUd4UDjOhkxOzSN4bMr0cULZJwQPRV8aj99tzdv8Ef44CwLVkLmm7Z2d1uRA9l +7xbK6W8L1oL95iV4K4o2u4+ZFG7mrOfU2T2wgTfMlIxHDoAxS49flUYkBpQI1Wj4 +JBZ6fKGFVH6PyIio0I2Mx2u80ZX6lPYQ2q4DR6eBUoNt8T8XSWpD4TjroFbOVIp+ +N84alBca/pvW2aF2HwPndrL/Y++HZp3TpdUeBUT757KOZ7hdwf30pxd8qNn8+peb +bP2h6b9pjKAmS8ciBExJzchhQWJRP6LIdvexTsBQ1HLxlZNrlg66wYT0kuVVzRTa +0qRvIjQ0ntmhy2DtmE5VFT9O8ahcQL5Ddk8B4dhiy59vjALS6kIUXJ6GCobzRUsQ +a7b7J7nu+PTY+qVo0LsTMO1rVTUXD63gVk8QmPUwnvduk9nxraNpVP+m17BuEcls +EsoGAAQYcmzOlnZpqhhbTnbsEayW1bMyxkLjBjXpOfBgrvyv5fU/09lP6VB20FJr +zVPZWr5PTzaaizDDecSKgZ1ANIwXIIwWirwzDqqQb922JtcH+Ca6JJWgrV7+kDCU +cVzwKVYievjXMCsmRSzDKEgp4n1AgrxcoaH0smD+qA+wzfsMqvEA1iTNW7zdnF0o +6UJ9rkWxKr+JRZ5jFO+kpKQ1DxfDJZE554aO8AXzYEI= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/certs/import-certificate.sh b/certs/import-certificate.sh new file mode 100755 index 0000000..596dddc --- /dev/null +++ b/certs/import-certificate.sh @@ -0,0 +1,31 @@ +#!/bin/bash + + +KEYSTORE=$JAVA_HOME/jre/lib/security/cacerts +if [[ ! -f $KEYSTORE ]]; then + KEYSTORE=$JAVA_HOME/lib/security/cacerts +fi + +__import_certificate() +{ + local FILE="$1" + local ALIAS=$(basename $FILE .pem) + local COMMAND=(keytool -noprompt -storepass changeit -keystore $KEYSTORE -importcert -alias $ALIAS -file $FILE) + + echo "${COMMAND[@]}" + "${COMMAND[@]}" +} + + +__main() +{ + if [ "$1" = "" ]; then + echo "USAGE: $(basename $0) certificate.pem" 2>&1 + exit 1 + fi + + __import_certificate "$1" +} + + +__main "$@" diff --git a/doc/CUSTOMIZATION.ja.md b/doc/CUSTOMIZATION.ja.md index b175753..5f4239b 100644 --- a/doc/CUSTOMIZATION.ja.md +++ b/doc/CUSTOMIZATION.ja.md @@ -9,7 +9,7 @@ この認可サーバーの実装では、[Authlete][1] をバックエンドとして使用しています。 これは、(1) [OAuth 2.0][2] と [OpenID Connect][3] の実装の中心となる部分が -java-oauth-server のソースツリー内ではなくクラウド上の Authleteサーバー内にあること、そして +java-oauth-server のソースツリー内ではなくクラウド上の Authlete サーバー内にあること、そして (2) アクセストークンなどの認可データ、認可サーバー自体の設定やクライアントアプリケーションの設定が、 ローカルデータベース内ではなくクラウド上のデータベースに保存されるということ、 を意味します。 そのため、非常に単純化して言うと、次の図が示すように、 @@ -35,26 +35,26 @@ java-oauth-server のソースツリー内ではなくクラウド上の Authlet Authlete が提供する [Web API][4] を使い、認可サーバーを書くことができます。 [authlete-java-common][5] は、その Web API と直接通信をおこなうライブラリです。 -[authlete-java-jaxrs][6] は、[authlete-java-common API][7] +[authlete-java-jakarta][6] は、[authlete-java-common API][7] をラッピングするユーティリティークラス群を含むライブラリで、それらのクラス群を使えば、 authlete-java-common API を直接使用するよりもかなり簡単に認可サーバーを書くことができます。 -java-oauth-server は、authlete-java-jaxrs のユーティリティークラス群によって構成される -[authlete-java-jaxrs API][8] を使用して書かれています。 +java-oauth-server は、authlete-java-jakarta のユーティリティークラス群によって構成される +[authlete-java-jakarta API][8] を使用して書かれています。 -名前が示唆するように、authlete-java-jaxrs ライブラリは JAX-RS 2.0 API に依存しています。 -JAX-RS は _The Java API for RESTful Web Services_ の略称です。 -JAX-RS 2.0 API は [JSR 339][9] で標準化され、Java EE 7 に含まれています。 +名前が示唆するように、authlete-java-jakarta ライブラリは Jakarta RESTful Web Services API +(_Jakarta REST_、旧称 JAX-RS) に依存しています。 Jakarta REST は [Jakarta EE][9] の一部です。 +この認可サーバーは Jakarta EE 10 (Jakarta REST 3.1 / Servlet 6.0) を対象としています。 次の図は、これまでに言及したコンポーネント群の関係を示したものです。 ``` -+-------------------------------+ -| java-oauth-server | -+----+--------------------------+ -| | authlete-java-jaxrs | -| +---+----------------------+ +----------+ -| JAX-RS | authlete-java-common | <------> | Authlete | -+--------+----------------------+ +----------+ ++----------------------------------+ +| java-oauth-server | ++---------+------------------------+ +| | authlete-java-jakarta | +| +----+-------------------+ +----------+ +| Jakarta | authlete-java-common | <------> | Authlete | ++---------+------------------------+ +----------+ ``` @@ -67,14 +67,14 @@ OAuth 2.0 に加えて OpenID Connect もサポートしているにもかかわ 実装では、[AuthorizationRequestHandler][15] クラスを使い、認可リクエストを処理する作業をそのクラスの `handle()` メソッドに委譲しています。 -クラスの詳細については [authlete-java-jaxrs][6] ライブラリの README ファイルに書かれています。 +クラスの詳細については [authlete-java-jakarta][6] ライブラリの README ファイルに書かれています。 ここで重要なのは、このクラスのコンストラクタが [AuthorizationRequestHandlerSpi][16] インターフェースの実装を必要とし、その実装はあなたが提供しなければならないという点です。 別の言い方をすると、`AuthorizationRequestHandlerSpi` インターフェースのメソッド群がカスタマイズポイントです。 当該インターフェースには、次のようなメソッド群が定義されています。 -これらのメソッド群の要求事項の詳細については authlete-java-jaxrs API の +これらのメソッド群の要求事項の詳細については authlete-java-jakarta API の [JavaDoc][8] を参照してください。 1. `boolean isUserAuthenticated()` @@ -121,8 +121,8 @@ java-oauth-server の現在の実装では、(Authlete の `/api/auth/authorizat からの応答を表す [AuthorizationResponse][20] クラスのインスタンスである) 引数からデータを取り出し、そのデータを [authorization.jsp][21] という HTML テンプレートに埋め込みます。 これをおこなうため、実装では `Viewable` -というクラスを使用しています。 このクラスは [Jersey][12] (JAX-RS のレファレンス実装) -に含まれていますが、JAX-RS 2.0 API の一部ではありません。 +というクラスを使用しています。 このクラスは [Jersey][12] (Jakarta REST のレファレンス実装) +に含まれていますが、Jakarta REST API の一部ではありません。 認可ページをカスタマイズしたい場合は、`generateAuthorizationPage()` メソッドと認可ページのテンプレート (`authorization.jsp`) @@ -200,13 +200,13 @@ java-oauth-server の現在の実装は、エンドユーザーの決定を `/ap 実装では、[AuthorizationDecisionHandler][30] クラスを使い、エンドユーザーの決定を処理する作業をそのクラスの `handle()` メソッドに委譲しています。 -クラスの詳細については [authlete-java-jaxrs][6] ライブラリの README ファイルに書かれています。 +クラスの詳細については [authlete-java-jakarta][6] ライブラリの README ファイルに書かれています。 ここで重要なのは、このクラスのコンストラクタが [AuthorizationDecisionHandlerSpi][31] インターフェースの実装を必要とし、その実装はあなたが提供しなければならないという点です。 別の言い方をすると、`AuthorizationDecisionHandlerSpi` インターフェースのメソッド群がカスタマイズポイントです。 当該インターフェースには、次のようなメソッド群が定義されています。 -これらのメソッド群の要求事項の詳細については authlete-java-jaxrs API の +これらのメソッド群の要求事項の詳細については authlete-java-jakarta API の [JavaDoc][8] を参照してください。 1. `boolean isClientAuthorized()` @@ -281,7 +281,7 @@ Authlete に伝える必要があります。 `AuthorizationDecisionhandlerSpi` 実装では、[TokenRequestHandler][24] クラスを使い、トークンリクエストを処理する作業をそのクラスの `handle()` メソッドに委譲しています。 -クラスの詳細については [authlete-java-jaxrs][6] ライブラリの README ファイルに書かれています。 +クラスの詳細については [authlete-java-jakarta][6] ライブラリの README ファイルに書かれています。 ここで重要なのは、このクラスのコンストラクタが [TokenRequestHandlerSpi][25] インターフェースの実装を必要とし、その実装はあなたが提供しなければならないという点です。 別の言い方をすると、`TokenRequestHandlerSpi` インターフェースのメソッド群がカスタマイズポイントです。 @@ -313,53 +313,71 @@ class TokenRequestHandlerSpiImpl extends TokenRequestHandlerSpiAdapter ``` +イントロスペクションエンドポイント +---------------------------------- + +イントロスペクションエンドポイントの実装は [IntrospectionEndpoint.java][34] +内にあります。 + +[RFC 7662][35] (OAuth 2.0 Token Introspection) +は、イントロスペクションエンドポイントを何らかの方法で保護することを要求しています。 +IntrospectionEndpoint.java +内の保護の実装はデモンストレーション用のものであり、商用利用には向かないので、適宜変更してください。 + + その他の情報 ------------ - [Authlete][1] - Authlete ホームページ - [authlete-java-common][5] - Java 用 Authlete 共通ライブラリ - [authlete-java-common API][7] - Java 用 Authlete 共通ライブラリの JavaDoc -- [authlete-java-jaxrs][6] - JAX-RS (Java) 用 Authlete ライブラリ -- [authlete-java-jaxrs API][8] - JAX-RS (Java) 用 Authlete ライブラリの JavaDoc +- [authlete-java-jakarta][6] - Jakarta (Java) 用 Authlete ライブラリ +- [authlete-java-jakarta API][8] - Jakarta (Java) 用 Authlete ライブラリの JavaDoc -サポート --------- +コンタクト +---------- -[Authlete, Inc.][1]
-support@authlete.com +| 目的 | メールアドレス | +|:-----|:---------------------| +| 一般 | info@authlete.com | +| 営業 | sales@authlete.com | +| 広報 | pr@authlete.com | +| 技術 | support@authlete.com | [1]: https://www.authlete.com/ -[2]: http://tools.ietf.org/html/rfc6749 -[3]: http://openid.net/connect/ -[4]: https://www.authlete.com/documents/apis +[2]: https://tools.ietf.org/html/rfc6749 +[3]: https://openid.net/connect/ +[4]: https://docs.authlete.com/ [5]: https://github.com/authlete/authlete-java-common -[6]: https://github.com/authlete/authlete-java-jaxrs -[7]: http://authlete.github.io/authlete-java-common/ -[8]: http://authlete.github.io/authlete-java-jaxrs/ -[9]: https://jcp.org/en/jsr/detail?id=339 -[10]: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest -[11]: http://openid.net/specs/openid-connect-core-1_0.html +[6]: https://github.com/authlete/authlete-java-jakarta +[7]: https://authlete.github.io/authlete-java-common/ +[8]: https://authlete.github.io/authlete-java-jakarta/ +[9]: https://jakarta.ee/specifications/restful-ws/ +[10]: https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest +[11]: https://openid.net/specs/openid-connect-core-1_0.html [12]: https://jersey.java.net/ [13]: https://www.authlete.com/documents/so_console/ [14]: ../src/main/java/com/authlete/jaxrs/server/api/AuthorizationEndpoint.java -[15]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/AuthorizationRequestHandler.html -[16]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/spi/AuthorizationRequestHandlerSpi.html -[17]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/spi/AuthorizationRequestHandlerSpi.html#generateAuthorizationPage-com.authlete.common.dto.AuthorizationResponse- +[15]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/AuthorizationRequestHandler.html +[16]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/spi/AuthorizationRequestHandlerSpi.html +[17]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/spi/AuthorizationRequestHandlerSpi.html#generateAuthorizationPage-com.authlete.common.dto.AuthorizationResponse- [18]: ../src/main/java/com/authlete/jaxrs/server/api/AuthorizationRequestHandlerSpiImpl.java -[19]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/spi/AuthorizationRequestHandlerSpiAdapter.html -[20]: http://authlete.github.io/authlete-java-common/com/authlete/common/dto/AuthorizationResponse.html +[19]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/spi/AuthorizationRequestHandlerSpiAdapter.html +[20]: https://authlete.github.io/authlete-java-common/com/authlete/common/dto/AuthorizationResponse.html [21]: ../src/main/webapp/WEB-INF/template/authorization.jsp -[22]: http://authlete.github.io/authlete-java-common/com/authlete/common/types/Display.html +[22]: https://authlete.github.io/authlete-java-common/com/authlete/common/types/Display.html [23]: ../src/main/java/com/authlete/jaxrs/server/api/TokenEndpoint.java -[24]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/TokenRequestHandler.html -[25]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/spi/TokenRequestHandlerSpi.html +[24]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/TokenRequestHandler.html +[25]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/spi/TokenRequestHandlerSpi.html [26]: https://tools.ietf.org/html/rfc6749#section-4.3 [27]: ../src/main/java/com/authlete/jaxrs/server/api/TokenRequestHandlerSpiImpl.java -[28]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/spi/TokenRequestHandlerSpiAdapter.html +[28]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/spi/TokenRequestHandlerSpiAdapter.html [29]: ../src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionEndpoint.java -[30]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/AuthorizationDecisionHandler.html -[31]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/spi/AuthorizationDecisionHandlerSpi.html +[30]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/AuthorizationDecisionHandler.html +[31]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/spi/AuthorizationDecisionHandlerSpi.html [32]: ../src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionHandlerSpiImpl.java -[33]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/spi/AuthorizationDecisionHandlerSpiAdapter.html +[33]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/spi/AuthorizationDecisionHandlerSpiAdapter.html +[34]: ../src/main/java/com/authlete/jaxrs/server/api/IntrospectionEndpoint.java +[35]: https://tools.ietf.org/html/rfc7662 diff --git a/doc/CUSTOMIZATION.md b/doc/CUSTOMIZATION.md index da8d277..b5a5986 100644 --- a/doc/CUSTOMIZATION.md +++ b/doc/CUSTOMIZATION.md @@ -40,27 +40,28 @@ Overall Structure Authlete provides [Web APIs][4] that can be used to write an authorization server. [authlete-java-common][5] is a library which directly communicates -with the Web APIs, and [authlete-java-jaxrs][6] is a library which provides +with the Web APIs, and [authlete-java-jakarta][6] is a library which provides utility classes wrapping the [authlete-java-common API][7] to make it much easier for developers to implement an authorization server than using authlete-java-common API directly. java-oauth-server is written using -[authlete-java-jaxrs API][8] exposed by the utility classes. +[authlete-java-jakarta API][8] exposed by the utility classes. -As its name implies, authlete-java-jaxrs library depends on JAX-RS 2.0 API. -JAX-RS means _The Java API for RESTful Web Services_. JAX-RS 2.0 API has -been standardized by [JSR 339][9] and it is included in Java EE 7. +As its name implies, authlete-java-jakarta library depends on the Jakarta +RESTful Web Services API (_Jakarta REST_, formerly known as JAX-RS). Jakarta +REST is part of [Jakarta EE][9]; this server targets Jakarta EE 10 (Jakarta +REST 3.1 / Servlet 6.0). The figure below illustrates the relationship among the components mentioned so far. ``` -+-------------------------------+ -| java-oauth-server | -+----+--------------------------+ -| | authlete-java-jaxrs | -| +---+----------------------+ +----------+ -| JAX-RS | authlete-java-common | <------> | Authlete | -+--------+----------------------+ +----------+ ++----------------------------------+ +| java-oauth-server | ++---------+------------------------+ +| | authlete-java-jakarta | +| +----+-------------------+ +----------+ +| Jakarta | authlete-java-common | <------> | Authlete | ++---------+------------------------+ +----------+ ``` @@ -75,14 +76,14 @@ need to change this file. The implementation uses [AuthorizationRequestHandler][15] class and delegates the task to handle an authorization request to `handle()` method of the class. Details about the class is written in the README file of -[authlete-java-jaxrs][6] library. What is important here is that the +[authlete-java-jakarta][6] library. What is important here is that the constructor of the class requires an implementation of [AuthorizationRequestHandlerSpi][16] interface and that the implementation must be provided by you. In other words, the methods in `AuthorizationRequestHandlerSpi` interface are customization points. The interface has the methods listed below. See the [JavaDoc][8] of -authlete-java-jaxrs API for details about the requirements of these methods. +authlete-java-jakarta API for details about the requirements of these methods. 1. `boolean isUserAuthenticated()` 2. `long getUserAuthenticatedAt()` @@ -132,13 +133,13 @@ argument (an intance of [AuthorizationResponse][20] class which represents a response from Authlete's `/api/auth/authorization` API) and embeds them into an HTML template, [authorization.jsp][21]. To achieve this, the implementation uses `Viewable` class. The class is -included in [Jersey][12] (the reference implementation of JAX-RS), but it -is not a part of JAX-RS 2.0 API. +included in [Jersey][12] (the reference implementation of Jakarta REST), but it +is not a part of the Jakarta REST API. If you want to customize the authorization page, change the implementation of `generateAuthorizationPage()` method and/or the template of the authorization page (`authorization.jsp`). See the [JavaDoc][7] of -authlete-java-common library for details about `AtuhorizationResponse` +authlete-java-common library for details about `AuthorizationResponse` class. @@ -215,14 +216,14 @@ implementation of the authorization decision endpoint is in The implementation uses [AuthorizationDecisionHandler][30] class and delegates the task to handle an end-user's decision to `handle()` method of the class. Details about the class is written in the README file of -[authlete-java-jaxrs][6] library. What is important here is that the +[authlete-java-jakarta][6] library. What is important here is that the constructor of the class requires an implementation of [AuthorizationDecisionHandlerSpi][31] interface and that the implementation must be provided by you. In other words, the methods in `AuthorizationDecisionHandlerSpi` interface are customization points. The interface has the methods listed below. See the [JavaDoc][8] of -authlete-java-jaxrs API for details about the requirements of these methods. +authlete-java-jakarta API for details about the requirements of these methods. 1. `boolean isClientAuthorized()` 2. `long getUserAuthenticatedAt()` @@ -301,7 +302,7 @@ the file. The implementation uses [TokenRequestHandler][24] class and delegates the task to handle a token request to `handle()` method of the class. Details about -the class is written in the README file of [authlete-java-jaxrs][6] library. What +the class is written in the README file of [authlete-java-jakarta][6] library. What is important here is that the constructor of the class requires an implementation of [TokenRequestHandlerSpi][25] interface and that the implementation must be provided by you. In other words, the methods in `TokenRequestHandlerSpi` @@ -335,53 +336,72 @@ class TokenRequestHandlerSpiImpl extends TokenRequestHandlerSpiAdapter ``` +Introspection Endpoint +---------------------- + +The implementation of the introspection endpoint is in +[IntrospectionEndpoint.java][34]. + +[RFC 7662][35] (OAuth 2.0 Token Introspection) requires that the endpoint +be protected in some way or other. The implementation of the protection in +IntrospectionEndpoint.java is for demonstration purpose only, +and it is not suitable for commercial use. Therefore, modify the code +accordingly. + + See Also -------- - [Authlete][1] - Authlete Home Page - [authlete-java-common][5] - Authlete Common Library for Java - [authlete-java-common API][7] - JavaDoc of Authlete Common Library for Java -- [authlete-java-jaxrs][6] - Authlete Library for JAX-RS (Java) -- [authlete-java-jaxrs API][8] - JavaDoc of Authlete Library for JAX-RS (Java) +- [authlete-java-jakarta][6] - Authlete Library for Jakarta (Java) +- [authlete-java-jakarta API][8] - JavaDoc of Authlete Library for Jakarta (Java) -Support +Contact ------- -[Authlete, Inc.][1]
-support@authlete.com +| Purpose | Email Address | +|:----------|:---------------------| +| General | info@authlete.com | +| Sales | sales@authlete.com | +| PR | pr@authlete.com | +| Technical | support@authlete.com | [1]: https://www.authlete.com/ -[2]: http://tools.ietf.org/html/rfc6749 -[3]: http://openid.net/connect/ -[4]: https://www.authlete.com/documents/apis +[2]: https://tools.ietf.org/html/rfc6749 +[3]: https://openid.net/connect/ +[4]: https://docs.authlete.com/ [5]: https://github.com/authlete/authlete-java-common -[6]: https://github.com/authlete/authlete-java-jaxrs -[7]: http://authlete.github.io/authlete-java-common/ -[8]: http://authlete.github.io/authlete-java-jaxrs/ -[9]: https://jcp.org/en/jsr/detail?id=339 -[10]: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest -[11]: http://openid.net/specs/openid-connect-core-1_0.html +[6]: https://github.com/authlete/authlete-java-jakarta +[7]: https://authlete.github.io/authlete-java-common/ +[8]: https://authlete.github.io/authlete-java-jakarta/ +[9]: https://jakarta.ee/specifications/restful-ws/ +[10]: https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest +[11]: https://openid.net/specs/openid-connect-core-1_0.html [12]: https://jersey.java.net/ [13]: https://www.authlete.com/documents/so_console/ [14]: ../src/main/java/com/authlete/jaxrs/server/api/AuthorizationEndpoint.java -[15]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/AuthorizationRequestHandler.html -[16]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/spi/AuthorizationRequestHandlerSpi.html -[17]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/spi/AuthorizationRequestHandlerSpi.html#generateAuthorizationPage-com.authlete.common.dto.AuthorizationResponse- +[15]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/AuthorizationRequestHandler.html +[16]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/spi/AuthorizationRequestHandlerSpi.html +[17]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/spi/AuthorizationRequestHandlerSpi.html#generateAuthorizationPage-com.authlete.common.dto.AuthorizationResponse- [18]: ../src/main/java/com/authlete/jaxrs/server/api/AuthorizationRequestHandlerSpiImpl.java -[19]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/spi/AuthorizationRequestHandlerSpiAdapter.html -[20]: http://authlete.github.io/authlete-java-common/com/authlete/common/dto/AuthorizationResponse.html +[19]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/spi/AuthorizationRequestHandlerSpiAdapter.html +[20]: https://authlete.github.io/authlete-java-common/com/authlete/common/dto/AuthorizationResponse.html [21]: ../src/main/webapp/WEB-INF/template/authorization.jsp -[22]: http://authlete.github.io/authlete-java-common/com/authlete/common/types/Display.html +[22]: https://authlete.github.io/authlete-java-common/com/authlete/common/types/Display.html [23]: ../src/main/java/com/authlete/jaxrs/server/api/TokenEndpoint.java -[24]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/TokenRequestHandler.html -[25]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/spi/TokenRequestHandlerSpi.html +[24]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/TokenRequestHandler.html +[25]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/spi/TokenRequestHandlerSpi.html [26]: https://tools.ietf.org/html/rfc6749#section-4.3 [27]: ../src/main/java/com/authlete/jaxrs/server/api/TokenRequestHandlerSpiImpl.java -[28]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/spi/TokenRequestHandlerSpiAdapter.html +[28]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/spi/TokenRequestHandlerSpiAdapter.html [29]: ../src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionEndpoint.java -[30]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/AuthorizationDecisionHandler.html -[31]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/spi/AuthorizationDecisionHandlerSpi.html +[30]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/AuthorizationDecisionHandler.html +[31]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/spi/AuthorizationDecisionHandlerSpi.html [32]: ../src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionHandlerSpiImpl.java -[33]: http://authlete.github.io/authlete-java-jaxrs/com/authlete/jaxrs/spi/AuthorizationDecisionHandlerSpiAdapter.html +[33]: https://authlete.github.io/authlete-java-jakarta/com/authlete/jakarta/spi/AuthorizationDecisionHandlerSpiAdapter.html +[34]: ../src/main/java/com/authlete/jaxrs/server/api/IntrospectionEndpoint.java +[35]: https://tools.ietf.org/html/rfc7662 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8e476d4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +version: "3" +services: + app: + build: . + ports: + - 8080:8080 + volumes: + - .:/authlete/app diff --git a/federations.json b/federations.json new file mode 100644 index 0000000..1bcf649 --- /dev/null +++ b/federations.json @@ -0,0 +1,17 @@ +{ + "federations": [ + { + "id": "okta", + "server": { + "name": "Okta-hosted IdP", + "issuer": "https://YOUR_COMPANY.okta.com" + }, + "client": { + "clientId": "YOUR_CLIENT_ID", + "clientSecret": "YOUR_CLIENT_SECRET", + "redirectUri": "http://localhost:8080/api/federation/callback/okta", + "idTokenSignedResponseAlg": "RS256" + } + } + ] +} \ No newline at end of file diff --git a/jetty/jetty-http.xml b/jetty/jetty-http.xml new file mode 100644 index 0000000..ac05d40 --- /dev/null +++ b/jetty/jetty-http.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pom.xml b/pom.xml index 074af2d..429a158 100644 --- a/pom.xml +++ b/pom.xml @@ -12,15 +12,50 @@ UTF-8 - 2.7-SNAPSHOT - 2.1-SNAPSHOT - 3.0.1 - 2.22.1 - 9.3.7.v20160115 - 3.3 - 2.5 + 25 + + 4.45 + 2.96 + 1.21 + 6.0.0 + 3.0.2 + 3.0.1 + 2.1.1 + 4.0.5 + 4.0.9 + 3.1.11 + 12.1.10 + 3.15.0 + 3.5.1 + + + Java9 + + [9,) + + + + jakarta.annotation + jakarta.annotation-api + ${jakarta.annotation-api.version} + + + jakarta.xml.bind + jakarta.xml.bind-api + ${jakarta.xml.bind-api.version} + + + org.glassfish.jaxb + jaxb-runtime + ${jaxb-runtime.version} + runtime + + + + + com.authlete @@ -30,14 +65,20 @@ com.authlete - authlete-java-jaxrs - ${authlete.java.jaxrs.version} + authlete-java-jakarta + ${authlete.java.jakarta.version} + + + + com.authlete + cbor + ${authlete.cbor.version} - javax.servlet - javax.servlet-api - ${javax.servlet-api.version} + jakarta.servlet + jakarta.servlet-api + ${jakarta.servlet-api.version} provided @@ -64,6 +105,79 @@ jersey-mvc-jsp ${jersey.version} + + + org.glassfish.jersey.inject + jersey-hk2 + ${jersey.version} + + + + jakarta.servlet.jsp.jstl + jakarta.servlet.jsp.jstl-api + ${jakarta.servlet.jsp.jstl-api.version} + + + + org.glassfish.web + jakarta.servlet.jsp.jstl + ${jakarta.servlet.jsp.jstl.version} + + + + org.bouncycastle + bcpkix-jdk18on + 1.84 + + + + com.nimbusds + oauth2-oidc-sdk + 11.37.2 + + + + ch.qos.logback + logback-core + 1.5.34 + + + + ch.qos.logback + logback-classic + 1.5.34 + + + + org.slf4j + slf4j-api + 2.0.18 + + + + com.google.zxing + core + 3.5.4 + + + com.google.zxing + javase + 3.5.4 + + + + com.google.code.gson + gson + 2.14.0 + + + + + junit + junit + 4.13.2 + test + @@ -73,8 +187,7 @@ maven-compiler-plugin ${maven.compiler.plugin.version} - 1.7 - 1.7 + ${maven.compiler.release} -proc:none @@ -91,18 +204,26 @@ - org.eclipse.jetty - jetty-maven-plugin + org.eclipse.jetty.ee10 + jetty-ee10-maven-plugin ${jetty.version} + ${project.basedir}/jetty/jetty-http.xml + - .*/.*jersey-[^/]\.jar$ + ^$ - - 8080 - 9090 stop + + TLS_DHE_RSA_WITH_AES_128_GCM_SHA256,TLS_DHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + diff --git a/src/main/java/com/authlete/jaxrs/server/ServerConfig.java b/src/main/java/com/authlete/jaxrs/server/ServerConfig.java new file mode 100644 index 0000000..02e2b33 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/ServerConfig.java @@ -0,0 +1,439 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server; + + +import com.authlete.jaxrs.server.ad.type.Mode; +import com.authlete.jaxrs.server.util.ServerProperties; + + +/** + * A class for configuration of this server. + * + * @author Hideki Ikeda + */ +public class ServerConfig +{ + /** + * Properties. + */ + private static final ServerProperties sProperties = new ServerProperties(); + + + /** + * Property keys. + */ + private static final String AUTHLETE_AD_BASE_URL_KEY = "authlete.ad.base_url"; + private static final String AUTHLETE_AD_WORKSPACE_KEY = "authlete.ad.workspace"; + private static final String AUTHLETE_AD_MODE_KEY = "authlete.ad.mode"; + private static final String AUTHLETE_AD_SYNC_CONNECT_TIMEOUT_KEY = "authlete.ad.sync.connect_timeout"; + private static final String AUTHLETE_AD_SYNC_ADDITIONAL_READ_TIMEOUT_KEY = "authlete.ad.sync.additional_read_timeout"; + private static final String AUTHLETE_AD_ASYNC_CONNECT_TIMEOUT_KEY = "authlete.ad.async.connect_timeout"; + private static final String AUTHLETE_AD_ASYNC_READ_TIMEOUT_KEY = "authlete.ad.async.read_timeout"; + private static final String AUTHLETE_AD_POLL_CONNECT_TIMEOUT_KEY = "authlete.ad.poll.connect_timeout"; + private static final String AUTHLETE_AD_POLL_RESULT_READ_TIMEOUT_KEY = "authlete.ad.poll.read_timeout"; + private static final String AUTHLETE_AD_POLL_RESULT_CONNECT_TIMEOUT_KEY = "authlete.ad.poll.result.connect_timeout"; + private static final String AUTHLETE_AD_POLL_READ_TIMEOUT_KEY = "authlete.ad.poll.result.read_timeout"; + private static final String AUTHLETE_AD_POLL_MAX_COUNT_KEY = "authlete.ad.poll.max_count"; + private static final String AUTHLETE_AD_POLL_INTERVAL_KEY = "authlete.ad.poll.interval"; + private static final String AUTHLETE_AD_AUTH_TIMEOUT_RATIO_KEY = "authlete.ad.auth_timeout_ratio"; + + + /** + * Default configuration values. + */ + private static final String DEFAULT_AUTHLETE_AD_BASE_URL = "https://cibasim.authlete.com"; + private static final Mode DEFAULT_AUTHLETE_AD_MODE = Mode.SYNC; + private static final int DEFAULT_AUTHLETE_AD_SYNC_CONNECT_TIMEOUT = 10000; // 10000 milliseconds. + private static final int DEFAULT_AUTHLETE_AD_SYNC_ADDITIONAL_READ_TIMEOUT = 10000; // 10000 milliseconds. + private static final int DEFAULT_AUTHLETE_AD_ASYNC_CONNECT_TIMEOUT = 10000; // 10000 milliseconds. + private static final int DEFAULT_AUTHLETE_AD_ASYNC_READ_TIMEOUT = 10000; // 10000 milliseconds. + private static final int DEFAULT_AUTHLETE_AD_POLL_CONNECT_TIMEOUT = 10000; // 10000 milliseconds. + private static final int DEFAULT_AUTHLETE_AD_POLL_READ_TIMEOUT = 10000; // 10000 milliseconds. + private static final int DEFAULT_AUTHLETE_AD_POLL_RESULT_CONNECT_TIMEOUT = 10000; // 10000 milliseconds. + private static final int DEFAULT_AUTHLETE_AD_POLL_RESULT_READ_TIMEOUT = 10000; // 10000 milliseconds. + private static final int DEFAULT_AUTHLETE_AD_POLL_MAX_COUNT = 10; + private static final int DEFAULT_AUTHLETE_AD_POLL_INTERVAL = 5000; // 5000 milliseconds. + private static final float DEFALUT_AUTHLETE_AD_AUTH_TIMEOUT_RATIO = 0.8f; + + + /** + * Determined configuration values. + */ + private static final String AUTHLETE_AD_BASE_URL = sProperties.getString(AUTHLETE_AD_BASE_URL_KEY, DEFAULT_AUTHLETE_AD_BASE_URL); + private static final String AUTHLETE_AD_WORKSPACE = sProperties.getString(AUTHLETE_AD_WORKSPACE_KEY); + private static final Mode AUTHLETE_AD_MODE = determineAuthleteAdMode(); + private static final int AUTHLETE_AD_SYNC_CONNECT_TIMEOUT = sProperties.getInt(AUTHLETE_AD_SYNC_CONNECT_TIMEOUT_KEY, DEFAULT_AUTHLETE_AD_SYNC_CONNECT_TIMEOUT); + private static final int AUTHLETE_AD_SYNC_ADDITIONAL_READ_TIMEOUT = sProperties.getInt(AUTHLETE_AD_SYNC_ADDITIONAL_READ_TIMEOUT_KEY, DEFAULT_AUTHLETE_AD_SYNC_ADDITIONAL_READ_TIMEOUT); + private static final int AUTHLETE_AD_ASYNC_CONNECT_TIMEOUT = sProperties.getInt(AUTHLETE_AD_ASYNC_CONNECT_TIMEOUT_KEY, DEFAULT_AUTHLETE_AD_ASYNC_CONNECT_TIMEOUT); + private static final int AUTHLETE_AD_ASYNC_READ_TIMEOUT = sProperties.getInt(AUTHLETE_AD_ASYNC_READ_TIMEOUT_KEY, DEFAULT_AUTHLETE_AD_ASYNC_READ_TIMEOUT); + private static final int AUTHLETE_AD_POLL_CONNECT_TIMEOUT = sProperties.getInt(AUTHLETE_AD_POLL_CONNECT_TIMEOUT_KEY, DEFAULT_AUTHLETE_AD_POLL_CONNECT_TIMEOUT); + private static final int AUTHLETE_AD_POLL_READ_TIMEOUT = sProperties.getInt(AUTHLETE_AD_POLL_READ_TIMEOUT_KEY, DEFAULT_AUTHLETE_AD_POLL_READ_TIMEOUT); + private static final int AUTHLETE_AD_POLL_RESULT_CONNECT_TIMEOUT = sProperties.getInt(AUTHLETE_AD_POLL_RESULT_CONNECT_TIMEOUT_KEY, DEFAULT_AUTHLETE_AD_POLL_RESULT_CONNECT_TIMEOUT); + private static final int AUTHLETE_AD_POLL_RESULT_READ_TIMEOUT = sProperties.getInt(AUTHLETE_AD_POLL_RESULT_READ_TIMEOUT_KEY, DEFAULT_AUTHLETE_AD_POLL_RESULT_READ_TIMEOUT); + private static final int AUTHLETE_AD_POLL_MAX_COUNT = sProperties.getInt(AUTHLETE_AD_POLL_MAX_COUNT_KEY, DEFAULT_AUTHLETE_AD_POLL_MAX_COUNT); + private static final int AUTHLETE_AD_POLL_INTERVAL = sProperties.getInt(AUTHLETE_AD_POLL_INTERVAL_KEY, DEFAULT_AUTHLETE_AD_POLL_INTERVAL); + private static final float AUTHLETE_AD_AUTH_TIMEOUT_RATIO = sProperties.getFloat(AUTHLETE_AD_AUTH_TIMEOUT_RATIO_KEY, DEFALUT_AUTHLETE_AD_AUTH_TIMEOUT_RATIO); + + + private static Mode determineAuthleteAdMode() + { + String value = sProperties.getString(AUTHLETE_AD_MODE_KEY); + + if ("sync".equals(value)) + { + return Mode.SYNC; + } + else if ("async".equals(value)) + { + return Mode.ASYNC; + } + else if ("poll".equals(value)) + { + return Mode.POLL; + } + else + { + return DEFAULT_AUTHLETE_AD_MODE; + } + } + + + /** + * Get the base URL of + * Authlete CIBA authentication device simulator API. + * + * @return + * The base URL of + * Authlete CIBA authentication device simulator API. + * + * @see Authlete CIBA authentication device simulator API + */ + public static String getAuthleteAdBaseUrl() + { + return AUTHLETE_AD_BASE_URL; + } + + + /** + * Get the workspace for which end-user authentication and authorization is + * performed on Authlete CIBA authentication device simulator. + * + * @return + * The workspace for which end-user authentication and authorization is + * performed on Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication device simulator + * + * @see Authlete CIBA authentication device simulator API + */ + public static String getAuthleteAdWorkspace() + { + return AUTHLETE_AD_WORKSPACE; + } + + + /** + * Get the mode in which the authorization server communicates with + * Authlete CIBA authentication device simulator. + * Possible values are 'sync', 'async' and 'poll'. The default value is 'sync'. + * For more details, see + * API document for CIBA authentication device simulator. + * + * @return + * The mode in which the authorization server communicates with + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication device simulator + * + * @see Authlete CIBA authentication device simulator API + */ + public static Mode getAuthleteAdMode() + { + return AUTHLETE_AD_MODE; + } + + + /** + * Get the connect timeout value (in milliseconds) used when the authorization + * server makes a request to + * /api/authenticate/sync API of + * Authlete CIBA authentication device simulator. + * + * @return + * The connect timeout value (in milliseconds) used when the authorization + * server makes a request to + * /api/authenticate/sync API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication device simulator + * + * @see /api/authenticate/sync API + */ + public static int getAuthleteAdSyncConnectTimeout() + { + return AUTHLETE_AD_SYNC_CONNECT_TIMEOUT; + } + + + /** + * Get the value (in milliseconds) that is used to compute the read timeout + * value used when the authorization server makes a request to + * /api/authenticate/sync API of + * Authlete CIBA authentication device simulator. + * + *

+ * The read timeout value is computed as follows. + *

+ * + *

+ * (read timeout) = (the duration of an 'auth_req_id' in milliseconds) + (the value returned by this method) + *

+ * + * For more details, see {@link com.authlete.jaxrs.server.ad.AuthenticationDevice#sync(String, String, int, String) + * syncAuth} method in {@link com.authlete.jaxrs.server.ad.AuthenticationDevice + * AuthenticationDevice}. + * + * @return + * The value (in milliseconds) that is used to compute the read timeout + * value used when the authorization server makes a request to + * /api/authenticate/sync API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication device simulator + * + * @see /api/authenticate/sync API + * + * @see {@link com.authlete.jaxrs.server.ad.AuthenticationDevice AuthenticationDevice}. + */ + public static int getAuthleteAdSyncAdditionalReadTimeout() + { + return AUTHLETE_AD_SYNC_ADDITIONAL_READ_TIMEOUT; + } + + + /** + * Get the connect timeout value (in milliseconds) used when the authorization + * server makes a request to + * /api/authenticate/async API of + * Authlete CIBA authentication device simulator. + * + * @return + * The connect timeout value (in milliseconds) used when the authorization + * server makes a request to + * /api/authenticate/async API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication device simulator + * + * @see /api/authenticate/async API + */ + public static int getAuthleteAdAsyncConnectTimeout() + { + return AUTHLETE_AD_ASYNC_CONNECT_TIMEOUT; + } + + + /** + * Get the read timeout value (in milliseconds) used when the authorization + * server makes a request to + * /api/authenticate/async API of + * Authlete CIBA authentication device simulator. + * + * @return + * The read timeout value (in milliseconds) used when the authorization + * server makes a request to + * /api/authenticate/async API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication device simulator + * + * @see /api/authenticate/async API + */ + public static int getAuthleteAdAsyncReadTimeout() + { + return AUTHLETE_AD_ASYNC_READ_TIMEOUT; + } + + + /** + * Get the connect timeout value (in milliseconds) used when the authorization + * server makes a request to + * /api/authenticate/poll API of + * Authlete CIBA authentication device simulator. + * + * @return + * The connect timeout value (in milliseconds) used when the authorization + * server makes a request to + * /api/authenticate/poll API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication device simulator + * + * @see /api/authenticate/poll API + */ + public static int getAuthleteAdPollConnectTimeout() + { + return AUTHLETE_AD_POLL_CONNECT_TIMEOUT; + } + + + /** + * Get the read timeout value (in milliseconds) used when the authorization + * server makes a request to + * /api/authenticate/poll API of + * Authlete CIBA authentication device simulator. + * + * @return + * The read timeout value (in milliseconds) used when the authorization + * server makes a request to + * /api/authenticate/poll API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication device simulator + * + * @see /api/authenticate/poll API + */ + public static int getAuthleteAdPollReadTimeout() + { + return AUTHLETE_AD_POLL_READ_TIMEOUT; + } + + + /** + * Get the connect timeout value (in milliseconds) used when the authorization + * server makes a request to + * /api/authenticate/result API of + * Authlete CIBA authentication device simulator. + * + * @return + * The connect timeout value (in milliseconds) used when the authorization + * server makes a request to + * /api/authenticate/result API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication device simulator + * + * @see /api/authenticate/poll API + */ + public static int getAuthleteAdPollResultConnectTimeout() + { + return AUTHLETE_AD_POLL_RESULT_CONNECT_TIMEOUT; + } + + + /** + * Get the read timeout value (in milliseconds) used when the authorization + * server makes a request to + * /api/authenticate/result API of + * Authlete CIBA authentication device simulator. + * + * @return + * The read timeout value (in milliseconds) used when the authorization + * server makes a request to + * /api/authenticate/result API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication device simulator + * + * @see /api/authenticate/poll API + */ + public static int getAuthleteAdPollResultReadTimeout() + { + return AUTHLETE_AD_POLL_RESULT_READ_TIMEOUT; + } + + + /** + * Get the maximum number of polling trials against + * /api/authenticate/result API of + * Authlete CIBA authentication device simulator. + * + * @return + * The maximum number of polling trials against + * /api/authenticate/result API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication device simulator + * + * @see /api/authenticate/poll API + */ + public static int getAuthleteAdPollMaxCount() + { + return AUTHLETE_AD_POLL_MAX_COUNT; + } + + + /** + * Get the period of time in milliseconds for which this authorization server + * waits between polling trials against + * /api/authenticate/result API of + * Authlete CIBA authentication device simulator. + * + * @return + * The period of time in milliseconds for which this authorization + * server waits between polling trials against + * /api/authenticate/result API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication device simulator + * + * @see /api/authenticate/poll API + */ + public static int getAuthleteAdPollInterval() + { + return AUTHLETE_AD_POLL_INTERVAL; + } + + + /** + * Get the ratio of timeout for end-user authentication/authorization on the + * authentication device (Authlete CIBA + * authentication device simulator) to the duration of an 'auth_req_id'. + * Must be specified between 0.0 and 1.0. + * + *

+ * This value is used to compute the timeout value for end-user authentication/authorization + * on the authentication device based on the duration of an 'auth_req_id' + * as below. + *

+ * + *

+ * (timeout for end-user authentication/authorization on the authentication + * device in seconds) = (the value returned by this method) * (the duration + * of an 'auth_req_id' in seconds) + *

+ * + * For more details, see {@link com.authlete.jaxrs.server.api.backchannel.BaseAuthenticationDeviceProcessor#computeAuthTimeout + * computeAuthTimeout()} method in {@link com.authlete.jaxrs.server.api.backchannel.BaseAuthenticationDeviceProcessor + * BaseAuthenticationDeviceProcessor}. + * + * @return + * The ratio of timeout for end-user authentication/authorization on + * the authentication device (Authlete + * CIBA authentication device simulator) to the duration + * of an 'auth_req_id'. + * + * @see Authlete CIBA authentication device simulator + */ + public static float getAuthleteAdAuthTimeoutRatio() + { + return AUTHLETE_AD_AUTH_TIMEOUT_RATIO; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/ad/AuthenticationDevice.java b/src/main/java/com/authlete/jaxrs/server/ad/AuthenticationDevice.java new file mode 100644 index 0000000..3eab43e --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/ad/AuthenticationDevice.java @@ -0,0 +1,270 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.ad; + + +import static org.glassfish.jersey.client.ClientProperties.CONNECT_TIMEOUT; +import static org.glassfish.jersey.client.ClientProperties.READ_TIMEOUT; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.Entity; +import org.glassfish.jersey.client.ClientConfig; +import com.authlete.jaxrs.server.ServerConfig; +import com.authlete.jaxrs.server.ad.dto.AsyncAuthenticationRequest; +import com.authlete.jaxrs.server.ad.dto.AsyncAuthenticationResponse; +import com.authlete.jaxrs.server.ad.dto.PollAuthenticationRequest; +import com.authlete.jaxrs.server.ad.dto.PollAuthenticationResponse; +import com.authlete.jaxrs.server.ad.dto.PollAuthenticationResultRequest; +import com.authlete.jaxrs.server.ad.dto.PollAuthenticationResultResponse; +import com.authlete.jaxrs.server.ad.dto.SyncAuthenticationRequest; +import com.authlete.jaxrs.server.ad.dto.SyncAuthenticationResponse; + + +/** + * A class to communicate with + * Authlete CIBA authentication device simulator API. + * + * @see Authlete CIBA authentication + * device simulator + * + * @see Authlete CIBA authentication device simulator API + * + * @author Hideki Ikeda + */ +public class AuthenticationDevice +{ + /** + * The limit values for end-user authentication/authorization timeout defined + * by Authlete + * CIBA authentication device simulator API. + */ + public static final int AUTH_TIMEOUT_MIN = 5; + public static final int AUTH_TIMEOUT_MAX = 60; + + + /** + * Authlete CIBA authentication simulator API endpoints. + */ + private static final String SYNC_ENDPOINT_PATH = "/api/authenticate/sync"; + private static final String ASYNC_ENDPOINT_PATH = "/api/authenticate/async"; + private static final String POLL_ENDPOINT_PATH = "/api/authenticate/poll"; + private static final String POLL_RESULT_ENDPOINT_PATH = "/api/authenticate/result"; + + + /** + * Parameters required to communicate with the authentication device simulator. + */ + private static final String sBaseUrl = ServerConfig.getAuthleteAdBaseUrl(); + private static final String sWorkspace = ServerConfig.getAuthleteAdWorkspace(); + private static final int sSyncConnectTimeout = ServerConfig.getAuthleteAdSyncConnectTimeout(); + private static final int sSyncAdditionalReadTimeout = ServerConfig.getAuthleteAdSyncAdditionalReadTimeout(); + private static final int sAsyncConnectTimeout = ServerConfig.getAuthleteAdAsyncConnectTimeout(); + private static final int sAsyncReadTimeout = ServerConfig.getAuthleteAdAsyncReadTimeout(); + private static final int sPollConnectTimeout = ServerConfig.getAuthleteAdPollConnectTimeout(); + private static final int sPollReadTimeout = ServerConfig.getAuthleteAdPollReadTimeout(); + private static final int sPollResultConnectTimeout = ServerConfig.getAuthleteAdPollResultConnectTimeout(); + private static final int sPollResultReadTimeout = ServerConfig.getAuthleteAdPollResultReadTimeout(); + + + private static Client createClient(int readTimeout, int connectTimeout) + { + // Client configuration. + ClientConfig config = new ClientConfig(); + + // Read timeout. + config.property(READ_TIMEOUT, readTimeout); + + // Connect timeout. + config.property(CONNECT_TIMEOUT, connectTimeout); + + // The client that synchronously communicates with the authentication device simulator. + return ClientBuilder.newClient(config); + } + + + /** + * Send a request to the authentication device simulator for end-user authentication + * and authorization in synchronous mode. + * + * @param subject + * The subject of the end-user to be authenticated and asked to authorize + * the client application. + * + * @param message + * A message to be shown to the end-user on the authentication device. + * + * @param authTimeout + * The value of timeout in seconds for the end-user authentication/authorization + * on the authentication device. + * + * @param actionizeToken + * A token that is used with the actionize endpoint ({@code /api/atuhenticate/actionize}) + * to automate authentication device responses. + * + * @return + * A response from the authentication device. + */ + public static SyncAuthenticationResponse sync(String subject, String message, + int authTimeout, String actionizeToken) + { + // Determine the read timeout in milliseconds based on the value of the + // authentication timeout. This should be a bit longer than the timeout + // for end-user authentication/authorization. + int readTimeout = authTimeout * 1000 + sSyncAdditionalReadTimeout; + + // Create a web client to communicate with the authentication device. + Client client = createClient(readTimeout, sSyncConnectTimeout); + + // A request to be sent to the authentication device. + SyncAuthenticationRequest request = new SyncAuthenticationRequest() + .setWorkspace(sWorkspace) + .setUser(subject) + .setMessage(message) + .setTimeout(authTimeout) + .setActionizeToken(actionizeToken); + + // Send the request as an HTTP POST request. + return post(client, SYNC_ENDPOINT_PATH, request, SyncAuthenticationResponse.class); + } + + + /** + * Send a request to the authentication device simulator for for end-user authentication + * and authorization in asynchronous mode. + * + * @param subject + * The subject of the end-user to be authenticated and asked to authorize + * the client application. + * + * @param message + * A message to be shown to the end-user on the authentication device. + * + * @param authTimeout + * The value of timeout in seconds for the end-user authentication/authorization + * on the authentication device. + * + * @param actionizeToken + * A token that is used with the actionize endpoint ({@code /api/atuhenticate/actionize}) + * to automate authentication device responses. + * + * @return + * A response from the authentication device simulator. + */ + public static AsyncAuthenticationResponse async(String subject, String message, + int authTimeout, String actionizeToken) + { + // Create a web client to communicate with the authentication device. + Client client = createClient(sAsyncReadTimeout, sAsyncConnectTimeout); + + // A request to be sent to the authentication device. + AsyncAuthenticationRequest request = new AsyncAuthenticationRequest() + .setWorkspace(sWorkspace) + .setUser(subject) + .setMessage(message) + .setTimeout(authTimeout) + .setActionizeToken(actionizeToken); + + // Send the request as an HTTP POST request. + return post(client, ASYNC_ENDPOINT_PATH, request, AsyncAuthenticationResponse.class); + } + + + /** + * Send a request to the authentication device simulator for end-user authentication + * and authorization in poll mode. + * + * @param subject + * The subject of the end-user to be authenticated and asked to authorize + * the client application. + * + * @param message + * A message to be shown to the end-user on the authentication device. + * + * @param authTimeout + * The value of timeout in seconds for the end-user authentication/authorization + * on the authentication device. + * + * @param actionizeToken + * A token that is used with the actionize endpoint ({@code /api/atuhenticate/actionize}) + * to automate authentication device responses. + * + * @return + * A response from the authentication device simulator. + */ + public static PollAuthenticationResponse poll(String subject, String message, + int authTimeout, String actionizeToken) + { + // Create a web client to communicate with the authentication device. + Client client = createClient(sPollReadTimeout, sPollConnectTimeout); + + // A request to be sent to the authentication device. + PollAuthenticationRequest request = new PollAuthenticationRequest() + .setWorkspace(sWorkspace) + .setUser(subject) + .setMessage(message) + .setTimeout(authTimeout) + .setActionizeToken(actionizeToken); + + // Send the request as an HTTP POST request. + return post(client, POLL_ENDPOINT_PATH, request, PollAuthenticationResponse.class); + } + + + /** + * Send a request to the authentication device simulator for fetching the result + * of the end-user authentication and authorization in poll mode. + * + * @param requestId + * A request ID that was returned from the authentication simlator's + * poll result endpoint ({@code /api/authenticate/result}). + * + * @return + * A response from the authentication device simulator. + */ + public static PollAuthenticationResultResponse pollResult(String requestId) + { + // Create a web client to communicate with the authentication device. + Client client = createClient(sPollResultReadTimeout, sPollResultConnectTimeout); + + // A request to be sent to the authentication device. + PollAuthenticationResultRequest request = new PollAuthenticationResultRequest() + .setRequestId(requestId); + + // Send the request as an HTTP POST request. + return post(client, POLL_RESULT_ENDPOINT_PATH, request, PollAuthenticationResultResponse.class); + } + + + private static TResponse post(Client client, String path, + TRequest request, Class responseClass) + { + try + { + // Send the request to the authentication device as a HTTP Post request. + return client + .target(sBaseUrl) + .path(path) + .request() + .post(Entity.json(request), responseClass); + } + finally + { + // Close the client. + client.close(); + } + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/ad/dto/AsyncAuthenticationCallbackRequest.java b/src/main/java/com/authlete/jaxrs/server/ad/dto/AsyncAuthenticationCallbackRequest.java new file mode 100644 index 0000000..f209c51 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/ad/dto/AsyncAuthenticationCallbackRequest.java @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.ad.dto; + + +import java.io.Serializable; +import jakarta.xml.bind.annotation.XmlElement; +import com.authlete.jaxrs.server.ad.type.Result; + + +/** + * The class representing a callback request that is made from + * Authlete CIBA authentication device simulator when it is used in asynchronous + * mode. + * + *

+ * Note that, before the authorization server receives this callback request from + * the authentication device simulator, it is assumed that the authorization server + * has made a request to the authentication device simulator's + * /api/authenticate/async API for end-user authentication and authorization. + * This callback request contains the result of the end-user authentication and + * authorization. + *

+ * + * @see Authlete CIBA authentication + * device simulator + * + * @see + * /api/authenticate/async API + * + * @author Hideki Ikeda + */ +public class AsyncAuthenticationCallbackRequest implements Serializable +{ + private static final long serialVersionUID = 1L; + + + @XmlElement(name = "request_id") + private String requestId; + private Result result; + private String state; + + + /** + * Get the ID of a request that the authorization server has made to the authentication + * device simulator's + * /api/authenticate/async API before the authorization server receives + * this callback request from the authentication device simulator. + * + * @return + * The ID of a request that the authorization server has made to the + * authentication device simulator's + * /api/authenticate/async API before the authorization server + * receives this callback request from the authentication device simulator. + * + * @see + * /api/authenticate/async API + */ + public String getRequestId() + { + return requestId; + } + + + /** + * Set the ID of a request that the authorization server has made to the authentication + * device simulator's + * /api/authenticate/async API before the authorization server receives + * this callback request from the authentication device simulator. + * + * @param requestId + * The ID of a request that the authorization server has made to the + * authentication device simulator's + * /api/authenticate/async API before the authorization server + * receives this callback request from the authentication device simulator. + * + * @see + * /api/authenticate/async API + * + * @return + * {@code this} object. + */ + public AsyncAuthenticationCallbackRequest setRequestId(String requestId) + { + this.requestId = requestId; + + return this; + } + + + /** + * Get the result of end-user authentication and authorization. + * + * @return + * The result of end-user authentication and authorization + */ + public Result getResult() + { + return result; + } + + + /** + * Set the result of end-user authentication and authorization. + * + * @param result + * The result of end-user authentication and authorization + * + * @return + * {@code this} object. + */ + public AsyncAuthenticationCallbackRequest setResult(Result result) + { + this.result = result; + + return this; + } + + + /** + * Get the value of the {@code state} parameter that was included in a request + * that the authorization server made to the authentication device simulator's + * + * /api/authenticate/async API before the authorization server receives + * this callback request from the authentication device simulator. + * + * @return + * The value of the {@code state} parameter that was included in a request + * that the authorization server made to the authentication device simulator's + * + * /api/authenticate/async API before the authorization server + * receives this callback request from the authentication device simulator. + * + * @see + * /api/authenticate/async API + */ + public String getState() + { + return state; + } + + + /** + * Set the value of the {@code state} parameter that was included in a request + * that the authorization server made to the authentication device simulator's + * + * /api/authenticate/async API before the authorization server receives + * this callback request from the authentication device simulator. + * + * @param state + * The value of the {@code state} parameter that was included in a request + * that the authorization server made to the authentication device simulator's + * + * /api/authenticate/async API before the authorization server + * receives this callback request from the authentication device simulator. + * + * @return + * {@code this} object. + * + * @see + * /api/authenticate/async API + */ + public AsyncAuthenticationCallbackRequest setState(String state) + { + this.state = state; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/ad/dto/AsyncAuthenticationRequest.java b/src/main/java/com/authlete/jaxrs/server/ad/dto/AsyncAuthenticationRequest.java new file mode 100644 index 0000000..358369e --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/ad/dto/AsyncAuthenticationRequest.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.ad.dto; + + +/** + * A class representing a request to + * /api/authenticate/async API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication + * device simulator + * + * @see + * /api/authenticate/async API + * + * @author Hideki Ikeda + */ +public class AsyncAuthenticationRequest extends BaseAuthenticationRequest +{ + private static final long serialVersionUID = 1L; + + + private String state; + + + /** + * Get the value of {@code state} request parameter. + * + * @return + * The value of {@code state} request parameter + */ + public String getState() + { + return state; + } + + + /** + * Set the value of {@code state} request parameter. + * + *

+ * Arbitrary data can be set to this request parameter and the data will be + * sent to the callback endpoint with the result of end-user authentication + * and authorization. + *

+ * + * @param state + * Arbitrary data that will be sent to the callback endpoint with the + * result of end-user authentication and authorization. + * + * @return + * {@code this} object. + */ + public AsyncAuthenticationRequest setState(String state) + { + this.state = state; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/ad/dto/AsyncAuthenticationResponse.java b/src/main/java/com/authlete/jaxrs/server/ad/dto/AsyncAuthenticationResponse.java new file mode 100644 index 0000000..493d295 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/ad/dto/AsyncAuthenticationResponse.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.ad.dto; + + +import java.io.Serializable; +import jakarta.xml.bind.annotation.XmlElement; + + +/** + * A class representing a response from + * /api/authenticate/async API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication + * device simulator + * + * @see + * /api/authenticate/async API + * + * @author Hideki Ikeda + */ +public class AsyncAuthenticationResponse implements Serializable +{ + private static final long serialVersionUID = 1L; + + + @XmlElement(name = "request_id") + private String requestId; + + + /** + * Get the ID of the request corresponding to this response. + * + * @return + * The ID of the request corresponding to this response. + */ + public String getRequestId() + { + return requestId; + } + + + /** + * Set the ID of the request corresponding to this response. + * + * @param requestId + * The ID of the request corresponding to this response. + * + * @return + * {@code this} object. + */ + public AsyncAuthenticationResponse setRequestId(String requestId) + { + this.requestId = requestId; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/ad/dto/BaseAuthenticationRequest.java b/src/main/java/com/authlete/jaxrs/server/ad/dto/BaseAuthenticationRequest.java new file mode 100644 index 0000000..c0c953c --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/ad/dto/BaseAuthenticationRequest.java @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.ad.dto; + + +import java.io.Serializable; +import jakarta.xml.bind.annotation.XmlElement; + + +/** + * A base class for a request to an + * Authlete CIBA authentication device simulator API. + * + * @param + * Request type. + * + * @see Authlete + * CIBA authentication device simulator API + * + * @author Hideki Ikeda + */ +public class BaseAuthenticationRequest> implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private String workspace; + private String user; + private String message; + private int timeout; + + @XmlElement(name = "actionize_token") + private String actionizeToken; + + + /** + * Get the workspace for which end-user authentication and authorization is + * performed. + * + * @return + * The workspace for which end-user authentication and authorization + * is performed. + */ + public String getWorkspace() + { + return workspace; + } + + + /** + * Set the workspace for which end-user authentication and authorization is + * performed. + * + * @param workspace + * The workspace for which end-user authentication and authorization + * is performed. + * + * @return + * {@code this} object. + */ + @SuppressWarnings("unchecked") + public T setWorkspace(String workspace) + { + this.workspace = workspace; + + return (T)this; + } + + + /** + * Get the ID of an end-user to be authenticated and asked to authorize the + * client application. + * + * @return + * The ID of an end-user to be authenticated and asked to authorize + * the client application. + */ + public String getUser() + { + return user; + } + + + /** + * Set the ID of an end-user to be authenticated and asked to authorize the + * client application. + * + * @param user + * The ID of an end-user to be authenticated and asked to authorize + * the client application. + * + * @return + * {@code this} object. + */ + @SuppressWarnings("unchecked") + public T setUser(String user) + { + this.user = user; + + return (T)this; + } + + + /** + * Get a message to be shown to the end-user on the authentication device. + * + * @return + * A message to be shown to the end-user on the authentication device. + */ + public String getMessage() + { + return message; + } + + + /** + * Set a message to be shown to the end-user on the authentication device. + * + * @param message + * A message to be shown to the end-user on the authentication device. + * + * @return + * {@code this} object. + */ + @SuppressWarnings("unchecked") + public T setMessage(String message) + { + this.message = message; + + return (T)this; + } + + + /** + * Get the authentication/authorization timeout value in seconds. + * + *

+ * The authentication device waits for this timeout value to get authorization + * decision from an end-user. + *

+ * + * @return + * The authentication/authorization timeout value in seconds. + */ + public int getTimeout() + { + return timeout; + } + + + /** + * Set the authentication/authorization timeout value in seconds. + * + *

+ * The authentication device waits for this timeout value to get authorization + * decision from an end-user. + *

+ * + * @param timeout + * The authentication/authorization timeout value in seconds. + * + * @return + * {@code this} object. + */ + @SuppressWarnings("unchecked") + public T setTimeout(int timeout) + { + this.timeout = timeout; + + return (T)this; + } + + + /** + * Get a token that is used with the actionize endpoint ({@code /api/atuhenticate/actionize}) + * to automate authentication device responses. + * + * @return + * A token that is used with the actionize endpoint ({@code /api/atuhenticate/actionize}) + * to automate authentication device responses. + */ + public String getActionizeToken() + { + return actionizeToken; + } + + + /** + * Set a token that is used with the actionize endpoint ({@code /api/atuhenticate/actionize}) + * to automate authentication device responses. + * + * @param actionizeToken + * A token that is used with the actionize endpoint ({@code /api/atuhenticate/actionize}) + * to automate authentication device responses. + * + * @return + * {@code this} object. + */ + @SuppressWarnings("unchecked") + public T setActionizeToken(String actionizeToken) + { + this.actionizeToken = actionizeToken; + + return (T)this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/ad/dto/PollAuthenticationRequest.java b/src/main/java/com/authlete/jaxrs/server/ad/dto/PollAuthenticationRequest.java new file mode 100644 index 0000000..4c2dc14 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/ad/dto/PollAuthenticationRequest.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.ad.dto; + + +/** + * A class representing a request to + * /api/authenticate/poll API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication + * device simulator + * + * @see + * /api/authenticate/poll API + * + * @author Hideki Ikeda + */ +public class PollAuthenticationRequest extends BaseAuthenticationRequest +{ + private static final long serialVersionUID = 1L; +} diff --git a/src/main/java/com/authlete/jaxrs/server/ad/dto/PollAuthenticationResponse.java b/src/main/java/com/authlete/jaxrs/server/ad/dto/PollAuthenticationResponse.java new file mode 100644 index 0000000..f509e6a --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/ad/dto/PollAuthenticationResponse.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.ad.dto; + + +import java.io.Serializable; +import jakarta.xml.bind.annotation.XmlElement; + + +/** + * A class representing a response from + * /api/authenticate/poll API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication + * device simulator + * + * @see + * /api/authenticate/poll API + * + * @author Hideki Ikeda + */ +public class PollAuthenticationResponse implements Serializable +{ + private static final long serialVersionUID = 1L; + + + @XmlElement(name = "request_id") + private String requestId; + + + /** + * Get the ID of the request corresponding to this response. + * + * @return + * The ID of the request corresponding to this response. + */ + public String getRequestId() + { + return requestId; + } + + + /** + * Set the ID of the request corresponding to this response. + * + * @param requestId + * The ID of the request corresponding to this response. + * + * @return + * {@code this} object. + */ + public PollAuthenticationResponse setRequestId(String requestId) + { + this.requestId = requestId; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/ad/dto/PollAuthenticationResultRequest.java b/src/main/java/com/authlete/jaxrs/server/ad/dto/PollAuthenticationResultRequest.java new file mode 100644 index 0000000..6adc7f9 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/ad/dto/PollAuthenticationResultRequest.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.ad.dto; + + +import java.io.Serializable; +import jakarta.xml.bind.annotation.XmlElement; + + +/** + * A class representing a request to + * /api/authenticate/result API of + * Authlete CIBA authentication device simulator. + * + *

+ * Note that it is assumed that the authorization server has made a request to the + * authentication device simulator's + * /api/authenticate/poll API for end-user authentication and authorization + * before the authorization server makes this request to the authentication device + * simulator. + *

+ * + * @see Authlete CIBA authentication + * device simulator + * + * @see + * /api/authenticate/result API + * + * @see + * /api/authenticate/poll API + * + * @author Hideki Ikeda + */ +public class PollAuthenticationResultRequest implements Serializable +{ + private static final long serialVersionUID = 1L; + + + @XmlElement(name = "request_id") + private String requestId; + + + /** + * Get the ID of a request that the authorization server has made to the authentication + * device simulator's + * /api/authenticate/poll API before the authorization server makes this + * request to the authentication device simulator. + * + * @return + * The ID of a request that the authorization server has made to the + * authentication device simulator's + * /api/authenticate/poll API before the authorization server + * makes this request to the authentication device simulator. + * + * @see + * /api/authenticate/poll API + */ + public String getRequestId() + { + return requestId; + } + + + /** + * Set the ID of a request that the authorization server has made to the authentication + * device simulator's + * /api/authenticate/poll API before the authorization server makes this + * request to the authentication device simulator. + * + * @param requestId + * The ID of a request that the authorization server has made to the + * authentication device simulator's + * /api/authenticate/poll API before the authorization server + * makes this request to the authentication device simulator. + * + * @return + * {@code this} object. + * + * @see + * /api/authenticate/poll API + */ + public PollAuthenticationResultRequest setRequestId(String requestId) + { + this.requestId = requestId; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/ad/dto/PollAuthenticationResultResponse.java b/src/main/java/com/authlete/jaxrs/server/ad/dto/PollAuthenticationResultResponse.java new file mode 100644 index 0000000..eb0b1ae --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/ad/dto/PollAuthenticationResultResponse.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.ad.dto; + + +import java.io.Serializable; +import com.authlete.jaxrs.server.ad.type.Result; +import com.authlete.jaxrs.server.ad.type.Status; + + +/** + * A class representing a request from + * /api/authenticate/result API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication + * device simulator + * + * @see + * /api/authenticate/result API + * + * @author Hideki Ikeda + */ +public class PollAuthenticationResultResponse implements Serializable +{ + private static final long serialVersionUID = 1L; + + + Status status; + Result result; + + + /** + * Get the status of end-user authentication and authorization. + * + * @return + * The status of end-user authentication and authorization. + */ + public Status getStatus() + { + return status; + } + + + /** + * Set the status of end-user authentication and authorization. + * + * @param status + * The status of end-user authentication and authorization. + */ + public PollAuthenticationResultResponse setStatus(Status status) + { + this.status = status; + + return this; + } + + + /** + * Get the result of end-user authentication and authorization. + * + * @return + * The result of end-user authentication and authorization. {@code null} + * is returned if the end-user authentication and authorization has + * not completed yet. + */ + public Result getResult() + { + return result; + } + + + /** + * Set the result of end-user authentication and authorization. + * + * @param result + * The result of end-user authentication and authorization. + * + * @return + * {@code this} object. + */ + public PollAuthenticationResultResponse setResult(Result result) + { + this.result = result; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/ad/dto/SyncAuthenticationRequest.java b/src/main/java/com/authlete/jaxrs/server/ad/dto/SyncAuthenticationRequest.java new file mode 100644 index 0000000..3f5dbe7 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/ad/dto/SyncAuthenticationRequest.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.ad.dto; + + +/** + * A class representing a request to + * /api/authenticate/sync API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication + * device simulator + * + * @see + * /api/authenticate/sync API + * + * @author Hideki Ikeda + */ +public class SyncAuthenticationRequest extends BaseAuthenticationRequest +{ + private static final long serialVersionUID = 1L; +} diff --git a/src/main/java/com/authlete/jaxrs/server/ad/dto/SyncAuthenticationResponse.java b/src/main/java/com/authlete/jaxrs/server/ad/dto/SyncAuthenticationResponse.java new file mode 100644 index 0000000..56b84f8 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/ad/dto/SyncAuthenticationResponse.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.ad.dto; + + +import java.io.Serializable; +import com.authlete.jaxrs.server.ad.type.Result; + + +/** + * A class representing a response from + * /api/authenticate/sync API of + * Authlete CIBA authentication device simulator. + * + * @see Authlete CIBA authentication + * device simulator + * + * @see + * /api/authenticate/sync API + * + * @author Hideki Ikeda + */ +public class SyncAuthenticationResponse implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private Result result; + + + /** + * Get the result of end-user authentication and authorization. + * + * @return + * The result of end-user authentication and authorization. + */ + public Result getResult() + { + return result; + } + + + /** + * Set the result of end-user authentication and authorization. + * + * @param result + * The result of end-user authentication and authorization. + * + * @return + * {@code this} object. + */ + public SyncAuthenticationResponse setResult(Result result) + { + this.result = result; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/ad/type/Mode.java b/src/main/java/com/authlete/jaxrs/server/ad/type/Mode.java new file mode 100644 index 0000000..7bbdbc5 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/ad/type/Mode.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.ad.type; + + +/** + * The communication mode of Authlete + * CIBA authentication device simulator. + * + * @see Authlete CIBA authentication device + * simulator + * + * @see Authlete + * CIBA authentication device simulator API + * + * @author Hideki Ikeda + */ +public enum Mode +{ + /** + * The synchronous mode. + */ + SYNC, + + + /** + * The asynchronous mode. + */ + ASYNC, + + + /** + * The poll mode. + */ + POLL + ; +} diff --git a/src/main/java/com/authlete/jaxrs/server/ad/type/Result.java b/src/main/java/com/authlete/jaxrs/server/ad/type/Result.java new file mode 100644 index 0000000..02852ac --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/ad/type/Result.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.ad.type; + + +/** + * Result of end-user authentication and authorization returned from + * Authlete CIBA authentication device simulator + * + * @see Authlete CIBA authentication device + * simulator + * + * @see Authlete + * CIBA authentication device simulator API + * + * @author Hideki Ikeda + */ +public enum Result +{ + /** + * The result showing that an end-user authorized a client application's request. + */ + allow, + + + /** + * The result showing that an end-user denied a client application's request. + */ + deny, + + + /** + * The result showing that timeout occurred during end-user authentication and + * authorization process. + */ + timeout + ; +} diff --git a/src/main/java/com/authlete/jaxrs/server/ad/type/Status.java b/src/main/java/com/authlete/jaxrs/server/ad/type/Status.java new file mode 100644 index 0000000..edea8ff --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/ad/type/Status.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.ad.type; + + +/** + * Status of end-user authentication and authorization on + * Authlete CIBA authentication device simulator + * when it is used in poll mode. + * + * @see Authlete CIBA authentication device + * simulator + * + * @see Authlete + * CIBA authentication device simulator API + * + * @author Hideki Ikeda + */ +public enum Status +{ + /** + * The status showing that end-user authentication and authorization is being + * processed. + */ + active, + + + /** + * The status showing that end-user authentication and authorization process + * has completed. + */ + complete, + + + /** + * The status showing that timeout occurred during end-user authentication + * and authorization process + */ + timeout + ; +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/AppleAppSiteAssociation.java b/src/main/java/com/authlete/jaxrs/server/api/AppleAppSiteAssociation.java new file mode 100644 index 0000000..6047853 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/AppleAppSiteAssociation.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2016 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api; + + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + + +/** + * Allow our mobile app to claim the authorization endpoint + * + * See: + * + * https://openid.net/2019/10/21/guest-blog-implementing-app-to-app-authorisation-in-oauth2-openid-connect/ + * + * https://developer.apple.com/documentation/uikit/inter-process_communication/allowing_apps_and_websites_to_link_to_your_content/enabling_universal_links + */ +@Path("/.well-known/apple-app-site-association") +public class AppleAppSiteAssociation +{ + /** + * OpenID Provider configuration endpoint. + */ + @GET + public Response get() + { + String json = + "{\n" + + " \"applinks\": {\n" + + " \"apps\": [],\n" + + " \"details\": [{\n" + + " \"appID\": \"337ZW7BQW9.com.authlete.fapidev-app2app\",\n" + + " \"paths\": [\"/api/authorization\"]\n" + + " }]\n" + + " }\n" + + "}\n"; + return Response + .status(Response.Status.OK) + .entity(json).type(MediaType.APPLICATION_JSON_TYPE) + .build(); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionEndpoint.java index 1068d25..fc026ca 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 Authlete, Inc. + * Copyright (C) 2016-2025 Authlete, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,23 +17,25 @@ package com.authlete.jaxrs.server.api; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpSession; -import javax.ws.rs.Consumes; -import javax.ws.rs.POST; -import javax.ws.rs.Path; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.MultivaluedMap; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.Response.Status; -import com.authlete.common.api.AuthleteApiFactory; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.Client; import com.authlete.common.types.User; -import com.authlete.jaxrs.BaseAuthorizationDecisionEndpoint; -import com.authlete.jaxrs.server.db.UserDao; +import com.authlete.jakarta.AuthorizationDecisionHandler.Params; +import com.authlete.jakarta.BaseAuthorizationDecisionEndpoint; +import com.authlete.jaxrs.server.util.ProcessingUtil; +import com.authlete.jakarta.spi.AuthorizationDecisionHandlerSpi; /** @@ -44,6 +46,22 @@ @Path("/api/authorization/decision") public class AuthorizationDecisionEndpoint extends BaseAuthorizationDecisionEndpoint { + private static void addTxnToClaimNames(Params params) { + // txn claim is always required by ConnectID Australia + // https://cdn.connectid.com.au/specifications/digitalid-identity-assurance-profile-06.html + String[] claimNames = params.getClaimNames(); + if (claimNames == null) { + // if no claims were requested it can't be a connectid au request + return; + } + // txn will now be returned for any requests that request oidc claims - as our AS is multipurpose there's no + // real good way to identify the ecosystem variant being tested and returning an random uuid is harmless + ArrayList claimNamesArray = new ArrayList<>(Arrays.asList(claimNames)); + claimNamesArray.add("txn"); + + params.setClaimNames(claimNamesArray.toArray(new String[0])); + } + /** * Process a request from the form in the authorization page. * @@ -73,93 +91,29 @@ public Response post( MultivaluedMap parameters) { // Get the existing session. - HttpSession session = getSession(request); + HttpSession session = ProcessingUtil.getSession(request); // Retrieve some variables from the session. See the implementation // of AuthorizationRequestHandlerSpiImpl.getAuthorizationPage(). - String ticket = (String) takeAttribute(session, "ticket"); - String[] claimNames = (String[])takeAttribute(session, "claimNames"); - String[] claimLocales = (String[])takeAttribute(session, "claimLocales"); + Params params = (Params) takeAttribute(session, "params"); + String[] acrs = (String[])takeAttribute(session, "acrs"); + Client client = (Client) takeAttribute(session, "client"); + User user = ProcessingUtil.getUser(session, parameters); + Date authTime = (Date) session.getAttribute("authTime"); - User user = getUser(session, parameters); - Date authTime = (Date) session.getAttribute("authTime"); - - // Handle the end-user's decision. - return handle(AuthleteApiFactory.getDefaultApi(), - new AuthorizationDecisionHandlerSpiImpl(parameters, user, authTime), - ticket, claimNames, claimLocales); - } + addTxnToClaimNames(params); + // Claims requested to be embedded in the ID token. + String idTokenClaims = (params != null) ? params.getIdTokenClaims() : null; - /** - * Get the existing session. - */ - private HttpSession getSession(HttpServletRequest request) - { - // Get the existing session. - HttpSession session = request.getSession(false); + // Implementation of AuthorizationDecisionHandlerSpi. + AuthorizationDecisionHandlerSpi spi = + new AuthorizationDecisionHandlerSpiImpl( + parameters, user, authTime, idTokenClaims, acrs, client, + session.getId()); - // If there exists a session. - if (session != null) - { - // OK. - return session; - } - - // A session does not exist. Make a response of "400 Bad Request". - String message = "A session does not exist."; - Response response = Response - .status(Status.BAD_REQUEST) - .entity(message) - .type(MediaType.TEXT_PLAIN) - .build(); - - throw new WebApplicationException(message, response); - } - - - /** - * Look up an end-user. - */ - private static User getUser(HttpSession session, MultivaluedMap parameters) - { - - // Look up the user in the session to see if they're already logged in - User sessionUser = (User) session.getAttribute("user"); -// System.err.println("User from session: " + sessionUser); - - if (sessionUser != null) { - return sessionUser; - } else { - // Look up an end-user who has the login credentials. - User loginUser = UserDao.getByCredentials( - parameters.getFirst("loginId"), - parameters.getFirst("password")); - - if (loginUser != null) { -// System.err.println("Logged in as: " + loginUser); - session.setAttribute("user", loginUser); - session.setAttribute("authTime", new Date()); - } - - return loginUser; - } - + // Handle the end-user's decision. + return handle(ResilientAuthleteApiFactory.getDefaultApi(), spi, params); } - /** - * Get the value of an attribute from the given session and - * remove the attribute from the session after the retrieval. - */ - private Object takeAttribute(HttpSession session, String key) - { - // Retrieve the value from the session. - Object value = session.getAttribute(key); - - // Remove the attribute from the session. - session.removeAttribute(key); - - // Return the value of the attribute. - return value; - } } diff --git a/src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionHandlerSpiImpl.java b/src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionHandlerSpiImpl.java index 561ded1..47342c0 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionHandlerSpiImpl.java +++ b/src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionHandlerSpiImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 Authlete, Inc. + * Copyright (C) 2016-2025 Authlete, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,18 +18,31 @@ import java.util.Date; - -import javax.ws.rs.core.MultivaluedMap; - +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import com.authlete.common.assurance.VerifiedClaims; +import com.authlete.common.assurance.constraint.VerifiedClaimsConstraint; +import com.authlete.common.dto.Client; import com.authlete.common.dto.Property; +import com.authlete.common.types.SubjectType; import com.authlete.common.types.User; -import com.authlete.jaxrs.spi.AuthorizationDecisionHandlerSpiAdapter; +import com.authlete.common.util.Utils; +import com.authlete.jaxrs.server.db.DatasetDao; +import com.authlete.jaxrs.server.db.VerifiedClaimsDao; +import com.authlete.jaxrs.server.util.ResponseUtil; +import com.authlete.jakarta.spi.AuthorizationDecisionHandlerSpiAdapter; /** - * Implementation of {@link com.authlete.jaxrs.spi.AuthorizationDecisionHandlerSpi + * Implementation of {@link com.authlete.jakarta.spi.AuthorizationDecisionHandlerSpi * AuthorizationDecisionHandlerSpi} interface which needs to be given - * to the constructor of {@link com.authlete.jaxrs.AuthorizationDecisionHandler + * to the constructor of {@link com.authlete.jakarta.AuthorizationDecisionHandler * AuthorizationDecisionHandler}. * *

@@ -40,6 +53,12 @@ */ class AuthorizationDecisionHandlerSpiImpl extends AuthorizationDecisionHandlerSpiAdapter { + // The pattern of "openbanking_intent_id". + // See openbanking/AccountRequestsEndpoint.java in java-resource-server. + private static final Pattern OPENBANKING_INTENT_ID_PATTERN + = Pattern.compile("^([0-9]+):.*$"); + + /** * The flag to indicate whether the client application has been granted * permissions by the user. @@ -65,6 +84,32 @@ class AuthorizationDecisionHandlerSpiImpl extends AuthorizationDecisionHandlerSp private String mUserSubject; + /** + * The value of the "id_token" property in the "claims" request parameter + * (or in the "claims" property in the request object) contained in the + * original authorization request. + */ + private Map mIdTokenClaims; + + + /** + * Requested ACRs. + */ + private String[] mAcrs; + + + /** + * Client associated with the request. + */ + private Client mClient; + + + /** + * The session ID of the user's authentication session. + */ + private String mSessionId; + + /** * Constructor with a request from the form in the authorization page. * @@ -73,7 +118,10 @@ class AuthorizationDecisionHandlerSpiImpl extends AuthorizationDecisionHandlerSp * {@code password} in {@code parameters}. *

*/ - public AuthorizationDecisionHandlerSpiImpl(MultivaluedMap parameters, User user, Date userAuthenticatedAt) + public AuthorizationDecisionHandlerSpiImpl( + MultivaluedMap parameters, User user, + Date userAuthenticatedAt, String idTokenClaims, String[] acrs, + Client client, String sessionId) { // If the end-user clicked the "Authorize" button, "authorized" // is contained in the request. @@ -94,16 +142,33 @@ public AuthorizationDecisionHandlerSpiImpl(MultivaluedMap parame return; } - // the authentication time is calculated externally and passed in - if (userAuthenticatedAt == null) { - return; + // The authentication time is calculated externally and passed in. + if (userAuthenticatedAt == null) + { + return; } - // TODO: this should be passing in seconds to the API but we currently need to pass in milliseconds to get the correct behavior + // TODO: This should be passing in seconds to the API but we currently + // need to pass in milliseconds to get the correct behavior. mUserAuthenticatedAt = userAuthenticatedAt.getTime() / 1000L; // The subject (= unique identifier) of the end-user. mUserSubject = mUser.getSubject(); + + // The value of the "id_token" property in the "claims" request parameter + // (or in the "claims" property in the request object) contained in the + // original authorization request. See '5.5. Requesting Claims using the + // "claims" Request Parameter' in OpenID Connect Core 1.0 for details. + mIdTokenClaims = parseJson(idTokenClaims); + + // The requested ACRs. + mAcrs = acrs; + + // The client associated with the request. + mClient = client; + + // The session ID of the user's authentication session. + mSessionId = sessionId; } @@ -135,6 +200,16 @@ public String getUserSubject() @Override public Object getUserClaim(String claimName, String languageTag) { + // First, check if the claim is a custom one. + Object value = getCustomClaim(claimName, languageTag); + + // If the value for the custom claim was obtained. + if (value != null) + { + // Return the value of the custom claim. + return value; + } + // getUserClaim() is called only when getUserSubject() has returned // a non-null value. So, mUser is not null when the flow reaches here. return mUser.getClaim(claimName, languageTag); @@ -150,4 +225,279 @@ public Property[] getProperties() // that may be issued as a result of the authorization request. return null; } + + + @Override + public String getAcr() + { + // Note that this is a dummy implementation. Regardless of whatever + // the actual authentication was, this implementation returns the + // first element of the requested ACRs if it is available. + // + // Of course, this implementation is not suitable for commercial use. + + if (mAcrs == null || mAcrs.length == 0) + { + return null; + } + + // The first element of the requested ACRs. + String acr = mAcrs[0]; + + if (acr == null || acr.length() == 0) + { + return null; + } + + // Return the first element of the requested ACRs. Again, + // this implementation is not suitable for commercial use. + return acr; + } + + + @SuppressWarnings("unchecked") + private static Map parseJson(String json) + { + if (json == null) + { + return null; + } + + try + { + return Utils.fromJson(json, Map.class); + } + catch (Exception e) + { + // Failed to parse the input as JSON. + return null; + } + } + + + private Object getCustomClaim(String claimName, String languageTag) + { + // Special behavior for Open Banking Profile. + if ("openbanking_intent_id".equals(claimName)) + { + // The Open Banking Profile requires that an authorization + // request contains the "openbanking_intent_id" claim and + // the authorization server embeds the value of the claim + // in an ID token. + return getOpenBankingIntentIdFromIdTokenClaims(claimName); + } + + if ("txn".equals(claimName)) { + // txn claim as used in ConnectID Australia: + // https://cdn.connectid.com.au/specifications/digitalid-identity-assurance-profile-06.html + return UUID.randomUUID(); + } + + // If the name indicates that the claim is a transformed claim. + // See "OpenID Connect Advanced Syntax for Claims (ASC) 1.0" + // for details about transformed claims. + if (claimName.startsWith(":")) + { + // The value of the transformed claim will be computed by + // Authlete later. The value returned here is not so + // important. + return "placeholder"; + } + + return null; + } + + + private Object getOpenBankingIntentIdFromIdTokenClaims(String claimName) + { + // Get the value of "openbanking_intent_id" from "id_token" property + // in the "claims" request parameter. + Object intentId = getValueFromIdTokenClaims(claimName); + + // If the value of "openbanking_intent_id" is null. + if (intentId == null) + { + throw badRequest("The value of 'openbanking_intent_id' is not available."); + } + + // Validate the value of the intent ID. + validateOpenBankingIntentId(intentId); + + // Return the validated intent ID. + return intentId; + } + + + private void validateOpenBankingIntentId(Object value) + { + // If the type of "openbanking_intent_id" is not String. + if (!(value instanceof String)) + { + throw badRequest("The value of 'openbanking_intent_id' is not a string."); + } + + String intentId = (String)value; + + // Matcher that checks whether the value of openbanking_intent_id + // matches the pattern "{ClientId}:...". + Matcher matcher = OPENBANKING_INTENT_ID_PATTERN.matcher(intentId); + + // If the openbanking_intent_id does not match the pattern. + if (!matcher.matches()) + { + // No validation on the value. + return; + } + + // The client ID embedded in the openbanking_intent_id. + String clientId = matcher.group(1); + + // If the client ID embedded in the openbanking_intent_id matches + // the ID of the client that has made the authorization request. + if (clientId.equals(String.valueOf(mClient.getClientId()))) + { + // OK. The intent ID is being used by the legitimate client. + return; + } + + throw badRequest("The 'openbanking_intent_id' is not for the client."); + } + + + private Object getValueFromIdTokenClaims(String claimName) + { + // Try to extract the entry for the claim from the "id_token" + // property in the "claims" (which was contained in the original + // authorization request). + Map entry = getEntryFromIdTokenClaims(claimName); + + // If an entry for the claim is not available. + if (entry == null) + { + // The value of the claim is not available. + return null; + } + + // This method expects that the entry has a "value" property. + return entry.get("value"); + } + + + @SuppressWarnings("unchecked") + private Map getEntryFromIdTokenClaims(String claimName) + { + // If the original authorization request does not include + // the "id_token" property in the "claims" request parameter + // (or in the "claims" property in the request object). + if (mIdTokenClaims == null) + { + // No entry for the claim. + return null; + } + + // Extract the entry for the claim from the "id_token" property. + Object entry = mIdTokenClaims.get(claimName); + + // If the claim is not included. + if (entry == null) + { + // No entry for the claim. + return null; + } + + // The expected format of a claim in the "id_token" property is + // as follows. See '5.5. Requesting Claims using the "claims" + // Request Parameter' in OpenID Connect Core 1.0 for details. + // + // "claim_name" : { + // "essential": , // Optional + // "value": , // Optional + // "values": [] // Optional + // } + // + // Therefore, 'entry' should be able to be parsed as Map. + + if (!(entry instanceof Map)) + { + // The format of the claim is invalid. + return null; + } + + // Found the entry for the claim. + return (Map)entry; + } + + + @Override + public String getSub() + { + if (mClient.getSubjectType() == SubjectType.PAIRWISE) + { + // it's a pairwise subject, calculate it here + + String sectorIdentifier = mClient.getDerivedSectorIdentifier(); + + return mClient.getSubjectType().name() + "-" + sectorIdentifier + "-" + mUserSubject; + } + else + { + return null; + } + } + + + @Override + public List getVerifiedClaims(String subject, VerifiedClaimsConstraint constraint) + { + // This method, getVerifiedClaims(String, VerifiedClaimsConstraint), + // is no longer called since authlete-java-jaxrs 2.42 unless the + // 'oldIdaFormatUsed' flag of AuthorizationDecisionHandler.Params is on. + // Instead, getVerifiedClaims(String, Object) is called. + + // The third Implementer's Draft of OpenID Connect for Identity + // Assurance 1.0 (which was published in September 2021) has introduced + // many breaking changes. In addition, it is scheduled that the next + // draft will introduce further breaking changes. The specification is + // still unstable. It turned out to be inadequate to define Java classes + // that correspond to data structures of elements under "verified_claims". + // In that sense, the classes under com.authlete.common.assurance package + // of the authlete-java-common library are no longer useful. + // + // Authlete 2.3 has implemented a different approach for ID3 and future + // drafts of OIDC4IDA that is less susceptible to specification changes. + + return VerifiedClaimsDao.get(subject, constraint); + } + + + private WebApplicationException badRequest(String description) + { + // The body of the response. + String content = String.format( + "{\"error\":\"invalid_request\", \"error_description\":\"%s\"}", description); + + // A response with 400 Bad Request and application/json. + Response response = ResponseUtil.badRequest(content); + + return new WebApplicationException(response); + } + + + @Override + public Object getVerifiedClaims(String subject, Object verifiedClaimsRequest) + { + // The list of available datasets of the subject. + List> datasets = DatasetDao.get(subject); + + // Build the content of "verified_claims" which meets conditions + // of the request from the available datasets. + return new VerifiedClaimsBuilder(verifiedClaimsRequest, datasets).build(); + } + + + @Override + public String getSessionId() + { + return mSessionId; + } } diff --git a/src/main/java/com/authlete/jaxrs/server/api/AuthorizationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/AuthorizationEndpoint.java index d21fc0b..2ac45d7 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/AuthorizationEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/AuthorizationEndpoint.java @@ -17,18 +17,18 @@ package com.authlete.jaxrs.server.api; -import javax.servlet.http.HttpServletRequest; -import javax.ws.rs.Consumes; -import javax.ws.rs.GET; -import javax.ws.rs.POST; -import javax.ws.rs.Path; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.MultivaluedMap; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.UriInfo; -import com.authlete.common.api.AuthleteApiFactory; -import com.authlete.jaxrs.BaseAuthorizationEndpoint; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.jakarta.BaseAuthorizationEndpoint; /** @@ -104,7 +104,7 @@ public Response post( */ private Response handle(HttpServletRequest request, MultivaluedMap parameters) { - return handle(AuthleteApiFactory.getDefaultApi(), + return handle(ResilientAuthleteApiFactory.getDefaultApi(), new AuthorizationRequestHandlerSpiImpl(request), parameters); } } diff --git a/src/main/java/com/authlete/jaxrs/server/api/AuthorizationRequestHandlerSpiImpl.java b/src/main/java/com/authlete/jaxrs/server/api/AuthorizationRequestHandlerSpiImpl.java index 5bce26e..ee075ca 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/AuthorizationRequestHandlerSpiImpl.java +++ b/src/main/java/com/authlete/jaxrs/server/api/AuthorizationRequestHandlerSpiImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 Authlete, Inc. + * Copyright (C) 2016-2019 Authlete, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,29 +17,28 @@ package com.authlete.jaxrs.server.api; - import java.util.Arrays; import java.util.Date; import java.util.List; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpSession; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; - +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import org.glassfish.jersey.server.mvc.Viewable; - import com.authlete.common.dto.AuthorizationResponse; +import com.authlete.common.dto.Client; import com.authlete.common.types.Prompt; +import com.authlete.common.types.SubjectType; import com.authlete.common.types.User; -import com.authlete.jaxrs.AuthorizationPageModel; -import com.authlete.jaxrs.spi.AuthorizationRequestHandlerSpiAdapter; +import com.authlete.jakarta.AuthorizationDecisionHandler.Params; +import com.authlete.jaxrs.server.federation.FederationManager; +import com.authlete.jakarta.spi.AuthorizationRequestHandlerSpiAdapter; /** - * Implementation of {@link com.authlete.jaxrs.spi.AuthorizationRequestHandlerSpi + * Implementation of {@link com.authlete.jakarta.spi.AuthorizationRequestHandlerSpi * AuthorizationRequestHandlerSpi} interface which needs to be given - * to the constructor of {@link com.authlete.jaxrs.AuthorizationRequestHandler + * to the constructor of {@link com.authlete.jakarta.AuthorizationRequestHandler * AuthorizationRequestHandler}. * *

@@ -57,7 +56,6 @@ */ class AuthorizationRequestHandlerSpiImpl extends AuthorizationRequestHandlerSpiAdapter { - /** * {@code "text/html;charset=UTF-8"} */ @@ -77,6 +75,12 @@ class AuthorizationRequestHandlerSpiImpl extends AuthorizationRequestHandlerSpiA private final HttpServletRequest mRequest; + /** + * Client associated with the authorization request. (Filled in during authorization response.) + */ + private Client mClient; + + /** * Constructor with an authorization request to the authorization endpoint. */ @@ -94,61 +98,29 @@ public Response generateAuthorizationPage(AuthorizationResponse info) // Store some variables into the session so that they can be // referred to later in AuthorizationDecisionEndpoint. - session.setAttribute("ticket", info.getTicket()); - session.setAttribute("claimNames", info.getClaims()); - session.setAttribute("claimLocales", info.getClaimsLocales()); - - // get the user from the session if they exist - User user = (User) session.getAttribute("user"); - Date authTime = (Date) session.getAttribute("authTime"); - - //System.err.println("USER: " + user); - //System.err.println("Auth Time: " + authTime); - - //System.err.println("AuthorizationResponse: " + info.summarize()); - - if (user != null && authTime != null) { - - // see if the user should be prompted for login anyway - if (info.getPrompts() != null) { - List prompts = Arrays.asList(info.getPrompts()); -// System.err.println("Prompts: " + prompts); - if (prompts.contains(Prompt.LOGIN)) { - // force a login by clearing out the current user -// System.err.println("XX Logged out from prompt"); - user = null; - session.removeAttribute("user"); - session.removeAttribute("authTime"); - } - } - - - // check the auth age to make sure this session isn't too old - - // TODO: max_age == 0 effectively means "log in the user interactively now" but it's used here as - // a flag, we should fix this to use Integer instead of int probably - if (info.getMaxAge() > 0) { - Date now = new Date(); - - // calculate number of seconds that have elapsed since login - long authAge = (now.getTime() - authTime.getTime()) / 1000; - - if (authAge > info.getMaxAge()) { - // session age is too old, clear out the current user -// System.err.println("XX Logged out from max_auth"); - user = null; - session.removeAttribute("user"); - session.removeAttribute("authTime"); - } - } - - } + session.setAttribute("params", Params.from(info)); + session.setAttribute("acrs", info.getAcrs()); + session.setAttribute("client", info.getClient()); + + mClient = info.getClient(); // update the client in case we need it with a no-interaction response + + // Clear the current user information in the session if necessary. + clearCurrentUserInfoInSessionIfNecessary(info, session); + + // Get the user from the session if they exist. + User user = (User)session.getAttribute("user"); // Prepare a model object which contains information needed to - // render the authorization page. Feel free to create a subclass - // of AuthorizationPageModel or define another different class - // according to what you need in the authorization page. - AuthorizationPageModel model = new AuthorizationPageModel(info, user); + // render the authorization page. + AuthzPageModel model = new AuthzPageModel(info, user, + FederationManager.getInstance().getConfigurations()); + + // Prepare another model object which contains information only + // from the AuthorizationResponse instance. This model will be + // used in FederationEndpoint if the end-user chooses to use an + // external OpenID Provider at the authorization page. + AuthzPageModel model2 = new AuthzPageModel(info, null, null); + session.setAttribute("authzPageModel", model2); // Create a Viewable instance that represents the authorization // page. Viewable is a class provided by Jersey for MVC. @@ -159,59 +131,139 @@ public Response generateAuthorizationPage(AuthorizationResponse info) } - /* (non-Javadoc) - * @see com.authlete.jaxrs.spi.AuthorizationRequestHandlerSpiAdapter#isUserAuthenticated() - */ - @Override - public boolean isUserAuthenticated() { + @Override + public boolean isUserAuthenticated() + { // Create an HTTP session. HttpSession session = mRequest.getSession(true); - - // get the user from the session if they exist - User user = (User) session.getAttribute("user"); - - if (user != null) { - return true; - } else { - return false; - } - } + + // Get the user from the session if they exist. + User user = (User)session.getAttribute("user"); + + // If the user information exists in the session, the user is already + // authenticated; Otherwise, the user is not authenticated. + return user != null; + } - /* (non-Javadoc) - * @see com.authlete.jaxrs.spi.AuthorizationRequestHandlerSpiAdapter#getUserAuthenticatedAt() - */ - @Override - public long getUserAuthenticatedAt() { + @Override + public long getUserAuthenticatedAt() + { // Create an HTTP session. HttpSession session = mRequest.getSession(true); - - // get the user from the session if they exist - Date authTime = (Date) session.getAttribute("authTime"); - - if (authTime != null) { - return authTime.getTime() / 1000L; - } else { - return 0; + + // Get the user from the session if they exist. + Date authTime = (Date)session.getAttribute("authTime"); + + if (authTime == null) + { + return 0; } - } + + return authTime.getTime() / 1000L; + } - /* (non-Javadoc) - * @see com.authlete.jaxrs.spi.AuthorizationRequestHandlerSpiAdapter#getUserSubject() - */ - @Override - public String getUserSubject() { + @Override + public String getUserSubject() + { // Create an HTTP session. HttpSession session = mRequest.getSession(true); - - // get the user from the session if they exist - User user = (User) session.getAttribute("user"); - - if (user != null) { - return user.getSubject(); - } else { - return null; + + // Get the user from the session if they exist. + User user = (User)session.getAttribute("user"); + + if (user == null) + { + return null; } - } + + return user.getSubject(); + } + + + private void clearCurrentUserInfoInSessionIfNecessary(AuthorizationResponse info, HttpSession session) + { + // Get the user from the session if they exist. + User user = (User)session.getAttribute("user"); + Date authTime = (Date)session.getAttribute("authTime"); + + if (user == null || authTime == null) + { + // The information about the user does not exist in the session. + return; + } + + // Check 'prompts'. + checkPrompts(info, session); + + // Check 'authentication age'. + checkAuthenticationAge(info, session, authTime); + } + + + private void checkPrompts(AuthorizationResponse info, HttpSession session) + { + if (info.getPrompts() == null) + { + return; + } + + List prompts = Arrays.asList(info.getPrompts()); + + if (prompts.contains(Prompt.LOGIN)) + { + // Force a login by clearing out the current user. + clearCurrentUserInfoInSession(session); + }; + } + + + private void checkAuthenticationAge(AuthorizationResponse info, HttpSession session, Date authTime) + { + // TODO: max_age == 0 effectively means "log in the user interactively + // now" but it's used here as a flag, we should fix this to use Integer + // instead of int probably. + if (info.getMaxAge() <= 0) + { + return; + } + + Date now = new Date(); + + // Calculate number of seconds that have elapsed since login. + long authAge = (now.getTime() - authTime.getTime()) / 1000L; + + if (authAge > info.getMaxAge()) + { + // Session age is too old, clear out the current user. + clearCurrentUserInfoInSession(session); + }; + } + + + private void clearCurrentUserInfoInSession(HttpSession session) + { + session.removeAttribute("user"); + session.removeAttribute("authTime"); + } + + + @Override + public String getSub() + { + if (mClient != null && + mClient.getSubjectType() == SubjectType.PAIRWISE) + { + // it's a pairwise subject, calculate it here + + String sectorIdentifier = mClient.getDerivedSectorIdentifier(); + + return mClient.getSubjectType().name() + "-" + sectorIdentifier + "-" + getUserSubject(); + } + else + { + return null; + } + } } diff --git a/src/main/java/com/authlete/jaxrs/server/api/AuthzPageModel.java b/src/main/java/com/authlete/jaxrs/server/api/AuthzPageModel.java new file mode 100644 index 0000000..406f463 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/AuthzPageModel.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2022 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.api; + + +import com.authlete.common.dto.AuthorizationResponse; +import com.authlete.common.types.User; +import com.authlete.jakarta.AuthorizationPageModel; +import com.authlete.jaxrs.server.federation.FederationConfig; + + +/** + * Data used to render the authorization page. + */ +public class AuthzPageModel extends AuthorizationPageModel +{ + private static final long serialVersionUID = 1L; + + + private FederationConfig[] federations; + private String federationMessage; + + + public AuthzPageModel( + AuthorizationResponse info, User user, FederationConfig[] federations) + { + super(info, user); + + this.federations = federations; + } + + + /** + * Get the configurations of ID federations. + * + *

+ * If this method returns a non-empty array, links for ID federation + * will be displayed in the authorization page. + *

+ */ + public FederationConfig[] getFederations() + { + return federations; + } + + + /** + * Set the configurations of ID federations. + */ + public AuthzPageModel setFederations(FederationConfig[] federations) + { + this.federations = federations; + + return this; + } + + + /** + * Get the feedback message from the process of ID federation. + * + *

+ * If this method returns a non-null value, the message will be displayed + * in the authorization page. + *

+ */ + public String getFederationMessage() + { + return federationMessage; + } + + + /** + * Set the feedback message from the process of ID federation. + */ + public AuthzPageModel setFederationMessage(String message) + { + this.federationMessage = message; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/ClientRegistrationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/ClientRegistrationEndpoint.java new file mode 100644 index 0000000..665aef9 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/ClientRegistrationEndpoint.java @@ -0,0 +1,210 @@ +/* + * Copyright (C) 2019-2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api; + + +import java.security.GeneralSecurityException; +import java.util.Map; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.util.Utils; +import com.authlete.jakarta.BaseClientRegistrationEndpoint; +import com.authlete.jaxrs.server.obb.util.ObbUtils; + + +/** + * An implementation of the dynamic client registration and + * dynamic client registration management endpoints. This implementation + * takes registration requests via POST to {@code /api/register} and + * returns the resulting registered client as JSON. This implementation + * takes client management requests via GET, PUT, and DELETE to + * {@code /api/register/client_id}, where {@code client_id} is the + * client ID of the registered client. This implementation will parse the + * client ID from the incoming URL and pass it to the Authlete API. + * + * @see RFC 7591 + * + * @see RFC 7592 + * + * @see OpenID Connect Dynamic Client Registration + */ +@Path("/api/register") +public class ClientRegistrationEndpoint extends BaseClientRegistrationEndpoint +{ + /** + * Dynamic client registration endpoint. + */ + @POST + @Consumes(MediaType.APPLICATION_JSON) + public Response register( + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, + String json, + @Context HttpServletRequest httpServletRequest) + { + // The interface of Authlete APIs. + AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); + + // Pre-process the request body as necessary. + json = preprocessRequestBody(httpServletRequest, json); + + // Execute the "register" operation. + return handleRegister(api, json, authorization); + } + + + /** + * Dynamic client registration management endpoint, "read" functionality. + */ + @GET + @Path("/{id}") + public Response read( + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, + @PathParam("id") String clientId, + @Context HttpServletRequest httpServletRequest) + { + // The interface of Authlete APIs. + AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); + + // Extra process before executing the "read" operation. + preprocessClient(httpServletRequest, api, clientId); + + // Execute the "read" operation. + return handleGet(api, clientId, authorization); + } + + + /** + * Dynamic client registration management endpoint, "update" functionality. + */ + @PUT + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + public Response update( + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, + @PathParam("id") String clientId, + String json, + @Context HttpServletRequest httpServletRequest) + { + // The interface of Authlete APIs. + AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); + + // Pre-process the request body as necessary. + json = preprocessRequestBody(httpServletRequest, json); + + // Execute the "update" operation. + return handleUpdate(api, clientId, json, authorization); + } + + + /** + * Dynamic client registration management endpoint, "delete" functionality. + */ + @DELETE + @Path("/{id}") + public Response delete( + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, + @PathParam("id") String clientId, + @Context HttpServletRequest httpServletRequest) + { + // The interface of Authlete APIs. + AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); + + // Extra process before executing the "delete" operation. + preprocessClient(httpServletRequest, api, clientId); + + // Execute the "delete" operation. + return handleDelete(api, clientId, authorization); + } + + + private static void preprocessClient( + HttpServletRequest request, AuthleteApi api, String clientId) + { + // If the client identified by the client ID seems a client + // that has been dynamically registered for Open Banking Brasil. + if (ObbUtils.isObbDynamicClient(api, clientId)) + { + // Validate the client certificate. + validateCertificate(request); + } + } + + + private static String preprocessRequestBody(HttpServletRequest request, String requestBody) + { + // If the request body seems a Dynamic Client Registration request + // for Open Banking Brasil or if the request includes a client + // certificate for Open Banking Brasil. + if (ObbUtils.isObbDcr(requestBody) || + ObbUtils.includesObbCertificate(request)) + { + // Validate the client certificate. + validateCertificate(request); + + // Perform validation specific to Open Banking Brasil. + // The resultant map holds client metadata. + Map metadata = + new OBBDCRProcessor().process(request, requestBody); + + return Utils.toJson(metadata); + } + else + { + // No pre-processing. + return requestBody; + } + } + + + private static void validateCertificate(HttpServletRequest request) + { + // Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 + // 7.1. Authorization server + // + // 1. shall reject dynamic client registration requests not performed + // over a connection secured with mutual tls using certificates + // issued by Brazil ICP (production) or the Directory of Participants + // (sandbox); + + try + { + // Validate the client certificate. + OBBCertValidator.getInstance().validate(request); + } + catch (GeneralSecurityException e) + { + throw OBBDCRProcessor.errorResponse(Status.UNAUTHORIZED, + "invalid_client", + String.format("Client certificate validation failed: %s", e.getMessage())); + } + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/ConfigurationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/ConfigurationEndpoint.java index 5b00a1b..78d70e7 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/ConfigurationEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/ConfigurationEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 Authlete, Inc. + * Copyright (C) 2016-2024 Authlete, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,11 +17,14 @@ package com.authlete.jaxrs.server.api; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.core.Response; -import com.authlete.common.api.AuthleteApiFactory; -import com.authlete.jaxrs.BaseConfigurationEndpoint; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.ServiceConfigurationRequest; +import com.authlete.jakarta.BaseConfigurationEndpoint; /** @@ -29,16 +32,16 @@ * *

* An OpenID Provider that supports OpenID Connect + * "https://openid.net/specs/openid-connect-discovery-1_0.html">OpenID Connect * Discovery 1.0 must provide an endpoint that returns its configuration * information in a JSON format. Details about the format are described in - * "3. OpenID Provider Metadata" in OpenID Connect Discovery 1.0. *

* *

* Note that the URI of an OpenID Provider configuration endpoint is defined in - * "4.1. OpenID Provider Configuration Request" in OpenID Connect Discovery * 1.0. In short, the URI must be: *

@@ -50,9 +53,9 @@ *

* Issuer Identifier is a URL to identify an OpenID Provider. For example, * {@code https://example.com}. For details about Issuer Identifier, See {@code issuer} - * in "3. OpenID Provider Metadata" (OpenID Connect Discovery 1.0) and {@code iss} in - * "2. ID Token" + * "2. ID Token" * (OpenID Connect Core 1.0). *

* @@ -63,21 +66,79 @@ * use, so you should change it. *

* - * @see OpenID Connect Discovery 1.0 * + * @see RFC 8414 OAuth 2.0 Authorization Server Metadata + * * @author Takahiko Kawasaki */ -@Path("/.well-known/openid-configuration") +@Path("/.well-known/{path : openid-configuration|oauth-authorization-server}") public class ConfigurationEndpoint extends BaseConfigurationEndpoint { /** * OpenID Provider configuration endpoint. + * + *

+ * This implementation accepts {@code "pretty"} and {@code "patch"} as + * request parameters, but they are not standardized ones. They are + * processed just to demonstrate capabilities of Authlete's + * {@code /service/configuration} API. Note that the version of Authlete + * must be 2.2.36 or greater to use the request parameters. + *

+ * + *

+ * The value of the {@code patch} request parameter is a JSON Patch + * that conforms to RFC + * 6902 JavaScript Object Notation (JSON) Patch. API callers can make + * the Authlete API modify JSON on Authlete side before it returns the + * configuration JSON. Of course, API callers can modify JSON as they like + * AFTER they receive a response from the Authlete API, so API callers do + * not necessarily need to use the {@code patch} request parameter. + *

*/ @GET - public Response get() + public Response get( + @QueryParam("pretty") String pretty, + @QueryParam("patch") String patch + ) + { + // An AuthleteApi instance to access Authlete APIs. + AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); + + // If either or both of the 'pretty' request parameter + // and the 'patch' request parameter are given. + if ((pretty != null && !pretty.isEmpty()) || + (patch != null && !patch .isEmpty()) ) + { + // Call the /service/configuration API with HTTP POST, + // which is supported since Authlete 2.2.36. + return handle(api, createRequest(pretty, patch)); + } + + // Call the /service/configuration API with HTTP GET. + return handle(api); + } + + + private static ServiceConfigurationRequest createRequest(String pretty, String patch) + { + return new ServiceConfigurationRequest() + .setPretty(determinePretty(pretty)) + .setPatch(patch); + } + + + private static boolean determinePretty(String pretty) { - // Handle the configuration request. - return handle(AuthleteApiFactory.getDefaultApi()); + // If the 'pretty' request parameter is not given. + if (pretty == null || pretty.isEmpty()) + { + // The default value of 'pretty' is true. + return true; + } + + return Boolean.parseBoolean(pretty); } } diff --git a/src/main/java/com/authlete/jaxrs/server/api/FederationConfigurationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/FederationConfigurationEndpoint.java new file mode 100644 index 0000000..3fa2186 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/FederationConfigurationEndpoint.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2022-2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api; + + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.FederationConfigurationRequest; +import com.authlete.common.types.EntityType; +import com.authlete.jakarta.BaseFederationConfigurationEndpoint; + + +/** + * An implementation of the entity configuration endpoint. + * + *

+ * An OpenID Provider that supports OpenID + * Federation 1.0 must provide an endpoint that returns its entity + * configuration in the JWT format. The URI of the endpoint is defined + * as follows: + *

+ * + *
    + *
  1. Entity ID + {@code /.well-known/openid-federation} + *
  2. Host component of Entity ID + {@code /.well-known/openid-federation} + * + Path component of Entity ID (The same rule in RFC 8414) + *
+ * + *

+ * Entity ID is a URL that identifies an OpenID Provider (and other + * entities including Relying Parties, Trust Anchors and Intermediate + * Authorities) in the context of OpenID Federation 1.0. + *

+ * + *

+ * Note that OpenID Federation 1.0 is supported since Authlete 2.3. + *

+ * + * @see OpenID Federation 1.0 + */ +@Path("/.well-known/openid-federation") +public class FederationConfigurationEndpoint extends BaseFederationConfigurationEndpoint +{ + /** + * The request to Authlete's /federation/configuration API. + */ + private static final FederationConfigurationRequest REQUEST = + new FederationConfigurationRequest() + .setEntityTypes(new EntityType[] { + EntityType.OPENID_PROVIDER, + EntityType.OPENID_CREDENTIAL_ISSUER + }); + + + /** + * Entity configuration endpoint. + */ + @GET + public Response get() + { + // Handle the request to the endpoint. + return handle(ResilientAuthleteApiFactory.getDefaultApi(), REQUEST); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/FederationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/FederationEndpoint.java new file mode 100644 index 0000000..6c25fa3 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/FederationEndpoint.java @@ -0,0 +1,330 @@ +/* + * Copyright (C) 2022 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api; + + +import java.io.IOException; +import java.net.URI; +import java.util.Date; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import org.glassfish.jersey.server.mvc.Viewable; +import com.authlete.common.types.User; +import com.authlete.jakarta.BaseEndpoint; +import com.authlete.jaxrs.server.db.UserDao; +import com.authlete.jaxrs.server.db.UserEntity; +import com.authlete.jaxrs.server.federation.Federation; +import com.authlete.jaxrs.server.federation.FederationManager; +import com.authlete.jaxrs.server.util.ResponseUtil; +import com.nimbusds.oauth2.sdk.id.State; +import com.nimbusds.oauth2.sdk.pkce.CodeVerifier; +import com.nimbusds.openid.connect.sdk.claims.UserInfo; + + +@Path("/api/federation") +public class FederationEndpoint extends BaseEndpoint +{ + private static final MediaType MEDIA_TYPE_HTML = + MediaType.TEXT_HTML_TYPE.withCharset("UTF-8"); + private static final String TEMPLATE = "/authorization"; + + private static final String KEY_MODEL = "authzPageModel"; + private static final String KEY_STATE = "state"; + private static final String KEY_VERIFIER = "codeVerifier"; + + + @GET + @Path("initiation/{federationId}") + public Response initiation( + @Context HttpServletRequest req, + @PathParam("federationId") String federationId) + { + // Get the Federation instance that corresponds to the federation ID. + Federation federation = getFederation(federationId); + + // Generate a state and a code verifier. + String state = new State().getValue(); + String verifier = new CodeVerifier().getValue(); + + // Put them in the session so that callback() can use them later. + putToSession(req, KEY_STATE, state); + putToSession(req, KEY_VERIFIER, verifier); + + // Build an authentication request that conforms to OpenID Connect. + URI authenticationRequest = + buildAuthenticationRequest(federation, state, verifier); + + // Redirect the web browser to the authorization endpoint of the + // OpenID Provider. As a result, the web browser will send the + // authentication request to the authorization endpoint. + return redirectTo(authenticationRequest); + } + + + @GET + @Path("callback/{federationId}") + public Response callback( + @Context HttpServletRequest req, + @PathParam("federationId") String federationId) + { + // Authentication response from the OpenID Provider. + URI authenticationResponse = getFullUri(req); + + // Get the Federation instance that corresponds to the federation ID. + Federation federation = getFederation(federationId); + + // Data used to render the authorization page. + AuthzPageModel model = getAuthzPageModel(req); + + // "state" and "code_verifier" which were generated in initiation(). + String state = takeFromSession(req, KEY_STATE); + String verifier = takeFromSession(req, KEY_VERIFIER); + + // Ensure that 'state' is available. + ensureState(state); + + // Communicate with the OpenID Provider to get information about the user. + UserInfo userInfo = getUserInfo( + federation, authenticationResponse, state, verifier, model); + + // Register the user into this server (or overwrite the existing info). + User user = registerUser(federation, userInfo); + + // Make the user login. + makeUserLogin(req, user); + + // Go back to the authorization page. + return authorizationPage(model, user, null); + } + + + private Federation getFederation(String federationId) throws WebApplicationException + { + // Get the Federation instance that corresponds to the federation ID. + Federation federation = + FederationManager.getInstance().getFederation(federationId); + + if (federation == null) + { + // 404 Not Found + throw notFound("Unknown federation ID: " + federationId); + } + + return federation; + } + + + private URI buildAuthenticationRequest( + Federation federation, String state, String verifier) throws WebApplicationException + { + try + { + // Build an authentication request that conforms to OpenID Connect. + return federation.createFederationRequest(state, verifier); + } + catch (IOException e) + { + throw internalServerError("Failed to build an authentication request: " + e.getMessage()); + } + } + + + private Response redirectTo(URI location) + { + // 302 Found + // Location: {location} + return Response.status(Status.FOUND).location(location).build(); + } + + + private URI getFullUri(HttpServletRequest req) + { + StringBuffer url = req.getRequestURL(); + String queryString = req.getQueryString(); + + if (queryString != null) + { + url.append("?").append(queryString); + } + + return URI.create(url.toString()); + } + + + private AuthzPageModel getAuthzPageModel(HttpServletRequest req) throws WebApplicationException + { + AuthzPageModel model = getFromSession(req, KEY_MODEL); + + if (model == null) + { + // 400 Bad Request + throw badRequest("Not in the context of an authorization flow."); + } + + return model; + } + + + private void ensureState(String state) throws WebApplicationException + { + if (state == null || state.isEmpty()) + { + // 400 Bad Request + throw badRequest("Invalid state."); + } + } + + + private UserInfo getUserInfo( + Federation federation, URI authenticationResponse, + String state, String verifier, AuthzPageModel model) throws WebApplicationException + { + try + { + // Send a token request with the authorization code and the code + // verifier to the token endpoint of the OpenID Provider and + // receive an ID token and an access token. + // + // Access the userinfo endpoint of the OpenID Provider with the + // access token and receive information about the end-user. + // + // Necessary validation steps (such as checking the "state" and + // verifying the signature of the ID token) will be executed in + // processFederationResponse(). + return federation.processFederationResponse( + authenticationResponse, state, verifier); + } + catch (IOException e) + { + // The authorization page with an error message. + Response page = authorizationPage(model, null, + "ID federation failed: " + e.getMessage()); + + // Return the authorization page to the web browser. + throw new WebApplicationException(page); + } + } + + + private User registerUser(Federation federation, UserInfo userInfo) + { + // Create a user entity from the userinfo. + UserEntity userEntity = createUserEntity(federation, userInfo); + + // Register (or overwrite) the user. + UserDao.add(userEntity); + + return userEntity; + } + + + private static UserEntity createUserEntity(Federation federation, UserInfo userInfo) + { + // The subject of the user. + String subject = String.format("%s@%s", + userInfo.getSubject(), federation.getConfiguration().getId()); + + return new UserEntity(userInfo).setSubject(subject); + } + + + private void makeUserLogin(HttpServletRequest req, User user) + { + putToSession(req, "user", user); + putToSession(req, "authTime", new Date()); + } + + + private Response authorizationPage(AuthzPageModel model, User user, String message) + { + model.setUser(user); + model.setFederations(FederationManager.getInstance().getConfigurations()); + model.setFederationMessage(message); + + // Create a Viewable instance that represents the authorization page. + // Viewable is a class provided by Jersey for MVC. + Viewable viewable = new Viewable(TEMPLATE, model); + + // Create a response that has the viewable as its content. + return Response.ok(viewable, MEDIA_TYPE_HTML).build(); + } + + + @SuppressWarnings("unchecked") + private T getFromSession(HttpServletRequest req, String key) + { + HttpSession session = req.getSession(); + + if (session == null) + { + return null; + } + + return (T)session.getAttribute(key); + } + + + private void putToSession(HttpServletRequest req, String key, Object value) + { + HttpSession session = req.getSession(true); + + session.setAttribute(key, value); + } + + + @SuppressWarnings("unchecked") + private T takeFromSession(HttpServletRequest req, String key) + { + HttpSession session = req.getSession(); + + if (session == null) + { + return null; + } + + return (T)takeAttribute(session, key); + } + + + private WebApplicationException badRequest(String message) + { + // 400 Bad Request + return new WebApplicationException(ResponseUtil.badRequest(message)); + } + + + private WebApplicationException notFound(String message) + { + // 404 Not Found + return new WebApplicationException(ResponseUtil.notFound(message)); + } + + + private WebApplicationException internalServerError(String message) + { + // 500 Internal Server Error + return new WebApplicationException(ResponseUtil.internalServerError(message)); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/FederationRegistrationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/FederationRegistrationEndpoint.java new file mode 100644 index 0000000..5e1fcad --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/FederationRegistrationEndpoint.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2022 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api; + + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.FederationRegistrationRequest; +import com.authlete.jakarta.BaseFederationRegistrationEndpoint; + + +/** + * An implementation of the federation registration endpoint. + * + *

+ * An OpenID Provider that supports the "explicit" client registration defined + * in OpenID Connect Federation 1.0 is supposed to provide a federation + * registration endpoint that accepts explicit client registration requests. + *

+ * + *

+ * The endpoint accepts {@code POST} requests whose {@code Content-Type} + * is either of the following. + *

+ * + *
    + *
  1. {@code application/entity-statement+jwt} + *
  2. {@code application/trust-chain+json} + *
+ * + *

+ * When the {@code Content-Type} of a request is + * {@code application/entity-statement+jwt}, the content of the request is + * the entity configuration of a relying party that is to be registered. + *

+ * + *

+ * On the other hand, when the {@code Content-Type} of a request is + * {@code application/trust-chain+json}, the content of the request is a + * JSON array that contains entity statements in JWT format. The sequence + * of the entity statements composes the trust chain of a relying party + * that is to be registered. + *

+ * + *

+ * On successful registration, the endpoint should return a kind of entity + * statement (JWT) with the HTTP status code {@code 200 OK} and the content + * type {@code application/jose}. + *

+ * + *

+ * The discovery document (OpenID Connect + * Discovery 1.0) should include the {@code federation_registration_endpoint} + * server metadata that denotes the URL of the federation registration endpoint. + *

+ * + *

+ * Note that OpenID Connect Federation 1.0 is supported since Authlete 2.3. + *

+ * + * @see OpenID Connect Federation 1.0 + */ +@Path("/api/federation/register") +public class FederationRegistrationEndpoint extends BaseFederationRegistrationEndpoint +{ + @POST + @Consumes("application/entity-statement+jwt") + public Response entityConfiguration(String jwt) + { + // Client registration by a relying party's entity configuration. + return handle( + ResilientAuthleteApiFactory.getDefaultApi(), + request().setEntityConfiguration(jwt)); + } + + + @POST + @Consumes("application/trust-chain+json") + public Response trustChain(String json) + { + // Client registration by a relying party's trust chain. + return handle( + ResilientAuthleteApiFactory.getDefaultApi(), + request().setTrustChain(json)); + } + + + private static FederationRegistrationRequest request() + { + return new FederationRegistrationRequest(); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/GrantManagementEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/GrantManagementEndpoint.java new file mode 100644 index 0000000..4f11027 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/GrantManagementEndpoint.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api; + + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.jakarta.BaseGrantManagementEndpoint; + + +/** + * An implementation of Grant Management Endpoint. + * + * @see Grant Management for OAuth 2.0 + */ +@Path("/api/gm") +public class GrantManagementEndpoint extends BaseGrantManagementEndpoint +{ + /** + * The entry point for grant management 'query' requests. + */ + @GET + @Path("{grantId}") + public Response query( + @Context HttpServletRequest req, + @PathParam("grantId") String grantId) + { + // Handle the grant management 'query' request. + return handle(ResilientAuthleteApiFactory.getDefaultApi(), req, grantId); + } + + + /** + * The entry point for grant management 'revoke' requests. + */ + @DELETE + @Path("{grantId}") + public Response revoke( + @Context HttpServletRequest req, + @PathParam("grantId") String grantId) + { + // Handle the grant management 'revoke' request. + return handle(ResilientAuthleteApiFactory.getDefaultApi(), req, grantId); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/IntrospectionEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/IntrospectionEndpoint.java new file mode 100644 index 0000000..27f1299 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/IntrospectionEndpoint.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2017-2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api; + + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.web.BasicCredentials; +import com.authlete.jakarta.BaseIntrospectionEndpoint; +import com.authlete.jakarta.IntrospectionRequestHandler.Params; +import com.authlete.jaxrs.server.db.ResourceServerDao; +import com.authlete.jaxrs.server.db.ResourceServerEntity; + + +/** + * An implementation of introspection endpoint (RFC 7662). + * + * @see RFC 7662, OAuth 2.0 Token Introspection + * + * @author Takahiko Kawasaki + * @author Hideki Ikeda + */ +@Path("/api/introspection") +public class IntrospectionEndpoint extends BaseIntrospectionEndpoint +{ + /** + * The introspection endpoint. + * + * @see RFC 7662, 2.1. Introspection Request + */ + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response post( + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, + @HeaderParam(HttpHeaders.ACCEPT) String accept, + MultivaluedMap parameters) + { + // "2.1. Introspection Request" in RFC 7662 says as follows: + // + // To prevent token scanning attacks, the endpoint MUST also require + // some form of authorization to access this endpoint, such as client + // authentication as described in OAuth 2.0 [RFC6749] or a separate + // OAuth 2.0 access token such as the bearer token described in OAuth + // 2.0 Bearer Token Usage [RFC6750]. The methods of managing and + // validating these authentication credentials are out of scope of this + // specification. + // + // Therefore, this API must be protected in some way or other. + // Basic Authentication and Bearer Token are typical means, and + // both use the value of the 'Authorization' header. + + BasicCredentials credentials = BasicCredentials.parse(authorization); + + // Fetch the information about the resource server from DB. + ResourceServerEntity rsEntity = getResourceServer(credentials); + + // If failed to authenticate the resource server. + if (authenticateResourceServer(rsEntity, credentials) == false) + { + // RFC 9701 mandates a "400 Bad Request" for unauthenticated introspection + // requests as follows: + // + // Note: An AS compliant with this specification MUST refuse to serve + // introspection requests that don't authenticate the caller and return + // an HTTP status code 400. This is done to ensure token data is released + // to legitimate recipients only and prevent downgrading to [RFC7662] + // behavior (see Section 8.2). + // + // However, we return "401 Unauthorized" instead here. + // While RFC 7662 leaves authentication details out of scope, we consider + // 401 the semantically correct HTTP status for API caller authentication + // failures and the standard behavior for protected endpoints. + + // Return "401 Unauthorized". + return Response.status(Status.UNAUTHORIZED).build(); + } + + // Build a Param object to call the request handler. + Params params = buildParams(parameters, accept, rsEntity); + + // Handle the introspection request. + return handle(ResilientAuthleteApiFactory.getDefaultApi(), params); + } + + + private Params buildParams( + MultivaluedMap parameters, String accept, ResourceServerEntity rsEntity) + { + return new Params() + .setParameters(parameters) + .setHttpAcceptHeader(accept) + .setRsUri(rsEntity.getUri()) + .setIntrospectionSignAlg(rsEntity.getIntrospectionSignAlg()) + .setIntrospectionEncryptionAlg(rsEntity.getIntrospectionEncryptionAlg()) + .setIntrospectionEncryptionEnc(rsEntity.getIntrospectionEncryptionEnc()) + .setPublicKeyForEncryption(rsEntity.getPublicKeyForIntrospectionResponseEncryption()) + .setSharedKeyForSign(rsEntity.getSharedKeyForIntrospectionResponseSign()) + .setSharedKeyForEncryption(rsEntity.getSharedKeyForIntrospectionResponseEncryption()); + } + + + private ResourceServerEntity getResourceServer(BasicCredentials credentials) + { + if (credentials == null) + { + return null; + } + + return ResourceServerDao.get(credentials.getUserId()); + } + + + private boolean authenticateResourceServer( + ResourceServerEntity rsEntity, BasicCredentials credentials) + { + return rsEntity != null && + rsEntity.getSecret().equals(credentials.getPassword()); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/JwksEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/JwksEndpoint.java index 8714fb9..34a55ae 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/JwksEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/JwksEndpoint.java @@ -17,11 +17,11 @@ package com.authlete.jaxrs.server.api; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.core.Response; -import com.authlete.common.api.AuthleteApiFactory; -import com.authlete.jaxrs.BaseJwksEndpoint; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.jakarta.BaseJwksEndpoint; /** @@ -60,6 +60,6 @@ public class JwksEndpoint extends BaseJwksEndpoint public Response get() { // Handle the JWK Set request. - return handle(AuthleteApiFactory.getDefaultApi()); + return handle(ResilientAuthleteApiFactory.getDefaultApi()); } } diff --git a/src/main/java/com/authlete/jaxrs/server/api/JwtAuthzGrantProcessor.java b/src/main/java/com/authlete/jaxrs/server/api/JwtAuthzGrantProcessor.java new file mode 100644 index 0000000..38474ee --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/JwtAuthzGrantProcessor.java @@ -0,0 +1,395 @@ +/* + * Copyright (C) 2022-2025 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api; + + +import java.util.Map; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.CacheControl; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.ResponseBuilder; +import jakarta.ws.rs.core.Response.Status; +import com.authlete.common.api.AuthleteApi; +import com.authlete.common.dto.TokenCreateRequest; +import com.authlete.common.dto.TokenCreateResponse; +import com.authlete.common.dto.TokenResponse; +import com.authlete.common.types.GrantType; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTParser; +import com.nimbusds.jwt.SignedJWT; + + +/** + * A sample implementation of processing a token request which uses the grant + * type {@code "urn:ietf:params:oauth:grant-type:jwt-bearer"} (RFC 7523). + * + *

+ * The token request contains an {@code assertion} request parameter. Its value + * is a JWT. However, RFC 7523 does not define details about how the JWT is + * generated by whom. As a result, it is not defined in the specification how + * to obtain the key whereby to verify the signature of the JWT. Therefore, + * each deployment has to define their own rules which are necessary to + * determine the key for signature verification. + *

+ * + *

+ * Note that your system must verify the signature of the assertion JWT by + * itself. The JavaDoc of TokenResponse explains (1) what validation steps Authlete performs on + * behalf of your system and (2) why Authlete does not (can not) verify the + * signature of the assertion JWT. + *

+ * + * @see RFC 7521 + * Assertion Framework for OAuth 2.0 Client Authentication and + * Authorization Grants + * + * @see RFC 7523 + * JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication + * and Authorization Grants + */ +class JwtAuthzGrantProcessor +{ + private final AuthleteApi mAuthleteApi; + private final HttpServletRequest mRequest; + private final TokenResponse mTokenResponse; + private final Map mHeaders; + + + public JwtAuthzGrantProcessor( + AuthleteApi authleteApi, HttpServletRequest request, + TokenResponse tokenResponse, Map headers) + { + mAuthleteApi = authleteApi; + mRequest = request; + mTokenResponse = tokenResponse; + mHeaders = headers; + } + + + public Response process() + { + try + { + return createResponse(); + } + catch (WebApplicationException cause) + { + return cause.getResponse(); + } + } + + + private Response createResponse() throws WebApplicationException + { + // Validate the assertion. + SignedJWT jwt = validateAssertion(); + + // Client ID to assign. + long clientId = determineClientId(); + + // Scopes to assign. + String[] scopes = determineScopes(); + + // Subject to assign. + String subject = determineSubject(jwt); + + // Create an access token. + TokenCreateResponse tcResponse = + createAccessToken(clientId, scopes, subject); + + // Create a successful token response. + return createSuccessfulResponse(tcResponse); + } + + + private SignedJWT validateAssertion() + { + // The value of the 'assertion' request parameter. + String assertion = mTokenResponse.getAssertion(); + + // This implementation requires that the assertion is a signed JWT. + SignedJWT jwt = parseAsSignedJwt(assertion); + + // When the assertion is a signed JWT, all validation steps common to + // signed JWTs have been done on Authlete side except verification of + // the signature. See the JavaDoc of TokenResponse class for details + // about the validation steps performed on Authlete side. + // + // https://authlete.github.io/authlete-java-common/com/authlete/common/dto/TokenResponse.html + // + + // Verify the signature of the JWT. + verifySignature(jwt); + + return jwt; + } + + + private SignedJWT parseAsSignedJwt(String assertion) + { + JWT jwt; + + try + { + // Parse the assertion as a JWT. + jwt = JWTParser.parse(assertion); + } + catch (Exception cause) + { + throw invalidGrant("The assertion failed to be parsed as a JWT."); + } + + // If the JWT is not a signed JWT. + if (!(jwt instanceof SignedJWT)) + { + throw invalidGrant( + "This authorization server requires that the assertion be a signed JWT."); + } + + return (SignedJWT)jwt; + } + + + private void verifySignature(SignedJWT jwt) + { + // Because RFC 7523 does not define details about how the assertion + // JWT is generated by whom. As a result, it is not defined in the + // specification how to obtain the key whereby to verify the signature + // of the JWT. Therefore, each deployment has to define their own rules + // which are necessary to determine the key for signature verification. + + // Your system must define additional requirements about the assertion + // so that your system can determine how to obtain the key for signature + // verification. + // + // For example, your system may define a rule like below. + // + // The value of the 'assertion' request parameter must be an ID Token + // issued by "https://example.com". + // + // If the assertion is an ID Token, it is possible to find the key for + // signature verification by (1) getting the server configuration from + // the discovery endpoint, (2) getting the JWK Set document from the + // location indicated by the "jwks_uri" property in the server + // configuration, and (3) selecting a key from among the JWK Set document. + + // TODO + // In any case, your implementation must verify the signature of the JWT. + } + + + private long determineClientId() + { + // The client ID of the client that made the token request. + long clientId = mTokenResponse.getClientId(); + + // If 'Service.jwtGrantByIdentifiableClientsOnly' is false, token + // requests that contain no client identifier are not rejected. + // In that case, 'clientId' here becomes 0. + // + // However, this authorization server implementation does not allow + // unidentifiable clients to make token requests with the grant type + // "urn:ietf:params:oauth:grant-type:jwt-bearer" regardless of whether + // 'Service.jwtGrantByIdentifiableClientsOnly' is true or false. + if (clientId == 0) + { + throw invalidRequest( + "This authorization server does not allow unidentifiable " + + "clients to make token requests with the grant type " + + "'urn:ietf:params:oauth:grant-type:jwt-bearer'."); + } + + // This simple implementation uses the client ID of the client + // that made the token request. + return clientId; + } + + + private String[] determineScopes() + { + // This simple implementation uses the scopes specified by the token request. + return mTokenResponse.getScopes(); + } + + + private String determineSubject(SignedJWT jwt) + { + try + { + // Get the value of the "sub" claim from the payload of the JWT. + // + // RFC 7523 requires that an assertion used with the grant type + // "urn:ietf:params:oauth:grant-type:jwt-bearer" have the "sub" + // claim. + return jwt.getJWTClaimsSet().getSubject(); + } + catch (Exception cause) + { + throw invalidGrant( + "The value of the 'sub' claim failed to be extracted " + + "from the payload of the assertion."); + } + } + + + private TokenCreateResponse createAccessToken( + long clientId, String[] scopes, String subject) + { + // A request to Authlete's /auth/token/create API. + TokenCreateRequest request = new TokenCreateRequest() + .setGrantType(GrantType.JWT_BEARER) + .setClientId(clientId) + .setScopes(scopes) + .setSubject(subject) + ; + + try + { + // Call Authlete's /auth/token/create API to create an access token. + return mAuthleteApi.tokenCreate(request); + } + catch (Exception cause) + { + // API call to /auth/token/create failed. + cause.printStackTrace(); + throw serverError("API call to /auth/token/create failed."); + } + } + + + private Response createSuccessfulResponse(TokenCreateResponse tcResponse) + { + // The content of a successful token response that conforms to RFC 6749. + String content = String.format( + "{\n" + + " \"access_token\":\"%s\",\n" + + " \"token_type\":\"Bearer\",\n" + + " \"expires_in\":%d,\n" + + " \"scope\":\"%s\"\n" + + "}\n", + extractAccessToken(tcResponse), + tcResponse.getExpiresIn(), + buildScope(tcResponse) + ); + + return toJsonResponse(Status.OK, content); + } + + + private String extractAccessToken(TokenCreateResponse tcResponse) + { + // If a JWT access token has been issued, it takes precedence over + // a random-string access token. + + // An access token in the JWT format. This response parameter holds + // a non-null value when Service.accessTokenSignAlg is not null. + String at = tcResponse.getJwtAccessToken(); + + // If an access token in the JWT format has not been issued. + if (at == null) + { + // An access token whose format is just a random string. + at = tcResponse.getAccessToken(); + } + + // The newly issued access token. + return at; + } + + + private String buildScope(TokenCreateResponse tcResponse) + { + String[] scopes = tcResponse.getScopes(); + + if (scopes == null) + { + return ""; + } + + return String.join(" ", scopes); + } + + + private Response toJsonResponse(Status status, String content) + { + CacheControl cacheControl = new CacheControl(); + cacheControl.setNoCache(true); + cacheControl.setNoStore(true); + + ResponseBuilder builder = Response.status(status) + .type(MediaType.APPLICATION_JSON_TYPE) + .cacheControl(cacheControl) + .entity(content) + ; + + addResponseHeaders(builder, mHeaders); + + return builder.build(); + } + + + private static void addResponseHeaders(ResponseBuilder builder, Map headers) + { + if (headers == null) + { + return; + } + + for (Map.Entry header : headers.entrySet()) + { + builder.header(header.getKey(), header.getValue()); + } + } + + + private WebApplicationException toException(Status status, String error, String description) + { + String content = String.format( + "{\n" + + " \"error\":\"%s\",\n" + + " \"error_description\":\"%s\"\n" + + "}\n", + error, description); + + Response response = toJsonResponse(status, content); + + return new WebApplicationException(response); + } + + + private WebApplicationException invalidGrant(String message) + { + return toException(Status.BAD_REQUEST, "invalid_grant", message); + } + + + private WebApplicationException invalidRequest(String message) + { + return toException(Status.BAD_REQUEST, "invalid_request", message); + } + + + private WebApplicationException serverError(String message) + { + return toException(Status.INTERNAL_SERVER_ERROR, "server_error", message); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/NativeSsoProcessor.java b/src/main/java/com/authlete/jaxrs/server/api/NativeSsoProcessor.java new file mode 100644 index 0000000..85dd5f8 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/NativeSsoProcessor.java @@ -0,0 +1,482 @@ +/* + * Copyright (C) 2025 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api; + + +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.common.dto.NativeSsoRequest; +import com.authlete.common.dto.NativeSsoResponse; +import com.authlete.common.dto.TokenResponse; +import com.authlete.common.types.GrantType; +import com.authlete.jaxrs.server.core.SessionTracker; +import com.authlete.jaxrs.server.nativesso.DeviceSecret; +import com.authlete.jaxrs.server.nativesso.DeviceSecretManager; +import com.authlete.jaxrs.server.util.ResponseUtil; + + +public class NativeSsoProcessor +{ + private final AuthleteApi mAuthleteApi; + private final HttpServletRequest mRequest; + private final TokenResponse mTokenResponse; + private final Map mHeaders; + + + public NativeSsoProcessor( + AuthleteApi authleteApi, HttpServletRequest request, + TokenResponse tokenResponse, Map headers) + { + mAuthleteApi = authleteApi; + mRequest = request; + mTokenResponse = tokenResponse; + mHeaders = headers; + } + + + public Response process() + { + try + { + return createResponse(); + } + catch (WebApplicationException cause) + { + return cause.getResponse(); + } + } + + + private Response createResponse() throws WebApplicationException + { + // The device secret value and device secret hash that may be + // included in the response from the /auth/token API. + String deviceSecretValue = retrieveDeviceSecretValue(); + String deviceSecretHash = retrieveDeviceSecretHash(); + + // The session ID included in the response from the /auth/token API. + String sessionId = retrieveSessionId(); + + // The identifier of the device accessing this authorization server. + String deviceId = retrieveDeviceId(); + + // Validate the Native SSO parameters. + DeviceSecret ds = validateParameters( + deviceSecretValue, deviceSecretHash, sessionId, deviceId); + + // Call Authlete's /nativesso API to generate a Native SSO-compliant + // ID token and a token response. + NativeSsoResponse nsr = nativeSso(ds); + + // Generate a token response. + return generateResponse(nsr); + } + + + private String retrieveDeviceSecretValue() + { + // The device secret that may be included in the response from the + // /auth/token API. + // + // When the flow is the authorization code flow or the refresh token + // flow, the device secret is the value of the "device_secret" request + // parameter to the token endpoint. + // + // When the flow is the token exchange flow, the device secret is the + // value of the "actor_token" request parameter to the token endpoint. + return mTokenResponse.getDeviceSecret(); + } + + + private String retrieveDeviceSecretHash() + { + // The device secret hash that may be included in the response from + // the /auth/token API. + // + // The device secret hash is available only when the flow is the token + // exchange flow. Its value originates from the "ds_hash" claim in the + // subject token. + return mTokenResponse.getDeviceSecretHash(); + } + + + private String retrieveSessionId() + { + // The session ID included in the response from the /auth/token API. + // + // When the flow is the authorization code flow, the session ID is the + // value included in the preceding call of the /auth/authorization/issue + // API. + // + // When the flow is the refresh token flow, the session ID is the one + // associated with the refresh token. + // + // When the flow is the token exchange flow, the session ID is the + // value of the "sid" claim in the subject token. + return mTokenResponse.getSessionId(); + } + + + private String retrieveDeviceId() + { + // Information that can identify the device should be extracted from the + // HTTP request (mRequest) and processed before being used as a device ID. + // + // However, this sample implementation does not perform such processing. + // As a result, it cannot determine whether Native App 1 and Native App 2 + // are running on the same device. + return null; + } + + + private DeviceSecret validateParameters( + String deviceSecretValue, String deviceSecretHash, + String sessionId, String deviceId) + { + // Validate the session ID. + validateSessionId(sessionId); + + if (deviceSecretValue == null) + { + // This happens when (1) the flow is either the authorization code + // flow or the refresh token flow, and (2) the token request does + // not contain the "device_secret" request parameter. + + // Create a new DeviceSecret instance and register it. + return createAndRegisterDeviceSecret(sessionId, deviceId); + } + + // Look up the DeviceSecret instance corresponding to the device + // secret value specified by the "device_secret" request parameter + // or the "actor_token" request parameter. + DeviceSecret ds = DeviceSecretManager.getByValue(deviceSecretValue); + + // If the specified device secret exists and is valid. + if (ds != null && isValid(ds, deviceSecretHash, sessionId, deviceId)) + { + // Use the existing DeviceSecret instance. + return ds; + } + + // The specified device secret does not exist or is invalid. + + if (deviceSecretHash == null) + { + // This happens when (1) the flow is either the authorization code + // flow or the refresh token flow, and (2) the token request contains + // the "device_secret" request parameter. + + // Since the Native SSO specification states as follows: + // + // If a device_secret is provided as part of the token request, + // and the device_secret is invalid, then the AS must process + // the request as if no device_secret was specified. + // + // We don't treat this case as an error. Instead, we provide a + // new DeviceSecret instance. + return createAndRegisterDeviceSecret(sessionId, deviceId); + } + + // This happens when (1) the flow is the token exchange flow. + + // Build a message describing the error. + String message = buildInvalidDeviceSecretErrorMessage( + ds, deviceSecretValue, deviceSecretHash, sessionId, deviceId); + + // 400 Bad Request with error=invalid_grant + throw invalidGrant(message); + } + + + private void validateSessionId(String sessionId) + { + // If the session ID is still active. + if (SessionTracker.isActiveSessionId(sessionId)) + { + // Okay. The session is still active. + return; + } + + // Build an error message indicating that the session ID is no longer valid. + String message = buildInvalidSessionIdErrorMessage(mTokenResponse.getGrantType()); + + // 400 Bad Request with error=invalid_grant + throw invalidGrant(message); + } + + + private static String buildInvalidSessionIdErrorMessage(GrantType grantType) + { + switch (grantType) + { + case AUTHORIZATION_CODE: + return "The session ID used during the authorization request is no longer valid."; + + case REFRESH_TOKEN: + return "The session ID associated with the refresh token is no longer valid."; + + case TOKEN_EXCHANGE: + return "The session ID associated with the subject token is no longer valid"; + + default: + // This should never happen. + return "The session ID associated with the token request is no longer valid."; + } + } + + + private DeviceSecret createAndRegisterDeviceSecret(String sessionId, String deviceId) + { + // Create a new DeviceSecret instance. + DeviceSecret ds = createDeviceSecret(sessionId, deviceId); + + // Register it. + DeviceSecretManager.register(ds); + + return ds; + } + + + private DeviceSecret createDeviceSecret(String sessionId, String deviceId) + { + // The device secret value. + String dsValue = generateDeviceSecretValue(); + + // The device secret hash. + String dsHash = computeDeviceSecretHash(dsValue); + + // Create a DeviceSecret instance tied to the device and session. + return new DeviceSecret() + .setValue(dsValue) + .setHash(dsHash) + .setSessionId(sessionId) + .setDeviceId(deviceId) + ; + } + + + private String generateDeviceSecretValue() + { + // A random value. + return UUID.randomUUID().toString(); + } + + + private String computeDeviceSecretHash(String deviceSecretValue) + { + // Compute the hash of the specified device secret value. + return DeviceSecret.computeHash(deviceSecretValue); + } + + + private boolean isValid( + DeviceSecret ds, String deviceSecretHash, String sessionId, String deviceId) + { + // If the device secret hash is specified. + if (deviceSecretHash != null) + { + // If the device secret hashes do not match. + if (!Objects.equals(ds.getHash(), deviceSecretHash)) + { + // Invalid. + return false; + } + } + + // If the session IDs do not match. + if (!Objects.equals(ds.getSessionId(), sessionId)) + { + // Invalid. + return false; + } + + // If the existing DeviceSecret instance is tied to a device ID. + if (ds.getDeviceId() != null) + { + // If the device IDs do not match. + if (!Objects.equals(ds.getDeviceId(), deviceId)) + { + // Invalid. + return false; + } + } + + // Valid. + return true; + } + + + private static String buildInvalidDeviceSecretErrorMessage( + DeviceSecret ds, String deviceSecretValue, String deviceSecretHash, + String sessionId, String deviceId) + { + // This method is called only from the context of the token exchange flow. + + if (ds == null) + { + return String.format( + "The specified device secret ('%s') does not exist.", + deviceSecretValue); + } + + // If the device secret hashes don't match. + if (!Objects.equals(ds.getHash(), deviceSecretHash)) + { + return String.format( + "The device secret hash ('%s') in the subject token does " + + "not match the hash of the presented device secret ('%s').", + deviceSecretHash, deviceSecretValue); + } + + // If the session IDs don't match. + if (!Objects.equals(ds.getSessionId(), sessionId)) + { + return String.format( + "The session ID ('%s') in the subject token does not match " + + "the one associated with the presented device secret ('%s').", + sessionId, deviceSecretValue); + } + + // If the existing device secret is tied to a device ID and it does not + // match the identifier of the device accessing this authorization server. + if (ds.getDeviceId() != null && !Objects.equals(ds.getDeviceId(), deviceId)) + { + return String.format( + "The identifier of the device accessing this authorization " + + "server does not match the one associated with the presented " + + "device secret ('%s').", + deviceSecretValue); + } + + // Hmm. The code flow should not reach here. + return String.format( + "The specified device secret ('%s') is invalid for an unknown reason.", + deviceSecretValue); + } + + + private NativeSsoResponse nativeSso(DeviceSecret ds) + { + // Prepare request parameters for the /nativesso API. + NativeSsoRequest request = new NativeSsoRequest() + .setAccessToken(chooseAccessToken()) + .setRefreshToken(mTokenResponse.getRefreshToken()) + .setDeviceSecret(ds.getValue()) + .setDeviceSecretHash(ds.getHash()) + ; + + try + { + // Call Authlete's /nativesso API. + return mAuthleteApi.nativeSso(request, null); + } + catch (Exception cause) + { + // API call to /nativeSso failed. + cause.printStackTrace(); + + throw serverError("API call to /nativesso failed: " + cause.getMessage()); + } + } + + + private String chooseAccessToken() + { + // The access token in the JWT format. Whether this is available + // depends on configuration. + String jwtAt = mTokenResponse.getJwtAccessToken(); + + return (jwtAt != null) ? jwtAt : mTokenResponse.getAccessToken(); + } + + + private Response generateResponse(NativeSsoResponse nsr) + { + // The message body of the token response the /nativesso API prepared. + String content = nsr.getResponseContent(); + + // Dispatch according to the "action" parameter in the response from + // the /nativesso API. + switch (nsr.getAction()) + { + case OK: + // 200 OK with application/json + return ResponseUtil.okJson(content, mHeaders); + + case INTERNAL_SERVER_ERROR: + case CALLER_ERROR: + // 500 Internal Server Error with application/json + return ResponseUtil.internalServerErrorJson(content, mHeaders); + + default: + // 500 Internal Server Error with application/json + throw unknownAction(nsr.getAction()); + } + } + + + private WebApplicationException invalidGrant(String message) + { + // {"error":"invalid_grant", "error_description":""} + String content = buildErrorJson("invalid_grant", message); + + // 400 Bad Request with application/json + Response response = ResponseUtil.badRequestJson(content, mHeaders); + + // Wrap the response in a WebApplicationException. + return new WebApplicationException(response); + } + + + private WebApplicationException serverError(String message) + { + // {"error":"server_error", "error_description":""} + String content = buildErrorJson("server_error", message); + + // 500 Internal Server Error with application/json + Response response = ResponseUtil.internalServerErrorJson(content, mHeaders); + + // Wrap the response in a WebApplicationException. + return new WebApplicationException(response); + } + + + private WebApplicationException unknownAction(NativeSsoResponse.Action action) + { + String message = String.format( + "The /nativesso has returned an unknown action '%s'.", action); + + // 500 Internal Server Error with application/json + return serverError(message); + } + + + private static String buildErrorJson(String error, String description) + { + return String.format( + "{\n" + + " \"error\": \"%s\",\n" + + " \"error_description\": \"%s\"\n" + + "}\n", + error, description); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/OBBCertValidator.java b/src/main/java/com/authlete/jaxrs/server/api/OBBCertValidator.java new file mode 100644 index 0000000..2556d55 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/OBBCertValidator.java @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api; + + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.GeneralSecurityException; +import java.security.InvalidAlgorithmParameterException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Pattern; +import com.authlete.jaxrs.server.util.CertValidator; + + +public class OBBCertValidator extends CertValidator +{ + // The pattern of the environment variables each of which specifies + // the path of a root certificate. The range of the number at the + // end is from 0 to 9. + private static final String ENV_ROOT_CERTIFICATE_PATTERN = "^OBB_ROOT_CERTIFICATE_[0-9]$"; + + // Paths of root certificates that have been issued by OBB. These + // are used as fallback when valid paths are not specified via the + // environment variables. + private static final Path[] ROOT_CERTIFICATES = { + Paths.get(pwd(), "certs", "Open_Banking_Brasil_Sandbox_Root_G2.pem") + }; + + + private static OBBCertValidator sInstance; + private static boolean sInstantiationTried; + + + private OBBCertValidator() + throws CertificateException, InvalidAlgorithmParameterException, + NoSuchAlgorithmException, IOException + { + super(determineRootCertificates()); + } + + + private static Path[] determineRootCertificates() + { + // The pattern of names of environment variables each of which + // specifies the path of a root certificate. + Pattern pattern = Pattern.compile(ENV_ROOT_CERTIFICATE_PATTERN); + + Set pathSet = new TreeSet<>(); + + // For each environment variable. + for (Map.Entry entry : System.getenv().entrySet()) + { + // The name of the environment variable. + String name = entry.getKey(); + + // If the name of the environment variable does not match the pattern. + if (!pattern.matcher(name).matches()) + { + continue; + } + + // The path of a root certificate. + Path path = Paths.get(entry.getValue()); + + // If the path does not exist. + if (!Files.exists(path)) + { + System.err.format( + "[OBBCertValidator] Ignoring '%s' (specified by %s) because it does not exist.\n", + path.toString(), name); + continue; + } + + // If the path is a directory. + if (Files.isDirectory(path)) + { + System.err.format( + "[OBBCertValidator] Ignoring '%s' (specified by %s) because it is a directory.\n", + path.toString(), name); + continue; + } + + pathSet.add(path); + } + + // Paths collected from the environment variables or the fallback. + Path[] paths = (pathSet.size() == 0) ? ROOT_CERTIFICATES + : pathSet.toArray(new Path[pathSet.size()]); + + for (int i = 0; i < paths.length; ++i) + { + System.out.format( + "[OBBCertValidator] Using a root certificate [%d/%d]: %s\n", + (i+1), paths.length, paths[i].toString()); + } + + return paths; + } + + + private static String pwd() + { + return Paths.get("").toAbsolutePath().toString(); + } + + + public static synchronized OBBCertValidator getInstance() throws GeneralSecurityException + { + if (sInstantiationTried) + { + if (sInstance != null) + { + return sInstance; + } + + throw new GeneralSecurityException( + "Certificate validator for Open Banking Brasil is not available."); + } + + sInstantiationTried = true; + + try + { + sInstance = new OBBCertValidator(); + return sInstance; + } + catch (Exception e) + { + e.printStackTrace(); + + throw new GeneralSecurityException( + "Failed to create a certificate validator for Open Banking Brasil: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/OBBDCRConstants.java b/src/main/java/com/authlete/jaxrs/server/api/OBBDCRConstants.java new file mode 100644 index 0000000..132b871 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/OBBDCRConstants.java @@ -0,0 +1,294 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api; + + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + + +public class OBBDCRConstants +{ + // Client authentication methods allowed in the context of FAPI 1.0 Advanced. + public static final Set CLIENT_AUTHENTICATION_METHODS = toSet( + "private_key_jwt", + "tls_client_auth", + "self_signed_tls_client_auth" + ); + + + // A list of tls_client_auth_san_* client metadata defined in RFC 8705. + public static final List TLS_CLIENT_AUTH_SAN_CLIENT_METADATA = toList( + "tls_client_auth_san_dns", + "tls_client_auth_san_uri", + "tls_client_auth_san_ip", + "tls_client_auth_san_email" + ); + + + // A list of client metadata whose value is JWS alg. + public static final List JWS_ALG_CLIENT_METADATA = toList( + // OpenID Connect Dynamic Client Registration 1.0 + "id_token_signed_response_alg", + "userinfo_signed_response_alg", + "request_object_signing_alg", + "token_endpoint_auth_signing_alg", + + // OpenID Connect Client-Initiated Backchannel Authentication Flow - Core 1.0 + "backchannel_authentication_request_signing_alg", + + // JWT Secured Authorization Response Mode for OAuth 2.0 (JARM) + "authorization_signed_response_alg" + ); + + + // A list of client metadata whose value is JWE alg. + public static final List JWE_ALG_CLIENT_METADATA = toList( + // OpenID Connect Dynamic Client Registration 1.0 + "id_token_encrypted_response_alg", + "userinfo_encrypted_response_alg", + "request_object_encryption_alg", + + // JWT Secured Authorization Response Mode for OAuth 2.0 (JARM) + "authorization_encrypted_response_alg" + ); + + + // A list of client metadata whose value is JWE enc. + public static final List JWE_ENC_CLIENT_METADATA = toList( + // OpenID Connect Dynamic Client Registration 1.0 + "id_token_encrypted_response_enc", + "userinfo_encrypted_response_enc", + "request_object_encryption_enc", + + // JWT Secured Authorization Response Mode for OAuth 2.0 (JARM) + "authorization_encrypted_response_enc" + ); + + + // A list of claims (in a software statement) and parameters (in a request + // body) that this implementation remember as client metadata. + // + // There are no clear criteria on this yet. See also: + // + // [OpenBanking-Brasil/specs-seguranca] Issue 84 + // Question: Which claims in SSA should be kept as client metadata? + // + // https://github.com/OpenBanking-Brasil/specs-seguranca/issues/84 + // + public static final Set RECOGNIZED_CLIENT_METADATA = toSet( + //----------------------------------------------------------------------------------------- + // OpenID Connect Dynamic Client Registration 1.0 + //----------------------------------------------------------------------------------------- + "redirect_uris", + "response_types", + "grant_types", + "application_type", + "contacts", + "client_name", + "logo_uri", + "client_uri", + "policy_uri", + "tos_uri", + "jwks_uri", + // "jwks", // Prohibited by Open Banking Brasil + "sector_identifier_uri", + "subject_type", + "id_token_signed_response_alg", + "id_token_encrypted_response_alg", + "id_token_encrypted_response_enc", + "userinfo_signed_response_alg", + "userinfo_encrypted_response_alg", + "userinfo_encrypted_response_enc", + "request_object_signing_alg", + "request_object_encryption_alg", + "request_object_encryption_enc", + "token_endpoint_auth_method", + "token_endpoint_auth_signing_alg", + "default_max_age", + "require_auth_time", + "default_acr_values", + "initiate_login_uri", + "request_uris", + + //----------------------------------------------------------------------------------------- + // RFC 7591 OAuth 2.0 Dynamic Client Registration Protocol + //----------------------------------------------------------------------------------------- + // "redirect_uri", // Duplicate + // "token_endpoint_auth_method", // Duplicate + // "grant_types", // Duplicate + // "response_types", // Duplicate + // "client_name", // Duplicate + // "client_uri", // Duplicate + // "logo_uri", // Duplicate + "scope", + // "contacts", // Duplicate + // "tos_uri", // Duplicate + // "policy_uri", // Duplicate + // "jwks_uri", // Duplicate + // "jwks", // Duplicate & Prohibited by Open Banking Brasil + "software_id", + "software_version", + + //----------------------------------------------------------------------------------------- + // RFC 7592 OAuth 2.0 Dynamic Client Registration Management Protocol + //----------------------------------------------------------------------------------------- + "client_id", + "client_secret", + + //----------------------------------------------------------------------------------------- + // RFC 8705 OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens + //----------------------------------------------------------------------------------------- + "tls_client_certificate_bound_access_tokens", + "tls_client_auth_subject_dn", + // "tls_client_auth_san_dns", // Prohibited by Open Banking Brasil + // "tls_client_auth_san_uri", // Prohibited by Open Banking Brasil + // "tls_client_auth_san_ip", // Prohibited by Open Banking Brasil + // "tls_client_auth_san_email", // Prohibited by Open Banking Brasil + + //----------------------------------------------------------------------------------------- + // OpenID Connect Client-Initiated Backchannel Authentication Flow - Core 1.0 + //----------------------------------------------------------------------------------------- + "backchannel_token_delivery_mode", + "backchannel_client_notification_endpoint", + "backchannel_authentication_request_signing_alg", + "backchannel_user_code_parameter", + + //----------------------------------------------------------------------------------------- + // JWT Secured Authorization Request (JAR) + //----------------------------------------------------------------------------------------- + "require_signed_request_object", + + //----------------------------------------------------------------------------------------- + // JWT Secured Authorization Response Mode for OAuth 2.0 (JARM) + //----------------------------------------------------------------------------------------- + "authorization_signed_response_alg", + "authorization_encrypted_response_alg", + "authorization_encrypted_response_enc", + + //----------------------------------------------------------------------------------------- + // OAuth 2.0 Pushed Authorization Requests (PAR) + //----------------------------------------------------------------------------------------- + "require_pushed_authorization_requests", + + //----------------------------------------------------------------------------------------- + // OAuth 2.0 Rich Authorization Requests (RAR) + //----------------------------------------------------------------------------------------- + "authorization_details_types", + + //----------------------------------------------------------------------------------------- + // Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 + //----------------------------------------------------------------------------------------- + + // NOTE: + // There are no clear criteria for inclusion and exclusion. See also: + // + // [OpenBanking-Brasil/specs-seguranca] Issue 84 + // Question: Which claims in SSA should be kept as client metadata? + // + // https://github.com/OpenBanking-Brasil/specs-seguranca/issues/84 + // + + // "software_mode", + // "software_redirect_uris", + // "software_statement_roles", + "software_client_name", + // "org_status", + "software_client_id", + // "iss", + "software_tos_uri", + "software_client_description", + "software_jwks_uri", + "software_policy_uri", + // "software_id", // Duplicate + "software_client_uri", + "software_jwks_inactive_uri", + "software_jwks_transport_inactive_uri", + "software_logo_uri", + "org_id", + "org_number", + "software_environment", + // "software_version", // Duplicate + "software_roles", + "org_name" + // "iat", + // "organisation_competent_authority_claims" + ); + + + // Mapping from a role to scopes. + // + // Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 + // 7.2. Regulatory Roles to OpenID and OAuth 2.0 Mappings + // + // --------------------------------------------------------------------- + // | Regulatory Roles | Allowed Scopes | + // |------------------+------------------------------------------------| + // | DADOS | openid accounts credit-cards-accounts consents | + // | | customers invoice-financings financings loans | + // | | unarranged-accounts-overdraft resources | + // |------------------+------------------------------------------------| + // | PAGTO | openid payments consents resources | + // |------------------+------------------------------------------------| + // | CONTA | openid | + // |------------------+------------------------------------------------| + // | CCORR | openid | + // --------------------------------------------------------------------- + // + public static final Map> ROLE_TO_SCOPES = toMap( + "DADOS", toSet( + "openid", "accounts", "credit-cards-accounts", "consents", + "customers", "invoice-financings", "financings", "loans", + "unarranged-accounts-overdraft", "resources" + ), + "PAGTO", toSet("openid", "payments", "consents", "resources"), + "CONTA", toSet("openid"), + "CCORR", toSet("openid") + ); + + + @SuppressWarnings("unchecked") + private static List toList(T... elements) + { + return Arrays.asList(elements); + } + + + @SuppressWarnings("unchecked") + private static Set toSet(T... elements) + { + return new HashSet(Arrays.asList(elements)); + } + + + @SuppressWarnings("unchecked") + private static Map toMap(Object... elements) + { + Map map = new HashMap(); + + for (int i = 0; i < elements.length; i += 2) + { + map.put((TKey)elements[i], (TValue)elements[i+1]); + } + + return map; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/OBBDCRProcessor.java b/src/main/java/com/authlete/jaxrs/server/api/OBBDCRProcessor.java new file mode 100644 index 0000000..bac9b21 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/OBBDCRProcessor.java @@ -0,0 +1,1412 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api; + + +import static com.authlete.jaxrs.server.api.OBBDCRConstants.CLIENT_AUTHENTICATION_METHODS; +import static com.authlete.jaxrs.server.api.OBBDCRConstants.JWE_ALG_CLIENT_METADATA; +import static com.authlete.jaxrs.server.api.OBBDCRConstants.JWE_ENC_CLIENT_METADATA; +import static com.authlete.jaxrs.server.api.OBBDCRConstants.JWS_ALG_CLIENT_METADATA; +import static com.authlete.jaxrs.server.api.OBBDCRConstants.RECOGNIZED_CLIENT_METADATA; +import static com.authlete.jaxrs.server.api.OBBDCRConstants.ROLE_TO_SCOPES; +import static com.authlete.jaxrs.server.api.OBBDCRConstants.TLS_CLIENT_AUTH_SAN_CLIENT_METADATA; +import java.io.IOException; +import java.net.URL; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import com.authlete.common.util.Utils; +import com.authlete.jaxrs.server.obb.util.ObbUtils; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKMatcher; +import com.nimbusds.jose.jwk.JWKSelector; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.SignedJWT; + + +/** + * A sample implementation of Client Registration Endpoint that + * conforms to requirements of Open Banking Brasil. + * + *

+ * NOTE: It is not assured that this implementation is perfect. + * There are no warranties even if you have troubles by using + * and/or referencing this implementation. + *

+ * + * @see Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 Implementers Draft 1 + */ +public class OBBDCRProcessor +{ + // Named boolean values just for code readability. + private static boolean OPTIONAL = true; + private static boolean REQUIRED = false; + private static boolean NULLABLE = true; + private static boolean NOT_NULL = false; + private static boolean FROM_SS = true; + private static boolean FROM_BODY = false; + + + public Map process(HttpServletRequest request, String requestBody) + { + // Parse the request body. + Map requestParams = parseRequestBody(requestBody); + + // Validate the software statement. + SignedJWT softwareStatement = validateSoftwareStatement(requestParams); + + // Validate the client metadata. + Map ssClaims = validateClientMetadata(requestParams, softwareStatement); + + // Merge the client metadata. + return mergeClientMetadata(requestParams, ssClaims); + } + + + @SuppressWarnings("unchecked") + private Map parseRequestBody(String body) + { + // If the request has no body. + if (body == null) + { + throw invalidRequest("The request has no body."); + } + + Map params; + + try + { + // According to RFC 7591, the format of the body of a Client + // Registration Request is JSON, so let's parse the request + // body as JSON. + // + // FYI: In UK Open Banking, the format is JWT. In that sense, the + // Client Registration Endpoint of UK Open Banking does not conform + // to RFC 7591. See also: + // + // [OpenBanking-Brasil/specs-seguranca] Issue 86 + // Question : Should DCR payload be a JSON payload on the DCR request or JWS? + // + // https://github.com/OpenBanking-Brasil/specs-seguranca/issues/86 + // + params = Utils.fromJson(body, Map.class); + } + catch (Exception e) + { + // Failed to parse the request body as JSON. + e.printStackTrace(); + + throw invalidRequest("The request body is not JSON."); + } + + return params; + } + + + private SignedJWT validateSoftwareStatement(Map params) + { + // Extract a software statement from the request. + SignedJWT ss = extractSoftwareStatement(params); + + // Verify the signature of the software statement. + verifySoftwareStatementSignature(ss); + + return ss; + } + + + private SignedJWT extractSoftwareStatement(Map params) + { + // Dynamic Client Registration Request in Open Banking Brasil must + // include a software statement assertion that has been issued by + // the Directory. + // + // The "software_statement" request parameter is defined in Section + // 3.1.1 of RFC 7591. + + // If the request does not include "software_statement". + if (!params.containsKey("software_statement")) + { + throw invalidRequest( + "The request body does not include the 'software_statement' parameter."); + } + + // Extract the value of "software_statement". + Object ss = params.get("software_statement"); + + // If the value of "software_statement" is not a string. + if (!(ss instanceof String)) + { + throw invalidSoftwareStatement( + "The value of the 'software_statement' parameter is not a string."); + } + + try + { + // Parse the value of "software_statement" as a signed JWT. + return SignedJWT.parse((String)ss); + } + catch (ParseException e) + { + // Failed to parse "software_statement" as a signed JWT. + e.printStackTrace(); + + throw invalidSoftwareStatement( + "The value of the 'software_statement' parameter is not a signed JWT."); + } + } + + + private void verifySoftwareStatementSignature(SignedJWT ss) + { + // Check if the signature algorithm of the software statement is permitted. + checkSoftwareStatementSignatureAlgorithm(ss); + + // Get a verifier to verify the signature of the software statement. + JWSVerifier verifier = getVerifierForSoftwareStatementSignature(ss); + + boolean verified; + + try + { + // Verify the signature of the software statement with the verifier. + verified = ss.verify(verifier); + } + catch (JOSEException e) + { + // Failed to verify the signature of the software statement. + e.printStackTrace(); + + throw invalidSoftwareStatement( + "Failed to verify the signature of the software statement."); + } + + if (verified == false) + { + throw invalidSoftwareStatement( + "The signature of the software statement is invalid."); + } + } + + + private void checkSoftwareStatementSignatureAlgorithm(SignedJWT ss) + { + // The value of "alg" in the header. It represents the signature algorithm + // of the JWT. + JWSAlgorithm alg = ss.getHeader().getAlgorithm(); + + // Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 + // 7.1. Authorization server + // + // 2. shall validate that the request contains software_statement jwt + // signed using the PS256 algorithm issued by the Open Banking + // Brasil directory of participants; + + // The algorithm must be "PS256". + if (alg != JWSAlgorithm.PS256) + { + throw invalidSoftwareStatement( + "The signature algorithm of the software statement is not 'PS256'."); + } + } + + + private JWSVerifier getVerifierForSoftwareStatementSignature(SignedJWT ss) + { + // Get the JWK Set that contains the public key for signature verification. + JWKSet jwkset = getJwkSetForSoftwareStatementSignatureVerification(ss); + + // Select a JWK from the JWK Set. + JWK jwk = selectJwkForSoftwareStatementSignatureVerification(ss, jwkset); + + try + { + // Build a verifier from the JWK. Because Open Banking Brasil allows + // PS256 only, verifiers are always RSSSAVerifier instances. + return new RSASSAVerifier(((RSAKey)jwk).toRSAPublicKey()); + } + catch (JOSEException e) + { + // Failed to build a verifier. + e.printStackTrace(); + + throw serverError( + "Failed to create a verifier to verify the signature of the software statement with."); + } + } + + + private JWKSet getJwkSetForSoftwareStatementSignatureVerification(SignedJWT ss) + { + // Get the location of the JWK Set that contains the JWK whereby to verify + // the signature of the software statement. + String location = getDirectoryJwksLocation(ss); + + // Parameters for JWKSet.load() method. + int connectTimeout = 10000; // in milliseconds + int readTimeout = 10000; // in milliseconds + int sizeLimit = 0; // in bytes + + try + { + // Fetch the JWK Set from the location. + return JWKSet.load(new URL(location), connectTimeout, readTimeout, sizeLimit); + } + catch (IOException e) + { + // Failed to fetch the JWK Set. + e.printStackTrace(); + + throw serverError("Failed to fetch the JWK Set from '%s'.", location); + } + catch (ParseException e) + { + // Failed to parse the content as a JWK Set. + e.printStackTrace(); + + throw serverError("Failed to parse the content at '%s' as a JWK Set.", location); + } + } + + + private String getDirectoryJwksLocation(SignedJWT ss) + { + // This system property allows developers to specify the location of + // the JWK Set of the Directory for debugging and testing purposes. + // + // Developers can specify the system property like below when invoking + // this server. + // + // -Dobb.directory.jwks_uri=LOCATION_OF_JWK_SET + // + String location = System.getProperty("obb.directory.jwks_uri"); + + if (location != null) + { + // If the system property is given, we use it as the location of + // the JWK Set of the Directory. + return location; + } + + String environment = null; + + try + { + // Get the value of "software_environment" in the software statement. + environment = ss.getJWTClaimsSet().getStringClaim("software_environment"); + } + catch (Exception e) + { + } + + // Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 + // 9.2. Open Banking Brasil SSA Key Store and Issuer Details + // + // Production + // https://keystore.directory.openbankingbrasil.org.br/openbanking.jwks + // Open Banking Open Banking Brasil production SSA issuer + // + // Sandbox + // https://keystore.sandbox.directory.openbankingbrasil.org.br/openbanking.jwks + // Open Banking Open Banking Brasil sandbox SSA issuer + // + + // The example in the OBB DCR specification indicates that a software + // statement contains a "software_environment" claim. In the example, + // its value is "production". + // + // It is not explicitly written, but this implementation assumes that + // "software_environment":"production" means that the issuer of the + // software statement is the production SSA issuer. + + // In the case of "software_environment":"production". + if (environment != null && environment.equals("production")) + { + // The location of the JWK Set of the production SSA issuer. + return "https://keystore.directory.openbankingbrasil.org.br/openbanking.jwks"; + } + // In other cases. + else + { + // The location of the JWK Set of the sandbox SSA issuer. + return "https://keystore.sandbox.directory.openbankingbrasil.org.br/openbanking.jwks"; + } + } + + + private JWK selectJwkForSoftwareStatementSignatureVerification(SignedJWT ss, JWKSet jwkset) + { + // Prepare a selector that selects a JWK from a given JWK Set. + JWKMatcher matcher = JWKMatcher.forJWSHeader(ss.getHeader()); + JWKSelector selector = new JWKSelector(matcher); + + // Select JWKs that match the conditions from the JWK Set. + List jwks = selector.select(jwkset); + + if (jwks == null || jwks.size() == 0) + { + throw invalidSoftwareStatement( + "The JWK Set contains no JWK to verify the signature of the software statement with."); + } + + if (1 < jwks.size()) + { + throw invalidSoftwareStatement( + "The JWK Set contains multiple JWKs to verify the signature of the software statement with."); + } + + return jwks.get(0); + } + + + private Map validateClientMetadata( + Map requestParams, SignedJWT softwareStatement) + { + // Extract the payload part of the software statement. + Map ssClaims = extractClaimsFromSoftwareStatement(softwareStatement); + + // Perform validation specific to Open Banking Brasil. + + // OBB DCR + validateIat(requestParams, ssClaims); + validateJwks(requestParams, ssClaims); + validateJwksUri(requestParams, ssClaims); + validateRedirectUris(requestParams, ssClaims); + validateClientAuthenticationMethod(requestParams, ssClaims); + validateRequestObjectEncryption(requestParams, ssClaims); + validateScopesWithRoles(requestParams, ssClaims); + validateClientAuthSubject(requestParams, ssClaims); + + // OBB FAPI + validateJwsAlg(requestParams, ssClaims); + validateJweAlg(requestParams, ssClaims); + validateJweEnc(requestParams, ssClaims); + + return ssClaims; + } + + + private Map extractClaimsFromSoftwareStatement(SignedJWT ss) + { + try + { + // Get the payload part of the software statement as Map. + return ss.getJWTClaimsSet().getClaims(); + } + catch (Exception e) + { + // Failed to get the payload part of the software statement. + e.printStackTrace(); + + throw invalidSoftwareStatement( + "Failed to extract claims from the software statement."); + } + } + + + private void validateIat(Map requestParams, Map ssClaims) + { + // Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 + // 7.1. Authorization server + // + // 3. shall validate that the software_statement was issued (iat) not + // more than 5 minutes prior to the request being received; + + // Extract the value of the "iat" claim from the software statement. + Date iat = extractAsDate(ssClaims, "iat", REQUIRED, NOT_NULL, FROM_SS); + long iat_ = iat.getTime(); + + // The difference between the current time and the 'iat' in milliseconds. + // + // According to RFC 7519, the value of the "iat" claim is NumericDate + // which is "A JSON numeric value representing the number of seconds + // from 1970-01-01T00:00:00Z UTC until the specified UTC date/time, + // ignoring leap seconds." + // + // Date.getTime() returns the number of milliseconds elapsed since the + // Unix epoch. + // + Date now = new Date(); + long now_ = now.getTime(); + long diff = now_ - iat_; + + if (diff < 0L) + { + throw invalidSoftwareStatement( + "The issue time of the software statement is pointing to the future: now=%s(%d), iat=%s(%d)", + ObbUtils.formatDate(now), now_, ObbUtils.formatDate(iat), iat_); + } + + if (300000L < diff) + { + throw invalidSoftwareStatement( + "More than 5 minutes have passed since the issue time of the software statement: now=%s(%d), iat=%s(%d)", + ObbUtils.formatDate(now), now_, ObbUtils.formatDate(iat), iat_); + } + } + + + private void validateJwks( + Map requestParams, Map ssClaims) + { + // Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 + // 7.1. Authorization server + // + // 4. shall validate that a jwks (key set by value) was not included; + + // If the request body contains "jwks". + if (requestParams.containsKey("jwks")) + { + throw invalidClientMetadata( + "The request body contains a 'jwks' parameter."); + } + + // If the software statement contains "jwks". + if (ssClaims.containsKey("jwks")) + { + throw invalidClientMetadata( + "The software statement contains a 'jwks' claim."); + } + } + + + private void validateJwksUri( + Map requestParams, Map ssClaims) + { + // Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 + // 7.1. Authorization server + // + // 5. shall require and validate that the jwks_uri matches the + // software_jwks_uri provided in the software statement; + + // Extract "jwks_uri" from the request body. + String jwksUri = extractAsString( + requestParams, "jwks_uri", REQUIRED, NOT_NULL, FROM_BODY); + + // Extract "software_jwks_uri" from the software statement. + String softwareJwksUri = extractAsString( + ssClaims, "software_jwks_uri", REQUIRED, NOT_NULL, FROM_SS); + + // If "jwks_uri" and "software_jwks_uri" hold the same value. + if (jwksUri.equals(softwareJwksUri)) + { + // Okay. + return; + } + + throw invalidClientMetadata( + "The value of 'jwks_uri' and the value of 'software_jwks_uri' do not match."); + } + + + private void validateRedirectUris( + Map requestParams, Map ssClaims) + { + // Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 + // 7.1. Authorization server + // + // 6. shall require and validate that redirect_uris match or contain a sub + // set of software_redirect_uris provided in the software statement; + + // Extract "redirect_uris" from the request body. + List redirectUris = extractAsStringList( + requestParams, "redirect_uris", REQUIRED, NOT_NULL, FROM_BODY); + + // Extract "software_redirect_uris" from the software statement. + List softwareRedirectUris = extractAsStringList( + ssClaims, "software_redirect_uris", REQUIRED, NOT_NULL, FROM_SS); + + // Convert the list of redirect URIs into a Set instance for faster lookup. + Set softwareRedirectUriSet = new HashSet<>(softwareRedirectUris); + + for (int i = 0; i < redirectUris.size(); i++) + { + // If the value in 'redirect_uris' is included in 'software_redirect_uris'. + if (softwareRedirectUriSet.contains(redirectUris.get(i))) + { + // Okay. + continue; + } + + throw invalidRedirectUri( + "The 'software_redirect_uris' claim in the software statement " + + "does not include the value at the '%d' index of the " + + "'redirect_uris' parameter in the request body.", i); + } + } + + + private void validateClientAuthenticationMethod( + Map requestParams, Map ssClaims) + { + // Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 + // 7.1. Authorization server + // + // 7. shall require and validate that all client authentication mechanism + // adhere to the requirements defined in Financial-grade API Security + // Profile 1.0 - Part 2: Advanced; + + // In the context of FAPI 1.0 Advanced, permitted client authentication + // methods are as follows. + // + // 1. private_key_jwt + // 2. tls_client_auth + // 3. self_signed_tls_client_auth + // + // Among client metadata defined in the following specifications, + // + // - Section 2 of OpenID Connect Dynamic Client Registration 1.0 + // - Section 2 of RFC 7591 OAuth 2.0 Dynamic Client Registration Protocol + // + // "token_endpoint_auth_method" only takes a client authentication method. + // + // See also: + // + // [OpenBanking-Brasil/specs-seguranca] Issue 111 + // Question: Client Authentication Method at Introspection and Revocation Endpoints + // + // https://github.com/OpenBanking-Brasil/specs-seguranca/issues/111 + // + + // Obtain "token_endpoint_auth_method" from the client registration request. + String method = obtainAsString(requestParams, ssClaims, "token_endpoint_auth_method"); + + if (method == null) + { + // Open Banking Brasil Financial-grade API extends FAPI 1.0 Advanced. + // Because clients for FAPI 1.0 Advanced are all confidential clients, + // a client authentication method must be always set. + throw invalidClientMetadata( + "'token_endpoint_auth_method' is not specified or null."); + } + + // If the value of "token_endpoint_auth_method" is included in the list + // of valid client authentication methods. + if (CLIENT_AUTHENTICATION_METHODS.contains(method)) + { + // Okay. + return; + } + + throw invalidClientMetadata( + "The value of 'token_endpoint_auth_method' is not allowed."); + } + + + private void validateRequestObjectEncryption( + Map requestParams, Map ssClaims) + { + // Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 + // 7.1. Authorization server + // + // 8. shall require encrypted request objects as required by the Brasil + // Open Banking Security Profile; + + // Open Banking Brasil Financial-grade API Security Profile 1.0 Implementers Draft 1 + // 6.1.1. Encryption algorithm considerations + // + // For JWE, both clients and Authorization Servers + // + // 1. shall use RSA-OAEP with A256GCM + + // OpenID Connect Dynamic Client Registration 1.0 + // 2. Client Metadata + // + // request_object_encryption_alg + // OPTIONAL. JWE [JWE] alg algorithm [JWA] the RP is declaring that + // it may use for encrypting Request Objects sent to the OP. This + // parameter SHOULD be included when symmetric encryption will be + // used, since this signals to the OP that a client_secret value + // needs to be returned from which the symmetric key will be + // derived, that might not otherwise be returned. The RP MAY still + // use other supported encryption algorithms or send unencrypted + // Request Objects, even when this parameter is present. If both + // signing and encryption are requested, the Request Object will + // be signed then encrypted, with the result being a Nested JWT, + // as defined in [JWT]. The default, if omitted, is that the RP + // is not declaring whether it might encrypt any Request Objects. + // + // request_object_encryption_enc + // OPTIONAL. JWE enc algorithm [JWA] the RP is declaring that it + // may use for encrypting Request Objects sent to the OP. If + // request_object_encryption_alg is specified, the default for this + // value is A128CBC-HS256. When request_object_encryption_enc is + // included, request_object_encryption_alg MUST also be provided. + + // In short, "request_object_encryption_alg" must be "RSA-OAEP" and + // "request_object_encryption_enc" must be "A256GCM". + + // If "request_object_encryption_alg" is included in the client + // registration request, its value is checked in validateJweAlg(). + // If the metadata is not included, "RSA-OAEP" will be set later + // as the default value. + + // If "request_object_encryption_enc" is included in the client + // registration request, its value is checked in validateJweEnc(). + // If the metadata is not included, "A256GCM" will be set later + // as the default value. + + // As a result, there is nothing to do here. + } + + + private void validateScopesWithRoles( + Map requestParams, Map ssClaims) + { + // Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 + // 7.1. Authorization server + // + // 9. shall validate that requested scopes are appropriate for the + // softwares authorized regulatory roles; + + // Extract "scope". + String scope = obtainAsString(requestParams, ssClaims, "scope"); + + // If the metadata does not contain 'scope' or its value is empty. + if (scope == null || scope.length() == 0) + { + // Nothing to validate here. + return; + } + + // True if the scope originates from the software statement. + boolean fromSS = ssClaims.containsKey("scope"); + + // The value of 'scope' is space-separated scope names. + String[] requestedScopes = scope.split(" +"); + + // Extract "software_roles" from the software statement. + List roles = extractAsStringList( + ssClaims, "software_roles", REQUIRED, NOT_NULL, FROM_SS); + + // For each requested scope. + for (String requestedScope : requestedScopes) + { + // Check if the requested scope is allowed for the roles. + validateScopeWithRoles(requestedScope, roles, fromSS); + } + + // Okay. All the requested scopes are allowed. + } + + + private void validateScopeWithRoles( + String requestedScope, List roles, boolean fromSS) + { + // For each role. + for (String role : roles) + { + // The scopes allowed for the role. + Set allowedScopes = getAllowedScopesForRole(role); + + // If the set of allowed scopes contains the requested scope. + if (allowedScopes.contains(requestedScope)) + { + // Okay. The requested scopes is allowed by the role. + return; + } + } + + throw this.invalidClientMetadata(fromSS, + "'%s' in the 'scope' claim in the software statement is not allowed by any role in 'software_roles'.", + "'%s' in the 'scope' parameter in the request body is not allowed by any role in 'software_roles'.", + requestedScope); + } + + + private Set getAllowedScopesForRole(String role) + { + // The scopes allowed for the role. + Set allowedScopes = ROLE_TO_SCOPES.get(role); + + // If allowed scopes for the role are not available. + if (allowedScopes == null) + { + // This means that the role is unknown to this implementation. + throw invalidSoftwareStatement( + "The role '%s' included in 'software_roles' is unknown.", role); + } + + return allowedScopes; + } + + + private void validateClientAuthSubject( + Map requestParams, Map ssClaims) + { + // Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 + // 7.1. Authorization server + // + // 12. if supporting tls_client_auth client authentication mechanism + // as defined in RFC8705 shall only accept tls_client_auth_subject_dn + // as an indication of the certificate subject value as defined + // in clause 2.1.2 RFC8705; + + // RFC 8705 OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens + // 2.1.2. Client Registration Metadata + // + // ... A client using the tls_client_auth authentication method MUST + // use exactly one of the below metadata parameters to indicate the + // certificate subject value that the authorization server is to + // expect when authenticating the respective client. + // + // tls_client_auth_subject_dn + // ... + // tls_client_auth_san_dns + // ... + // tls_client_auth_san_uri + // ... + // tls_client_auth_san_ip + // ... + // tls_client_auth_san_email + // ... + + // In summary, tls_client_auth_san_* client metadata are not allowed. + + // For each "tls_client_auth_san_*" client metadata defined in RFC 8705 + for (String metadata : TLS_CLIENT_AUTH_SAN_CLIENT_METADATA) + { + // If the software statement contains the metadata. + if (ssClaims.containsKey(metadata)) + { + throw invalidClientMetadata( + "The software statement contains a '%s' claim.", metadata); + } + + // If the request body contains the metadata. + if (requestParams.containsKey(metadata)) + { + throw invalidClientMetadata( + "The request body contains a '%s' parameter.", metadata); + } + } + } + + + private void validateJwsAlg( + Map requestParams, Map ssClaims) + { + // Open Banking Brasil Financial-grade API Security Profile 1.0 + // 6.1. Algorithm considerations + // + // For JWS, both clients and Authorization Servers + // + // 1. shall use PS256 algorithm; + + // For each client metadata whose value is JWS alg. + for (String metadata : JWS_ALG_CLIENT_METADATA) + { + String alg = obtainAsString(requestParams, ssClaims, metadata); + + if (alg == null || alg.equals("PS256")) + { + continue; + } + + throw invalidClientMetadata( + "The value of '%s' must be 'PS256' if specified.", metadata); + } + } + + + private void validateJweAlg( + Map requestParams, Map ssClaims) + { + // Open Banking Brasil Financial-grade API Security Profile 1.0 + // 6.1.1. Encryption algorithm considerations + // + // For JWE, both clients and Authorization Servers + // + // 1. shall use RSA-OAEP with A256GCM + + // For each client metadata whose value is JWE alg. + for (String metadata : JWE_ALG_CLIENT_METADATA) + { + String alg = obtainAsString(requestParams, ssClaims, metadata); + + if (alg == null || alg.equals("RSA-OAEP")) + { + continue; + } + + throw invalidClientMetadata( + "The value of '%s' must be 'RSA-OAEP' if specified.", metadata); + } + } + + + private void validateJweEnc( + Map requestParams, Map ssClaims) + { + // Open Banking Brasil Financial-grade API Security Profile 1.0 + // 6.1.1. Encryption algorithm considerations + // + // For JWE, both clients and Authorization Servers + // + // 1. shall use RSA-OAEP with A256GCM + + // For each client metadata whose value is JWE enc. + for (String metadata : JWE_ENC_CLIENT_METADATA) + { + String enc = obtainAsString(requestParams, ssClaims, metadata); + + if (enc == null || enc.equals("A256GCM")) + { + continue; + } + + throw invalidClientMetadata( + "The value of '%s' must be 'A256GCM' if specified.", metadata); + } + } + + + private Map mergeClientMetadata(Map requestParams, Map ssClaims) + { + // Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 + // 7.1. Authorization server + // + // 10. should where possible validate client asserted metadata + // against metadata provided in the software_statement; + + // This implementation does not check consistency between the sets of + // metadata. In any case, metadata in the software statement take + // precedence as RFC 7591 requires so. + + Map merged = new HashMap(); + + // For each recognized client metadata. + for (String metadata : RECOGNIZED_CLIENT_METADATA) + { + // Get the value of the metadata from the software statement or + // the request body. The value in the software statement takes + // precedence. + Object value = obtainAsObject(requestParams, ssClaims, metadata); + + if (value != null) + { + merged.put(metadata, value); + } + } + + // Adjust client metadata. + adjustClientMetadata(merged, ssClaims); + + return merged; + } + + + private void adjustClientMetadata(Map merged, Map ssClaims) + { + // Open Banking Brasil requires that JWS alg be always "PS256". + + // By definition, ID Tokens are always signed. + merged.putIfAbsent("id_token_signed_response_alg", "PS256"); + + // FAPI 1.0 Advanced requires that a Request Object be always used + // and signed. The "require_signed_request_object" client metadata + // is defined in JWT Secured Authorization Request (JAR). + // + // Setting true to "require_signed_request_object" will require + // that the authorization server process Request Objects based on + // the rules defined in JAR. See the following article for details. + // + // Implementer’s note about JAR (JWT Secured Authorization Request) + // https://darutk.medium.com/implementers-note-about-jar-fff4cbd158fe + // + merged.putIfAbsent("request_object_signing_alg", "PS256"); + merged.putIfAbsent("require_signed_request_object", Boolean.TRUE); + + // Open Banking Brasil requires that Request Objects be encrypted + // with "RSA-OAEP" and "A256GCM". + merged.putIfAbsent("request_object_encryption_alg", "RSA-OAEP"); + merged.putIfAbsent("request_object_encryption_enc", "A256GCM"); + + // The explanation of the "request_object_encryption_alg" client + // metadata in "OpenID Connect Dynamic Client Registration 1.0" + // states as follows: + // + // request_object_encryption_alg + // + // OPTIONAL. JWE [JWE] alg algorithm [JWA] the RP is declaring + // that it may use for encrypting Request Objects sent to the OP. + // This parameter SHOULD be included when symmetric encryption + // will be used, since this signals to the OP that a client_secret + // value needs to be returned from which the symmetric key will be + // derived, that might not otherwise be returned. The RP MAY still + // use other supported encryption algorithms or send unencrypted + // Request Objects, even when this parameter is present. If both + // signing and encryption are requested, the Request Object will + // be signed then encrypted, with the result being a Nested JWT, + // as defined in [JWT]. The default, if omitted, is that the RP is + // not declaring whether it might encrypt any Request Objects. + // + // According to this explanation, setting the client metadata does not + // mean forcing the client to use the specified algorithm. It cannot + // even force the client to encrypt request objects. + // + // Therefore, to meet the following requirement of Open Banking Brasil, + // + // Open Banking Brasil Financial-grade API Security Profile 1.0 + // 5.2.2. Authorization server + // + // 1. shall support a signed and encrypted JWE request object passed + // by value or shall require pushed authorization requests PAR; + // + // non-standard mechanisms are needed. Authlete fulfills the requirement + // by Authlete-specific client properties. See the JavaDoc of the Client + // class for details. + // + // JavaDoc of authlete-java-common library + // https://authlete.github.io/authlete-java-common/ + // + merged.putIfAbsent("authlete:frontChannelRequestObjectEncryptionRequired", Boolean.TRUE); + merged.putIfAbsent("authlete:requestObjectEncryptionAlgMatchRequired", Boolean.TRUE); + merged.putIfAbsent("authlete:requestObjectEncryptionEncMatchRequired", Boolean.TRUE); + + // The "token_endpoint_auth_signing_alg" client metadata has a meaning + // only when a client assertion is used for client authentication. + merged.putIfAbsent("token_endpoint_auth_signing_alg", "PS256"); + + // The "backchannel_authentication_request_signing_alg" client metadata + // has a meaning only when a backchannel authentication request contains + // the "request" request parameter. + merged.putIfAbsent("backchannel_authentication_request_signing_alg", "PS256"); + + // The "authorization_signed_response_alg" client metadata has a meaning + // only when "response_mode=[[query|fragment|form_post].]jwt" is given. + merged.putIfAbsent("authorization_signed_response_alg", "PS256"); + + // Note that the default value is not set for "userinfo_signed_response_alg". + // It's because setting an algorithm to the client metadata would change + // the format of responses from the UserInfo endpoint. + + // Open Banking Brasil Financial-grade API is based on + // "FAPI 1.0 Advanced" which requires certificate-bound access tokens. + merged.putIfAbsent("tls_client_certificate_bound_access_tokens", Boolean.TRUE); + + // the latest security profile ("v2") requires that id tokens are always encrypted + merged.putIfAbsent("id_token_encrypted_response_alg", "RSA-OAEP"); + merged.putIfAbsent("id_token_encrypted_response_enc", "A256GCM"); + // and that an acr value is always returned + merged.putIfAbsent("default_acr_values", Arrays.asList("urn:brasil:openbanking:loa3")); + + // Use some claims in the software statement as default values + // for some standard claims. See also: + // + // [OpenBanking-Brasil/specs-seguranca] Issue 114 + // Question: software_* in SSA as defaults for standard client metadata + // + // https://github.com/OpenBanking-Brasil/specs-seguranca/issues/114 + // + useAsDefault(merged, ssClaims, "software_client_name", "client_name"); + useAsDefault(merged, ssClaims, "software_tos_uri", "tos_uri"); + useAsDefault(merged, ssClaims, "software_client_description", "client_description"); + useAsDefault(merged, ssClaims, "software_policy_uri", "policy_uri"); + useAsDefault(merged, ssClaims, "software_client_uri", "client_uri"); + useAsDefault(merged, ssClaims, "software_logo_uri", "logo_uri"); + + // Adjust "scope". + adjustScope(merged); + } + + + private void useAsDefault( + Map merged, Map ssClaims, + String sourceKey, String targetKey) + { + // If the target key already exists in the merged set of client metadata. + if (merged.containsKey(targetKey)) + { + return; + } + + // If the source key does not exist in the software statement. + if (!ssClaims.containsKey(sourceKey)) + { + return; + } + + // Use the value in the software statement as the default value. + merged.put(targetKey, ssClaims.get(sourceKey)); + } + + + private void adjustScope(Map merged) + { + // Extract "software_roles". The software statement must include it. + List roles = extractAsStringList( + merged, "software_roles", REQUIRED, NOT_NULL, FROM_SS); + + // The "scope" in the merged client metadata. + String scope = (String)merged.get("scope"); + + if (scope == null) + { + // Prepare scopes based on the regulatory roles which are + // listed in the "software_roles" claim. + scope = prepareScopeByRoles(roles); + merged.put("scope", scope); + } + + // Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 + // Regulatory Roles to dynamic OAuth 2.0 scope Mappings + // + // ----------------------------------------- + // | Regulatory Role | Allowed Scopes | + // |-----------------+---------------------| + // | DADOS | consent:{ConsentId} | + // | PAGTO | consent:{ConsentId} | + // ----------------------------------------- + // + // For Authlete customers, + // + // To support the "Dynamic Consent Scope" defined in OBB FAPI, the + // "consents" scope of your authorization server must have a scope + // attribute whose name is "regex" and whose value is a regular + // expression that matches "consent:{ConsentId}". For example, + // "^consent:.+$". + // + // The "scope attribute" feature is specific to Authlete. Other solutions + // provide different approaches for the "Dynamic Consent Scope". + // + // See the following articles for details about Authlete's approach for + // dynamic scopes. + // + // [Blog] Implementer’s note about Open Banking Brasil + // https://darutk.medium.com/implementers-note-about-open-banking-brasil-78d3d612dfaf + // + // [Authlete Knowledge Base] Using “parameterized scopes” + // https://kb.authlete.com/en/s/oauth-and-openid-connect/a/parameterized-scopes + // + } + + + private String prepareScopeByRoles(List roles) + { + Set scopes = new HashSet(); + + // For each role listed in "software_roles". + for (String role : roles) + { + // The scopes allowed for the role. + Set allowedScopes = getAllowedScopesForRole(role); + + // Accumulate the allowed scopes without duplicates. + scopes.addAll(allowedScopes); + } + + // Concatenate the scopes with spaces. + return String.join(" ", scopes); + } + + + private Object extractAsObject( + Map map, String key, boolean optional, boolean nullable, boolean isSoftwareStatement) + { + // If the map does not include the key. + if (!map.containsKey(key)) + { + if (optional) + { + // The map does not include the key, but it is allowed. + return null; + } + + throw invalidClientMetadata(isSoftwareStatement, + "The software statement does not include the '%s' claim.", + "The request body does not include the '%s' parameter.", key); + } + + // Get the value from the map. + Object value = map.get(key); + + if (value == null) + { + if (nullable) + { + // The value of the entry is null, but it is allowed. + return null; + } + + throw invalidClientMetadata(isSoftwareStatement, + "The value of the '%s' claim in the software statement is null.", + "The value of the '%s' parameter in the request body is null.", key); + } + + // The map includes an entry for the key and its value is not null. + return value; + } + + + private String extractAsString( + Map map, String key, boolean optional, boolean nullable, boolean isSoftwareStatement) + { + // Extract the object from the map. + Object value = extractAsObject(map, key, optional, nullable, isSoftwareStatement); + if (value == null) + { + // The existence of the key is optional or null is allowed. + return null; + } + + // If the type of the value is not a string. + if (!(value instanceof String)) + { + throw invalidClientMetadata(isSoftwareStatement, + "The value of the '%s' claim in the software statement is not a string: class=%s", + "The value of the '%s' parameter in the request body is not a string: class=%s", + key, value.getClass().getName()); + } + + // The map includes an entry for the key and its value is a string. + return (String)value; + } + + + @SuppressWarnings("unused") + private Long extractAsLong( + Map map, String key, boolean optional, boolean nullable, boolean isSoftwareStatement) + { + // Extract the object from the map + Object value = extractAsObject(map, key, optional, nullable, isSoftwareStatement); + if (value == null) + { + // The existence of the key is optional or null is allowed. + return null; + } + + // If the type of the value is not a number. + if (!(value instanceof Number)) + { + throw invalidClientMetadata(isSoftwareStatement, + "The value of the '%s' claim in the software statement is not a number: class=%s", + "The value of the '%s' parameter in the request body is not a number: class=%s", + key, value.getClass().getName()); + } + + // The map includes an entry for the key and its value can be interpreted as Long. + return ((Number)value).longValue(); + } + + + private Date extractAsDate( + Map map, String key, boolean optional, boolean nullable, boolean isSoftwareStatement) + { + // Extract the object from the map + Object value = extractAsObject(map, key, optional, nullable, isSoftwareStatement); + if (value == null) + { + // The existence of the key is optional or null is allowed. + return null; + } + + // If the type of the value is not a Date. + if (!(value instanceof Date)) + { + throw invalidClientMetadata(isSoftwareStatement, + "The value of the '%s' claim in the software statement is not a date: class=%s", + "The value of the '%s' parameter in the request body is not a date: class=%s", + key, value.getClass().getName()); + } + + // The map includes an entry for the key and its value can be interpreted as Date. + return (Date)value; + } + + + private List extractAsStringList( + Map map, String key, boolean optional, boolean nullable, boolean isSoftwareStatement) + { + // Extract the object from the map + Object value = extractAsObject(map, key, optional, nullable, isSoftwareStatement); + if (value == null) + { + // The existence of the key is optional or null is allowed. + return null; + } + + // If the type of the value is not a list. + if (!(value instanceof List)) + { + throw invalidClientMetadata(isSoftwareStatement, + "The value of the '%s' claim in the software statement is not an array: class=%s", + "The value of the '%s' parameter in the request body is not an array: class=%s", + key, value.getClass().getName()); + } + + List list = (List)value; + int size = list.size(); + + List result = new ArrayList(size); + + for (int i = 0; i < size; i++) + { + Object element = list.get(i); + + // If the element is null or a string. + if (element == null || element instanceof String) + { + result.add((String)element); + continue; + } + + throw invalidClientMetadata(isSoftwareStatement, + "The value at the index '%d' of the '%s' claim in the software statement is not a string: class=%s", + "The value at the index '%d' of the '%s' parameter in the request body is not a string: class=%s", + i, key, element.getClass().getName()); + } + + return result; + } + + + private Object obtainAsObject(Map requestParams, Map ssClaims, String key) + { + // If the software statement contains the key. + if (ssClaims.containsKey(key)) + { + // Extract from the software statement. + return extractAsObject(ssClaims, key, OPTIONAL, NULLABLE, FROM_SS); + } + + // Extract from the request body. + return extractAsObject(requestParams, key, OPTIONAL, NULLABLE, FROM_BODY); + } + + + private String obtainAsString(Map requestParams, Map ssClaims, String key) + { + // If the software statement contains the key. + if (ssClaims.containsKey(key)) + { + // Extract from the software statement. + return extractAsString(ssClaims, key, OPTIONAL, NULLABLE, FROM_SS); + } + + // Extract from the request body. + return extractAsString(requestParams, key, OPTIONAL, NULLABLE, FROM_BODY); + } + + + public static WebApplicationException errorResponse(Status status, String code, String description) + { + String body = String.format( + "{\n" + + " \"error\": \"%s\",\n" + + " \"error_description\": \"%s\"\n" + + "}\n", + code, description) + ; + + Response response = Response + .status(status) + .type(MediaType.APPLICATION_JSON_TYPE) + .entity(body) + .build() + ; + + return new WebApplicationException(response); + } + + + private static WebApplicationException badRequest(String code, String description) + { + // RFC 7591 OAuth 2.0 Dynamic Client Registration Protocol + // 3.2.2. Client Registration Error Response + // + // When a registration error condition occurs, the authorization + // server returns an HTTP 400 status code (unless otherwise specified) + // with content type "application/json" consisting of a JSON object + // [RFC7159] describing the error in the response body. + // + return errorResponse(Status.BAD_REQUEST, code, description); + } + + + private static WebApplicationException invalidRequest(String format, Object... args) + { + return badRequest("invalid_request", String.format(format, args)); + } + + + private WebApplicationException invalidRedirectUri(String format, Object... args) + { + // RFC 7591, 3.2.2. Client Registration Error Response + // + // invalid_redirect_uri + // The value of one or more redirection URIs is invalid. + // + return badRequest("invalid_redirect_uri", String.format(format, args)); + } + + + private WebApplicationException invalidClientMetadata(String format, Object... args) + { + // RFC 7591, 3.2.2. Client Registration Error Response + // + // invalid_client_metadata + // The value of one of the client metadata fields is invalid and + // the server has rejected this request. Note that an authorization + // server MAY choose to substitute a valid value for any requested + // parameter of a client's metadata. + // + return badRequest("invalid_client_metadata", String.format(format, args)); + } + + + private WebApplicationException invalidClientMetadata( + boolean isSoftwareStatement, String formatForSS, String format, Object... args) + { + return invalidClientMetadata(isSoftwareStatement ? formatForSS : format, args); + } + + + private WebApplicationException invalidSoftwareStatement(String format, Object... args) + { + // RFC 7591, 3.2.2. Client Registration Error Response + // + // invalid_software_statement + // The software statement presented is invalid. + // + return badRequest("invalid_software_statement", String.format(format, args)); + } + + + private WebApplicationException serverError(String format, Object... args) + { + // Arguable on the HTTP status code in the case of "error":"server_error". + return badRequest("server_error", String.format(format, args)); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/OBBTokenTask.java b/src/main/java/com/authlete/jaxrs/server/api/OBBTokenTask.java new file mode 100644 index 0000000..c964910 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/OBBTokenTask.java @@ -0,0 +1,210 @@ +package com.authlete.jaxrs.server.api; + + +import java.util.Map; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.obb.database.ConsentDao; +import com.authlete.jaxrs.server.obb.model.Consent; +import com.authlete.jaxrs.server.obb.util.ObbUtils; + + +public class OBBTokenTask +{ + public void process( + AuthleteApi authleteApi, HttpServletRequest request, + MultivaluedMap requestParams, + Response response, Map responseParams) + { + // If further processing is not needed. + if (!needsProcessing(requestParams, response, responseParams)) + { + // Nothing to do. + return; + } + + // Get the consent ID associated with the access token. + String consentId = extractConsentId(responseParams); + + // If no consent ID is associated with the access token. + if (consentId == null) + { + // Nothing to do. + return; + } + + // Get the consent corresponding to the consent ID. + Consent consent = ConsentDao.getInstance().read(consentId); + + // If there is no consent which corresponds to the consent ID. + if (consent == null) + { + // Delete the access token (and the refresh token). + deleteAccessToken(authleteApi, responseParams); + + // Return an error response to the client application. + throw badRequestException("invalid_request", String.format( + "There is no consent corresponding to the consent ID '%s'.", consentId)); + } + + // Task on a refresh token. + doConsentTaskOnRefreshToken(authleteApi, responseParams, consent); + } + + + private static boolean needsProcessing( + MultivaluedMap requestParams, + Response response, Map responseParams) + { + // If the token request failed. + if (response.getStatus() != Status.OK.getStatusCode()) + { + // Nothing to do. + return false; + } + + // If the token request is a refresh token request. + String grantType = requestParams.getFirst("grant_type"); + if (grantType != null && grantType.equals("refresh_token")) + { + // Because Open Baning Brasil prohibits refresh token rotation, + // no new refresh token is issued by the refresh token request. + // + // The value of "refresh_token" in the response, even if any, + // holds the same value of "refresh_token" in the request. + // + // To make the service behave in this way, the setting of the + // "Service.refreshTokenKept" flag needs to be set to true. + // On the web console, "Refresh Token Continuous Use" represents + // the flag. Selecting the option "Kept" prevents the Service + // from doing refresh token rotation. + + // Nothing to do. + return false; + } + + // If no refresh token has been issued. + if (!responseParams.containsKey("refresh_token")) + { + // Nothing to do. + return false; + } + + // There are some tasks to be done for the newly issued refresh token. + return true; + } + + + private static String extractConsentId(Map responseParams) + { + // Get the value of the "scope" response parameter. + String scope = (String)responseParams.get("scope"); + + // If the token response does not contain "scope". + if (scope == null) + { + // Nothing to do. + return null; + } + + // The value of "scope" is a space-delimited scope names. + String[] scopes = scope.split(" +"); + + // Extract a "consent:{consentId}" scope from the scope list. + String consentScope = ObbUtils.extractConsentScope(scopes); + + // If the scope list does not contain "consent:{consentId}". + if (consentScope == null) + { + // Consent ID is not available. + return null; + } + + // Extract the "{consentId}" part from "consent:{consentId}". + return consentScope.substring(8); + } + + + private static void deleteAccessToken( + AuthleteApi authleteApi, Map responseParams) + { + // The access token issued for the token request. + String accessToken = (String)responseParams.get("access_token"); + + // If the token response does not contain "access_token". + if (accessToken == null) + { + // This won't happen. + return; + } + + try + { + // Delete the access token. Authlete will remove the refresh + // token that is coupled with the access token, too. + authleteApi.tokenDelete(accessToken); + } + catch (Exception e) + { + // Ignore the error. + } + } + + + private static void doConsentTaskOnRefreshToken( + AuthleteApi authleteApi, Map responseParams, Consent consent) + { + // The refresh token issued for the token request. + String refreshToken = (String)responseParams.get("refresh_token"); + + // Open Banking Brasil Financial-grade API Security Profile 1.0 + // 7.2.2. Authorization server + // + // 1. shall issue refresh tokens with validity equal to the + // expirationDateTime defined on the linked Consent Resource; + + // Change the expiration date of the refresh token. + changeRefreshTokenExpirationDate( + authleteApi, refreshToken, consent.getExpirationDateTime()); + + // Bind the refresh token to the consent. + consent.setRefreshToken(refreshToken); + ConsentDao.getInstance().update(consent); + } + + + private static void changeRefreshTokenExpirationDate( + AuthleteApi authleteApi, String refreshToken, String expirationDate) + { + // TODO + // Authlete will provide an API whereby to change the expiration date + // of a refresh token. + } + + + private static WebApplicationException badRequestException( + String code, String description) + { + // 400 Bad Request with Content-Type:application/json. + Response response = Response.status(Status.BAD_REQUEST) + .type(MediaType.APPLICATION_JSON_TYPE) + .entity(error(code, description)) + .build() + ; + + return new WebApplicationException(response); + } + + + private static String error(String code, String description) + { + return String.format( + "{\n \"error\":\"%s\",\n \"error_description\":\"%s\"\n}\n", + code, description); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/PushedAuthReqEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/PushedAuthReqEndpoint.java new file mode 100644 index 0000000..3d8a92d --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/PushedAuthReqEndpoint.java @@ -0,0 +1,96 @@ +package com.authlete.jaxrs.server.api; + + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.jakarta.BasePushedAuthReqEndpoint; +import com.authlete.jakarta.PushedAuthReqHandler.Params; + + +/** + * An implementation of a pushed authorization endpoint. + * + * @see OAuth 2.0 Pushed Authorization Requests + * + * @author Justin Richer + * + */ +@Path("/api/par") +public class PushedAuthReqEndpoint extends BasePushedAuthReqEndpoint +{ + /** + * The pushed authorization request endpoint. This uses the + * {@code POST} method and the same client authentication as + * is available on the Token Endpoint. + */ + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response post( + @Context HttpServletRequest request, + MultivaluedMap parameters) + { + // Authlete API + AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi(); + + // Parameters for Authlete's pushed_auth_req API. + Params params = buildParams(request, parameters); + + // Handle the PAR request. + return handle(authleteApi, params); + } + + + private Params buildParams( + HttpServletRequest request, MultivaluedMap parameters) + { + Params params = new Params(); + + // RFC 6749 + // The OAuth 2.0 Authorization Framework + params.setParameters(parameters) + .setAuthorization(request.getHeader(HttpHeaders.AUTHORIZATION)) + ; + + // MTLS + // RFC 8705 : OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens + params.setClientCertificatePath(extractClientCertificateChain(request)); + + // DPoP + // RFC 9449 : OAuth 2.0 Demonstrating Proof of Possession (DPoP) + params.setDpop(request.getHeader("DPoP")) + .setHtm("POST") + //.setHtu(request.getRequestURL().toString()) + ; + + // We can reconstruct the URL of the PAR endpoint by calling + // request.getRequestURL().toString() and set it to params by the + // setHtu(String) method. However, the calculated URL may be invalid + // behind proxies. + // + // If "htu" is not set here, the "pushedAuthReqEndpoint" property of + // "Service" (which can be configured by using Authlete's web console) + // is referred to as the default value. Therefore, we don't call the + // setHtu(String) method here intentionally. Note that this means you + // have to set "pushedAuthReqEndpoint" properly to support DPoP. + + // Even the call of the setHtm(String) method can be omitted, too. + // When "htm" is not set, "POST" is used as the default value. + + // OAuth 2.0 Attestation-Based Client Authentication + params.setClientAttestation( request.getHeader("OAuth-Client-Attestation")) + .setClientAttestationPop(request.getHeader("OAuth-Client-Attestation-PoP")) + ; + + return params; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/RevocationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/RevocationEndpoint.java index 1829bf5..71c0ba8 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/RevocationEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/RevocationEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 Authlete, Inc. + * Copyright (C) 2016-2024 Authlete, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,24 +17,27 @@ package com.authlete.jaxrs.server.api; -import javax.ws.rs.Consumes; -import javax.ws.rs.HeaderParam; -import javax.ws.rs.POST; -import javax.ws.rs.Path; -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.MultivaluedMap; -import javax.ws.rs.core.Response; -import com.authlete.common.api.AuthleteApiFactory; -import com.authlete.jaxrs.BaseRevocationEndpoint; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.jakarta.BaseRevocationEndpoint; +import com.authlete.jakarta.RevocationRequestHandler.Params; /** * An implementation of revocation endpoint (RFC 7009). + * "https://www.rfc-editor.org/rfc/rfc7009.html">RFC 7009). * - * @see RFC 7009, OAuth 2.0 Token Revocation + * @see RFC 7009: OAuth 2.0 Token Revocation * * @author Takahiko Kawasaki */ @@ -44,16 +47,46 @@ public class RevocationEndpoint extends BaseRevocationEndpoint /** * The revocation endpoint for {@code POST} method. * - * @see RFC 7009, 2.1. Revocation Request */ @POST @Consumes(MediaType.APPLICATION_FORM_URLENCODED) public Response post( - @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, + @Context HttpServletRequest request, MultivaluedMap parameters) { + // Authlete API + AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi(); + + // Parameters for Authlete's /auth/revocation API + Params params = buildParams(request, parameters); + // Handle the revocation request. - return handle(AuthleteApiFactory.getDefaultApi(), parameters, authorization); + return handle(authleteApi, params); + } + + + private Params buildParams( + HttpServletRequest request, MultivaluedMap parameters) + { + Params params = new Params(); + + // RFC 6749 + // The OAuth 2.0 Authorization Framework + params.setParameters(parameters) + .setAuthorization(request.getHeader(HttpHeaders.AUTHORIZATION)) + ; + + // MTLS + // RFC 8705 : OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens + params.setClientCertificatePath(extractClientCertificateChain(request)); + + // OAuth 2.0 Attestation-Based Client Authentication + params.setClientAttestation( request.getHeader("OAuth-Client-Attestation")) + .setClientAttestationPop(request.getHeader("OAuth-Client-Attestation-PoP")) + ; + + return params; } } diff --git a/src/main/java/com/authlete/jaxrs/server/api/TestEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/TestEndpoint.java new file mode 100644 index 0000000..1b48c34 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/TestEndpoint.java @@ -0,0 +1,112 @@ +package com.authlete.jaxrs.server.api; + + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + + +@Path("/api/test") +public class TestEndpoint +{ + /** + * Returns HTTP headers that this endpoint received in JSON format. + */ + @GET + @Path("headers") + public Response headers(@Context HttpServletRequest req) throws Exception + { + Map map = new TreeMap<>(); + + Enumeration headerNameEnumerator = req.getHeaderNames(); + + while (headerNameEnumerator.hasMoreElements()) + { + String headerName = headerNameEnumerator.nextElement(); + + Enumeration headerValueEnumerator = req.getHeaders(headerName); + List headerValues = new ArrayList<>(); + + while (headerValueEnumerator.hasMoreElements()) + { + headerValues.add(headerValueEnumerator.nextElement()); + } + + if (headerValues.size() == 1) + { + map.put(headerName, headerValues.get(0)); + } + else + { + map.put(headerName, headerValues); + } + } + + return toResponse(map); + } + + + /** + * Checks whether the root certificate of the certificate chain that + * consists of the presented client certificate and intermediate + * certificates is a certificate issued by the authority of Open + * Banking Brasil. The result is returned in JSON format. + * + *

+ * Below is an example of API call, assuming certificates.pem + * includes a client certificate and intermediate certificates. + *

+ * + *
+     * $ curl -k --key private.pem --cert certificates.pem https://example/api/test/obb
+     * 
+ */ + @GET + @Path("obb") + public Response obb(@Context HttpServletRequest req) + { + Map map = new TreeMap<>(); + + try + { + OBBCertValidator.getInstance().validate(req); + map.put("result", "succeeded"); + } + catch (Exception e) + { + e.printStackTrace(); + + map.put("result", "failed"); + map.put("error_message", e.getMessage()); + + List stacktrace = Arrays.stream( + e.getStackTrace()).map(st -> st.toString()) + .collect(Collectors.toList()); + + map.put("stacktrace", stacktrace); + } + + return toResponse(map); + } + + + private static Response toResponse(Map map) + { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + String json = gson.toJson(map); + + return Response.ok(json).type(MediaType.APPLICATION_JSON).build(); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/TokenEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/TokenEndpoint.java index 6b95c16..8a4dc66 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/TokenEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/TokenEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 Authlete, Inc. + * Copyright (C) 2016-2025 Authlete, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,16 +17,22 @@ package com.authlete.jaxrs.server.api; -import javax.ws.rs.Consumes; -import javax.ws.rs.HeaderParam; -import javax.ws.rs.POST; -import javax.ws.rs.Path; -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.MultivaluedMap; -import javax.ws.rs.core.Response; -import com.authlete.common.api.AuthleteApiFactory; -import com.authlete.jaxrs.BaseTokenEndpoint; +import java.util.Map; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.util.Utils; +import com.authlete.jakarta.BaseTokenEndpoint; +import com.authlete.jakarta.TokenRequestHandler.Params; +import com.authlete.jakarta.spi.TokenRequestHandlerSpi; /** @@ -70,11 +76,93 @@ public class TokenEndpoint extends BaseTokenEndpoint @POST @Consumes(MediaType.APPLICATION_FORM_URLENCODED) public Response post( - @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, + @Context HttpServletRequest request, MultivaluedMap parameters) { + // Authlete API + AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi(); + + // Process the token request in a standard way. + Response response = processTokenRequest(authleteApi, request, parameters); + + // Do additional tasks as necessary. + doTasks(authleteApi, request, parameters, response); + + return response; + } + + + private Response processTokenRequest( + AuthleteApi authleteApi, HttpServletRequest request, + MultivaluedMap parameters) + { + // Parameters for Authlete's /api/auth/token API. + Params params = buildParams(request, parameters); + + // The implementation of the SPI. + TokenRequestHandlerSpi spi = new TokenRequestHandlerSpiImpl(authleteApi, request); + // Handle the token request. - return handle(AuthleteApiFactory.getDefaultApi(), - new TokenRequestHandlerSpiImpl(), parameters, authorization); + return handle(authleteApi, spi, params); + } + + + private Params buildParams( + HttpServletRequest request, MultivaluedMap parameters) + { + Params params = new Params(); + + // RFC 6749 + // The OAuth 2.0 Authorization Framework + params.setParameters(parameters) + .setAuthorization(request.getHeader(HttpHeaders.AUTHORIZATION)) + ; + + // MTLS + // RFC 8705 : OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens + params.setClientCertificatePath(extractClientCertificateChain(request)); + + // DPoP + // OAuth 2.0 Demonstration of Proof-of-Possession at the Application Layer (DPoP) + params.setDpop(request.getHeader("DPoP")) + .setHtm("POST") + //.setHtu(request.getRequestURL().toString()) + ; + + // We can reconstruct the URL of the token endpoint by calling + // request.getRequestURL().toString() and set it to params by the + // setHtu(String) method. However, the calculated URL may be invalid + // behind proxies. + // + // If "htu" is not set here, the "tokenEndpoint" property of "Service" + // (which can be configured by using Authlete's Service Owner Console) + // is referred to as the default value. Therefore, we don't call the + // setHtu(String) method here intentionally. Note that this means you + // have to set "tokenEndpoint" properly to support DPoP. + + // Even the call of the setHtm(String) method can be omitted, too. + // When "htm" is not set, "POST" is used as the default value. + + // OAuth 2.0 Attestation-Based Client Authentication + params.setClientAttestation( request.getHeader("OAuth-Client-Attestation")) + .setClientAttestationPop(request.getHeader("OAuth-Client-Attestation-PoP")) + ; + + return params; + } + + + @SuppressWarnings("unchecked") + private void doTasks( + AuthleteApi authleteApi, HttpServletRequest request, + MultivaluedMap requestParams, Response response) + { + // The entity conforms to the token response defined in RFC 6749. + Map responseParams = + Utils.fromJson((String)response.getEntity(), Map.class); + + // A task specific to Open Banking Brasil. + new OBBTokenTask().process( + authleteApi, request, requestParams, response, responseParams); } } diff --git a/src/main/java/com/authlete/jaxrs/server/api/TokenExchanger.java b/src/main/java/com/authlete/jaxrs/server/api/TokenExchanger.java new file mode 100644 index 0000000..dacadda --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/TokenExchanger.java @@ -0,0 +1,409 @@ +/* + * Copyright (C) 2022-2025 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api; + + +import java.net.URI; +import java.util.Map; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.CacheControl; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.ResponseBuilder; +import jakarta.ws.rs.core.Response.Status; +import com.authlete.common.api.AuthleteApi; +import com.authlete.common.dto.TokenCreateRequest; +import com.authlete.common.dto.TokenCreateResponse; +import com.authlete.common.dto.TokenInfo; +import com.authlete.common.dto.TokenResponse; +import com.authlete.common.types.GrantType; +import com.authlete.common.types.TokenType; +import com.nimbusds.jwt.EncryptedJWT; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTParser; + + +/** + * A sample implementation of processing a token exchange request (RFC 8693 OAuth 2.0 + * Token Exchange). + * + *

+ * RFC 8693 is very flexible. In other words, the specification does not define + * details that are necessary for secure token exchange. Therefore, + * implementations have to complement the specification with their own rules. + *

+ * + *

+ * There are various patterns for such deployment-specific rules. The + * implementation in this file is just an example and does not intend to be + * perfect for commercial use. + *

+ * + * @see RFC 8693 OAuth 2.0 Token Exchange + */ +class TokenExchanger +{ + private final AuthleteApi mAuthleteApi; + private final HttpServletRequest mRequest; + private final TokenResponse mTokenResponse; + private final Map mHeaders; + + + public TokenExchanger( + AuthleteApi authleteApi, HttpServletRequest request, + TokenResponse tokenResponse, Map headers) + { + mAuthleteApi = authleteApi; + mRequest = request; + mTokenResponse = tokenResponse; + mHeaders = headers; + } + + + public Response process() + { + try + { + return createResponse(); + } + catch (WebApplicationException cause) + { + return cause.getResponse(); + } + } + + + private Response createResponse() throws WebApplicationException + { + // This sample implementation creates an access token. + + // Client ID to assign. + long clientId = determineClientId(); + + // Scopes to assign. + String[] scopes = determineScopes(); + + // Resources to assign. + URI[] resources = determineResources(); + + // Subject to assign. + String subject = determineSubject(); + + // Create an access token. + TokenCreateResponse tcResponse = + createAccessToken(clientId, scopes, resources, subject); + + // Create a successful token response. + return createSuccessfulResponse(tcResponse); + } + + + private long determineClientId() + { + // The client ID of the client that made the token exchange request. + long clientId = mTokenResponse.getClientId(); + + // If 'Service.tokenExchangeByIdentifiableClientsOnly' is false, + // token exchange requests that contain no client identifier are not + // rejected. In that case, 'clientId' here becomes 0. + // + // However, this authorization server implementation does not allow + // unidentifiable clients to make token exchange requests regardless + // of whether 'Service.tokenExchangeByIdentifiableClientsOnly' is + // true or false. + if (clientId == 0) + { + throw invalidRequest( + "This authorization server does not allow unidentifiable " + + "clients to make token exchange requests."); + } + + // This simple implementation uses the client ID of the client + // that made the token exchange request. + return clientId; + } + + + private String[] determineScopes() + { + // This simple implementation uses the scopes specified + // by the token exchange request. + return mTokenResponse.getScopes(); + } + + + private URI[] determineResources() + { + // This simple implementation uses the resources specified + // by the token exchange request. + return mTokenResponse.getResources(); + } + + + private String determineSubject() + { + // The value of the "subject_token_type" request parameter. + TokenType tokenType = mTokenResponse.getSubjectTokenType(); + + // The subject to be assigned to a new access token. + String subject = null; + + switch (tokenType) + { + case ACCESS_TOKEN: + case REFRESH_TOKEN: + // Use the subject associated with the token as the subject of + // a new access token. + subject = determineSubjectByTokenInfo(); + break; + + case JWT: + case ID_TOKEN: + // Use the value of the "sub" claim of the JWT as the subject of + // a new access token. + subject = determineSubjectByJwt(); + break; + + case SAML1: + case SAML2: + default: + throw invalidRequest( + "This authorization server does not support the token type '" + + tokenType + "'."); + } + + // If 'subject' failed to be determined. + if (subject == null) + { + // This happens (1) when an access token that was created by + // the client credentials flow was given or (2) when a JWT + // that does not contain the "sub" claim was given. + throw invalidRequest( + "Could not determine the subject from the given subject token."); + } + + return subject; + } + + + private String determineSubjectByTokenInfo() + { + // When the token type is "urn:ietf:params:oauth:token-type:access_token" + // or "urn:ietf:params:oauth:token-type:refresh_token", Authlete returns + // more information about the token. + TokenInfo tokenInfo = mTokenResponse.getSubjectTokenInfo(); + + // The subject associated with the token. If the token was created by the + // client credentials flow, the value is null. + return tokenInfo.getSubject(); + } + + + private String determineSubjectByJwt() + { + // When the token type is "urn:ietf:params:oauth:token-type:jwt" or + // "urn:ietf:params:oauth:token-type:id_token", the format of the + // subject token is JWT. + // + // Basic validation on the JWT has already been done by Authlete's + // /auth/token API. See the JavaDoc of the TokenResponse class for + // details about the validation steps. + String subjectToken = mTokenResponse.getSubjectToken(); + + JWT jwt; + + try + { + // Parse the subject token as JWT. + jwt = JWTParser.parse(subjectToken); + } + catch (Exception cause) + { + // This won't happen because Authlete has already confirmed that + // the format of the subject token conforms to the JWT specification. + throw invalidRequest("The subject token failed to be parsed as JWT."); + } + + // If the JWT is encrypted. + if (jwt instanceof EncryptedJWT) + { + throw invalidRequest( + "This authorization server does not accept " + + "an encrypted JWT as a subject token."); + } + + try + { + // Get the value of the "sub" claim from the payload of the JWT. + // + // An ID Token must always have the "sub" claim (OIDC Core) while + // a JWT does not necessarily have the "sub" claim (RFC 7519). + return jwt.getJWTClaimsSet().getSubject(); + } + catch (Exception cause) + { + throw invalidRequest( + "The value of the 'sub' claim failed to be extracted " + + "from the payload of the subject token."); + } + } + + + private TokenCreateResponse createAccessToken( + long clientId, String[] scopes, URI[] resources, String subject) + { + // A request to Authlete's /auth/token/create API. + TokenCreateRequest request = new TokenCreateRequest() + .setGrantType(GrantType.TOKEN_EXCHANGE) + .setClientId(clientId) + .setScopes(scopes) + .setResources(resources) + .setSubject(subject) + ; + + try + { + // Call Authlete's /auth/token/create API to create an access token. + return mAuthleteApi.tokenCreate(request); + } + catch (Exception cause) + { + // API call to /auth/token/create failed. + cause.printStackTrace(); + throw serverError("API call to /auth/token/create failed."); + } + } + + + private Response createSuccessfulResponse(TokenCreateResponse tcResponse) + { + // The content of a successful token response that conforms to + // Section 2.2.1. Successful Response of RFC 8693. + String content = String.format( + "{\n" + + " \"access_token\":\"%s\",\n" + + " \"issued_token_type\":\"urn:ietf:params:oauth:token-type:access_token\",\n" + + " \"token_type\":\"Bearer\",\n" + + " \"expires_in\":%d,\n" + + " \"scope\":\"%s\",\n" + + " \"refresh_token\":\"%s\"\n" + + "}\n", + extractAccessToken(tcResponse), + tcResponse.getExpiresIn(), + buildScope(tcResponse), + tcResponse.getRefreshToken() + ); + + return toJsonResponse(Status.OK, content); + } + + + private String extractAccessToken(TokenCreateResponse tcResponse) + { + // If a JWT access token has been issued, it takes precedence over + // a random-string access token. + + // An access token in the JWT format. This response parameter holds + // a non-null value when Service.accessTokenSignAlg is not null. + String at = tcResponse.getJwtAccessToken(); + + // If an access token in the JWT format has not been issued. + if (at == null) + { + // An access token whose format is just a random string. + at = tcResponse.getAccessToken(); + } + + // The newly issued access token. + return at; + } + + + private String buildScope(TokenCreateResponse tcResponse) + { + String[] scopes = tcResponse.getScopes(); + + if (scopes == null) + { + return ""; + } + + return String.join(" ", scopes); + } + + + private Response toJsonResponse(Status status, String content) + { + CacheControl cacheControl = new CacheControl(); + cacheControl.setNoCache(true); + cacheControl.setNoStore(true); + + ResponseBuilder builder = Response.status(status) + .type(MediaType.APPLICATION_JSON_TYPE) + .cacheControl(cacheControl) + .entity(content) + ; + + addResponseHeaders(builder, mHeaders); + + return builder.build(); + } + + + private static void addResponseHeaders(ResponseBuilder builder, Map headers) + { + if (headers == null) + { + return; + } + + for (Map.Entry header : headers.entrySet()) + { + builder.header(header.getKey(), header.getValue()); + } + } + + + private WebApplicationException toException(Status status, String error, String description) + { + String content = String.format( + "{\n" + + " \"error\":\"%s\",\n" + + " \"error_description\":\"%s\"\n" + + "}\n", + error, description); + + Response response = toJsonResponse(status, content); + + return new WebApplicationException(response); + } + + + private WebApplicationException invalidRequest(String message) + { + return toException(Status.BAD_REQUEST, "invalid_request", message); + } + + + private WebApplicationException serverError(String message) + { + return toException(Status.INTERNAL_SERVER_ERROR, "server_error", message); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/TokenRequestHandlerSpiImpl.java b/src/main/java/com/authlete/jaxrs/server/api/TokenRequestHandlerSpiImpl.java index f11feca..cee455c 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/TokenRequestHandlerSpiImpl.java +++ b/src/main/java/com/authlete/jaxrs/server/api/TokenRequestHandlerSpiImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 Authlete, Inc. + * Copyright (C) 2016-2022 Authlete, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,22 +17,38 @@ package com.authlete.jaxrs.server.api; +import java.util.Map; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; import com.authlete.common.dto.Property; +import com.authlete.common.dto.TokenResponse; import com.authlete.common.types.User; import com.authlete.jaxrs.server.db.UserDao; -import com.authlete.jaxrs.spi.TokenRequestHandlerSpiAdapter; +import com.authlete.jakarta.spi.TokenRequestHandlerSpiAdapter; /** - * Implementation of {@link com.authlete.jaxrs.spi.TokenRequestHandlerSpi + * Implementation of {@link com.authlete.jakarta.spi.TokenRequestHandlerSpi * TokenRequestHandlerSpi} interface which needs to be given to the - * constructor of {@link com.authlete.jaxrs.TokenRequestHandler + * constructor of {@link com.authlete.jakarta.TokenRequestHandler * TokenRequestHandler}. * * @author Takahiko Kawasaki */ class TokenRequestHandlerSpiImpl extends TokenRequestHandlerSpiAdapter { + private final AuthleteApi mAuthleteApi; + private final HttpServletRequest mRequest; + + + public TokenRequestHandlerSpiImpl(AuthleteApi authleteApi, HttpServletRequest request) + { + mAuthleteApi = authleteApi; + mRequest = request; + } + + @Override public String authenticateUser(String username, String password) { @@ -61,4 +77,32 @@ public Property[] getProperties() // access token that will be issued as a result of the token request. return null; } + + + @Override + public Response tokenExchange( + TokenResponse tokenResponse, Map headers) + { + // Handle the token exchange request (RFC 8693). + return new TokenExchanger(mAuthleteApi, mRequest, tokenResponse, headers).process(); + } + + + @Override + public Response jwtBearer( + TokenResponse tokenResponse, Map headers) + { + // Handle the token request that uses the grant type + // "urn:ietf:params:oauth:grant-type:jwt-bearer" (RFC 7523). + return new JwtAuthzGrantProcessor(mAuthleteApi, mRequest, tokenResponse, headers).process(); + } + + + @Override + public Response nativeSso(TokenResponse tokenResponse, Map headers) + { + // Handle the token request that complies with the + // "OpenID Connect Native SSO for Mobile Apps 1.0" specification. + return new NativeSsoProcessor(mAuthleteApi, mRequest, tokenResponse, headers).process(); + } } diff --git a/src/main/java/com/authlete/jaxrs/server/api/UserInfoEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/UserInfoEndpoint.java new file mode 100644 index 0000000..ae42050 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/UserInfoEndpoint.java @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2016-2024 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api; + + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.jakarta.BaseUserInfoEndpoint; +import com.authlete.jakarta.UserInfoRequestHandler.Params; +import com.authlete.jakarta.util.JaxRsUtils; + + +/** + * An implementation of userinfo endpoint (OpenID Connect Core 1.0, 5.3. UserInfo Endpoint). + * + * @see OpenID Connect Core 10, 5.3. UserInfo Endpoint + */ +@Path("/api/userinfo") +public class UserInfoEndpoint extends BaseUserInfoEndpoint +{ + /** + * The userinfo endpoint for {@code GET} method. + * + * @see OpenID Connect Core 1.0, 5.3.1. UserInfo Request + */ + @GET + public Response get( + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, + @HeaderParam("DPoP") String dpop, + @Context HttpServletRequest request) + { + // Select either the access token embedded in the Authorization header + // or the access token in the query component. + String accessToken = extractAccessToken(authorization, null); + + // Handle the userinfo request. + return handle(request, /*body*/null, accessToken, dpop); + } + + + /** + * The userinfo endpoint for {@code POST} method. + * + * @see OpenID Connect Core 1.0, 5.3.1. UserInfo Request + */ + @POST + public Response post( + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, + @HeaderParam("DPoP") String dpop, + @Context HttpServletRequest request, String body) + { + // '@Consumes(MediaType.APPLICATION_FORM_URLENCODED)' and + // '@FormParam("access_token") are not used here because clients may send + // a request without 'Content-Type' even if the HTTP method is 'POST'. + // + // See Issue 1137 in openid/connect for details. + // + // Is content-type application/x-www-form-urlencoded required + // when calling user info endpoint with empty body? + // + // https://bitbucket.org/openid/connect/issues/1137/is-content-type-application-x-www-form + // + + // Extract "access_token" from the request body if the Content-Type of + // the request is 'application/x-www-form-urlencoded'. + String accessToken = extractFormParameter(request, body, "access_token"); + + // Select either the access token embedded in the Authorization header + // or the access token in the request body. + accessToken = extractAccessToken(authorization, accessToken); + + // Handle the userinfo request. + return handle(request, body, accessToken, dpop); + } + + + private static String extractFormParameter(HttpServletRequest request, String body, String key) + { + // If the request does not include 'Content-Type' or + // its value is not 'application/x-www-form-urlencoded'. + if (!MediaType.APPLICATION_FORM_URLENCODED.equals(request.getContentType())) + { + return null; + } + + // Get the value of "access_token" if available. + return JaxRsUtils.parseFormUrlencoded(body).getFirst("access_token"); + } + + + /** + * Handle the userinfo request. + */ + private Response handle( + HttpServletRequest request, String body, + String accessToken, String dpop) + { + Params params = buildParams(request, body, accessToken, dpop); + + return handle(ResilientAuthleteApiFactory.getDefaultApi(), + new UserInfoRequestHandlerSpiImpl(), params); + } + + + private Params buildParams( + HttpServletRequest request, String body, + String accessToken, String dpop) + { + Params params = new Params(); + + // Access Token + params.setAccessToken(accessToken); + + // Client Certificate + params.setClientCertificate(extractClientCertificate(request)); + + // DPoP + params.setDpop(dpop) + .setHtm(request.getMethod()) + //.setHtu(request.getRequestURL().toString()) + ; + + // We can reconstruct the URL of the userinfo endpoint by calling + // request.getRequestURL().toString() and set it to params by the + // setHtu(String) method. However, the calculated URL may be invalid + // behind proxies. + // + // If "htu" is not set here, the "userInfoEndpoint" property of "Service" + // (which can be configured by using Authlete's Service Owner Console) + // is referred to as the default value. Therefore, we don't call the + // setHtu(String) method here intentionally. Note that this means you + // have to set "userInfoEndpoint" properly to support DPoP. + + // HTTP Message Signatures + params.setHeaders(extractHeadersAsPairs(request)) + .setRequestBodyContained(body != null) + //.setTargetUri(targetUri) + ; + + // We can reconstruct the target URI using request.getRequestURL() and + // request.getQueryString() and set it to params by the setTargetUri(URI) + // method. However, behind proxies, the constructed URI may be different + // from the original one. + // + // If the "targetUri" parameter is omitted, the value of the "htu" + // parameter is used. The "htu" parameter represents the URL of the + // userinfo endpoint, which usually serves as the target URI of the + // userinfo request. The only exception is when the access token is + // specified as a query parameter, as defined in RFC 6750 Section 2.3. + // However, RFC 6750 states that this method "SHOULD NOT be used" + // unless other methods are not viable. + // + // If neither the "targetUri" parameter nor the "htu" parameter is + // specified, the "userInfoEndpoint" property of the service is used + // as a fallback. + + return params; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/UserInfoRequestHandlerSpiImpl.java b/src/main/java/com/authlete/jaxrs/server/api/UserInfoRequestHandlerSpiImpl.java new file mode 100644 index 0000000..61c566b --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/UserInfoRequestHandlerSpiImpl.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2016-2022 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api; + + +import java.util.List; +import java.util.Map; +import com.authlete.common.assurance.VerifiedClaims; +import com.authlete.common.assurance.constraint.VerifiedClaimsConstraint; +import com.authlete.common.types.User; +import com.authlete.jaxrs.server.db.DatasetDao; +import com.authlete.jaxrs.server.db.UserDao; +import com.authlete.jaxrs.server.db.VerifiedClaimsDao; +import com.authlete.jakarta.spi.UserInfoRequestHandlerSpiAdapter; + + +/** + * Implementation of {@link com.authlete.jakarta.spi.UserInfoRequestHandlerSpi + * UserInfoRequestHandlerSpi} interface which needs to be given to the + * constructor of {@link com.authlete.jakarta.UserInfoRequestHandler + * UserInfoRequestHandler}. + */ +public class UserInfoRequestHandlerSpiImpl extends UserInfoRequestHandlerSpiAdapter +{ + private User mUser; + + + @Override + public void prepareUserClaims(String subject, String[] claimNames) + { + // Look up a user who has the subject. + mUser = UserDao.getBySubject(subject); + } + + + @Override + public Object getUserClaim(String claimName, String languageTag) + { + // If looking up a user has failed in prepareUserClaims(). + if (mUser == null) + { + // No claim is available. + return null; + } + + // Get the value of the claim. + return mUser.getClaim(claimName, languageTag); + } + + + @Override + public List getVerifiedClaims(String subject, VerifiedClaimsConstraint constraint) + { + // This method, getVerifiedClaims(String, VerifiedClaimsConstraint), + // is no longer called since authlete-java-jaxrs 2.42 unless the + // 'oldIdaFormatUsed' flag of UserInfoRequestHandler.Params is on. + // Instead, getVerifiedClaims(String, Object) is called. + + // The third Implementer's Draft of OpenID Connect for Identity + // Assurance 1.0 (which was published in September 2021) has introduced + // many breaking changes. In addition, it is scheduled that the next + // draft will introduce further breaking changes. The specification is + // still unstable. It turned out to be inadequate to define Java classes + // that correspond to data structures of elements under "verified_claims". + // In that sense, the classes under com.authlete.common.assurance package + // of the authlete-java-common library are no longer useful. + // + // Authlete 2.3 has implemented a different approach for ID3 and future + // drafts of OIDC4IDA that is less susceptible to specification changes. + + return VerifiedClaimsDao.get(subject, constraint); + } + + + @Override + public Object getVerifiedClaims(String subject, Object verifiedClaimsRequest) + { + // The list of available datasets of the subject. + List> datasets = DatasetDao.get(subject); + + // Build the content of "verified_claims" which meets conditions + // of the request from the available datasets. + return new VerifiedClaimsBuilder(verifiedClaimsRequest, datasets).build(); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/VerifiedClaimsBuilder.java b/src/main/java/com/authlete/jaxrs/server/api/VerifiedClaimsBuilder.java new file mode 100644 index 0000000..56c036b --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/VerifiedClaimsBuilder.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2022 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api; + + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import org.slf4j.LoggerFactory; +import com.authlete.common.ida.DatasetExtractor; + + +/** + * Utility to build a new dataset that satisfies conditions of a + * {@code "verified_claims"} request from one of the given datasets. + * + *

+ * This class is used in the {@code getVerifiedClaims(String, Object)} + * method of {@code AuthorizationDecisionHandlerSpi} and + * {@code UserInfoRequestHandlerSpi} implementations. + *

+ * + *

+ * A point to note is that this class uses {@link DatasetExtractor} + * which is a generic component included in the + * authlete-java-common library. The DatasetExtractor class + * implements the filtering rules and the data minimization policy + * that are written in OpenID Connect for Identity Assurance 1.0. + *

+ * + * @see OpenID Connect for Identity Assurance 1.0 + */ +class VerifiedClaimsBuilder +{ + // The content of "verified_claims" request. List or Map. + private final Object mRequest; + + // Available datasets of a particular subject. + private List> mDatasets; + + + public VerifiedClaimsBuilder(Object request, List> datasets) + { + mRequest = request; + mDatasets = datasets; + } + + + @SuppressWarnings("unchecked") + public Object build() + { + // If no dataset is available. + if (mDatasets == null || mDatasets.size() == 0) + { + // The content of "verified_claims" cannot be built. + return null; + } + + // The request is a List instance or a Map instance. + + // LIST: "verified_claims": [ { ... }, ... ] + if (mRequest instanceof List) + { + return buildList((List>)mRequest, mDatasets); + } + + // MAP: "verified_claims": { ... } + if (mRequest instanceof Map) + { + return buildMap((Map)mRequest, mDatasets); + } + + // The flow reaches here when the "claims" request parameter of the + // authorization request does not include "verified_claims" or its + // value is neither a JSON array nor a JSON object. The latter case + // is a specification violation. + return null; + } + + + private List> buildList( + List> requests, List> datasets) + { + // Utility to build a new dataset that meets conditions of a + // "verified_claims" request from one of available datasets. + DatasetExtractor extractor = createDatasetExtractor(); + + // Build a new dataset for each element in the 'requests' array. + List> results = requests.stream() + .map(request -> extractor.extract(request, datasets)) + .filter(Objects::nonNull) + .collect(Collectors.toList()) + ; + + // If none of the available datasets could satisfy any of the + // elements in the 'requests' array. + if (results.size() == 0) + { + // No content for "verified_claims" response. + return null; + } + + // Content for "verified_claims" response. + return results; + } + + + private Map buildMap( + Map request, List> datasets) + { + // Build a new dataset that meets conditions of the request + // from one of the available datasets. + return createDatasetExtractor().extract(request, datasets); + } + + + private DatasetExtractor createDatasetExtractor() + { + // Create a dataset extractor. + return new DatasetExtractor() + .setLogger(LoggerFactory.getLogger(getClass())) + ; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/attestation/AttestationChallengeEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/attestation/AttestationChallengeEndpoint.java new file mode 100644 index 0000000..0dc16e2 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/attestation/AttestationChallengeEndpoint.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.attestation; + + +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.AttestationChallengeRequest; +import com.authlete.jakarta.BaseAttestationChallengeEndpoint; + + +/** + * An implementation of the challenge endpoint defined in the OAuth 2.0 Attestation-Based Client Authentication. + * + * @see + * OAuth 2.0 Attestation-Based Client Authentication + */ +@Path("/api/challenge") +public class AttestationChallengeEndpoint extends BaseAttestationChallengeEndpoint +{ + /** + * The challenge endpoint. + * + *

+ * From OAuth 2.0 Attestation-Based Client Authentication: + *

+ * + *
+ *

+ * A request for a Challenge is made by sending an HTTP POST request to the URL + * provided in the {@code challenge_endpoint} of the Authorization Serve metadata. + *

+ *
+ * + * @return + * A response from the challenge endpoint. + */ + @POST + public Response post() + { + // Authlete API interface + AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); + + // Request to the Authlete's /api/{service-id}/attestation/challenge API + AttestationChallengeRequest request = + new AttestationChallengeRequest() + .setPretty(true); + + // Process the request. + return handle(api, request); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/AsyncAuthenticationDeviceProcessor.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/AsyncAuthenticationDeviceProcessor.java new file mode 100644 index 0000000..a7eeb34 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/AsyncAuthenticationDeviceProcessor.java @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.backchannel; + + +import com.authlete.common.dto.Scope; +import com.authlete.common.types.User; +import com.authlete.jaxrs.server.ad.AuthenticationDevice; +import com.authlete.jaxrs.server.ad.dto.AsyncAuthenticationResponse; + + +/** + * A processor that communicates with + * Authlete CIBA authentication device simulator for end-user authentication + * and authorization in asynchronous mode. + * + *

+ * Note that this processor does not receive the result of end-user authentication + * and authorization in {@link #process()} method. Instead, the result is obtained + * in the {@link BackchannelAuthenticationCallbackEndpoint} when the endpoint is + * called back by the authentication device simulator. + *

+ * + * @see Authlete CIBA authentication device + * simulator + * + * @see Authlete + * CIBA authentication device simulator API + * + * @see BackchannelAuthenticationCallbackEndpoint + * + * @author Hideki Ikeda + */ +public class AsyncAuthenticationDeviceProcessor extends BaseAuthenticationDeviceProcessor +{ + /** + * Construct a processor that communicates with the authentication device simulator + * for end-user authentication and authorization in asynchronous mode. + * + * @param ticket + * A ticket that was issued by Authlete's {@code /api/backchannel/authentication} + * API. + * + * @param user + * An end-user to be authenticated and asked to authorize the client + * application. + * + * @param clientName + * The name of the client application. + * + * @param acrs + * The requested ACRs. + * + * @param scopes + * The requested scopes. + * + * @param claimNames + * The names of the requested claims. + * + * @param bindingMessage + * The binding message to be shown to the end-user on the authentication + * device. + * + * @param authReqId + * The authentication request ID ({@code auth_req_id}) issued to the + * client. + * + * @param expiresIn + * The duration of the issued authentication request ID ({@code auth_req_id}) + * in seconds. + * + * @return + * A processor that communicates with the authentication device simulator + * for end-user authentication and authorization in asynchronous mode. + */ + public AsyncAuthenticationDeviceProcessor(String ticket, User user, String clientName, + String[] acrs, Scope[] scopes, String[] claimNames, String bindingMessage, + String authReqId, int expiresIn) + { + super(ticket, user, clientName, acrs, scopes, claimNames, bindingMessage, + authReqId, expiresIn); + } + + + @Override + public void process() + { + // The response to be returned from the authentication device. + AsyncAuthenticationResponse response; + + try + { + // Communicate with the authentication device for end-user authentication + // and authorization. + response = AuthenticationDevice.async(mUser.getSubject(), buildMessage(), + computeAuthTimeout(), mAuthReqId); + } + catch (Throwable t) + { + // An unexpected error occurred when communicating with the authentication + // device. + completeWithTransactionFailed( + "Failed to communicate with the authentication device asynchronously."); + return; + } + + // OK. The communication between this authorization server and the authentication + // device has been successfully done. + + // The ID of the request sent to the authentication device above. + String requestId = response.getRequestId(); + + // Check the request ID. + if (requestId == null || requestId.length() == 0) + { + // The request ID was invalid. This should never happen. + completeWithTransactionFailed( + "The request ID returned from the authentication device is invalid."); + return; + } + + // OK. The request ID returned from the authentication device is valid. + // In this case, the process does not complete here. Instead, the result + // of end-user authentication and authorization will be returned from the + // authentication device to the BackchannelAuthenticationCallbackEndpoint + // of this authorization server later and then the authentication/authorization + // process will complete there. Then, we need to store some information + // required to complete the process (e.g. ticket, claim names, etc...) at + // the BackchannelAuthenticationCallbackEndpoint. + AuthInfoHolder.put(requestId, new AuthInfo(mTicket, mUser, mClaimNames, mAcrs)); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/AuthInfo.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/AuthInfo.java new file mode 100644 index 0000000..bd806f5 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/AuthInfo.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.backchannel; + + +import com.authlete.common.types.User; + + +/** + * Information required to complete processes that are executed in {@link AsyncAuthenticationDeviceProcessor} + * + * @see AsyncAuthenticationDeviceProcessor + * + * @author Hideki Ikeda + */ +public class AuthInfo +{ + String mTicket; + User mUser; + String[] mClaimNames; + String[] mAcrs; + + + /** + * Construct an information to complete processes that are executed in {@link + * AsyncAuthenticationDeviceProcessor} + * + * @param ticket + * A ticket that was issued by Authlete's {@code /api/backchannel/authentication} + * API. + * + * @param user + * The end-user who was requested to authorize the client application. + * + * @param claimNames + * The names of the requested claims. + * + * @param acrs + * The requested ACRs. + */ + public AuthInfo(String ticket, User user, String[] claimNames, String[] acrs) + { + mTicket = ticket; + mUser = user; + mClaimNames = claimNames; + mAcrs = acrs; + } + + + /** + * Get the ticket that was issued by Authlete's {@code /api/backchannel/authentication} + * API. + * + * @return + * The ticket that was issued by Authlete's {@code /api/backchannel/authentication} + * API. + */ + public String getTicket() + { + return mTicket; + } + + + /** + * Get The end-user who was requested to authorize the client application. + * + * @return + * The end-user who was requested to authorize the client application. + */ + public User getUser() + { + return mUser; + } + + + /** + * Get the names of the requested claims. + * + * @return + * The names of the requested claims. + */ + public String[] getClaimNames() + { + return mClaimNames; + } + + + /** + * Get the requested ACRs. + * + * @return + * The requested ACRs. + */ + public String[] getAcrs() + { + return mAcrs; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/AuthInfoHolder.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/AuthInfoHolder.java new file mode 100644 index 0000000..a3753b0 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/AuthInfoHolder.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.backchannel; + + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + + +/** + * The holder storing {@link AuthInfo information} required to complete processes + * that are executed in {@link AsyncAuthenticationDeviceProcessor}. The information + * is expected to be stored in the {@link AsyncAuthenticationDeviceProcessor#process() + * process()} method of {@link AsyncAuthenticationDeviceProcessor} and retrieved + * in {@link BackchannelAuthenticationCallbackEndpoint} to complete the processes. + * + *

+ * Note that this implementation is a dummy implementation and not suitable for + * commercial use. + *

+ * + * @see AuthInfo + * + * @see AsyncAuthenticationDeviceProcessor + * + * @see BackchannelAuthenticationCallbackEndpoint + * + * @author Hideki Ikeda + */ +public class AuthInfoHolder +{ + private static final Map sHolder = new ConcurrentHashMap(); + + + /** + * Get the information by the request ID. + * + * @param requestId + * The request ID. + * + * @return + * The information associated with the request ID. + */ + public static AuthInfo get(String requestId) + { + return sHolder.get(requestId); + } + + + /** + * Associate information with a request ID. + * + * @param requestId + * A request ID with which the specified information is to be associated. + * + * @param info + * Information to be associated with the specified request ID + * + * @return + * The previous value associated with the specified request ID, or + * {@code null} if there was no mapping for the request ID. + */ + public static AuthInfo put(String requestId, AuthInfo info) + { + return sHolder.put(requestId, info); + } + + + /** + * Remove information for a request ID. + * + * @param requestId + * A request ID whose information is to be removed. + * + * @return + * The previous value associated with the request ID, or {@code null} + * if there was no mapping for the request ID. + */ + public static AuthInfo remove(String requestId) + { + return sHolder.remove(requestId); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/AuthenticationDeviceProcessor.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/AuthenticationDeviceProcessor.java new file mode 100644 index 0000000..d76818d --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/AuthenticationDeviceProcessor.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.backchannel; + + +/** + * An interface for processors that communicate with + * Authlete CIBA authentication device simulator for end-user authentication + * and authorization. + * + * @see Authlete CIBA authentication device + * simulator + * + * @see Authlete + * CIBA authentication device simulator API + * + * @author Hideki Ikeda + */ +public interface AuthenticationDeviceProcessor +{ + /** + * Process communication between the authorization server and the authentication + * device for end-user authentication and authorization. + */ + void process(); +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/AuthenticationDeviceProcessorFactory.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/AuthenticationDeviceProcessorFactory.java new file mode 100644 index 0000000..f5f6386 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/AuthenticationDeviceProcessorFactory.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.backchannel; + + +import com.authlete.common.dto.Scope; +import com.authlete.common.types.User; +import com.authlete.jaxrs.server.ad.type.Mode; + + +/** + * The factory class that creates a processor that communicates with + * Authlete CIBA authentication device simulator + * for end-user authentication and authorization. + * + * @see Authlete CIBA authentication device + * simulator + * + * @see Authlete + * CIBA authentication device simulator API + * + * @author Hideki Ikeda + */ +public class AuthenticationDeviceProcessorFactory +{ + /** + * Create a processor that communicates with the authentication device simulator + * for end-user authentication and authorization. + * + * @param mode + * The mode communication with the authentication device simulator. + * + * @param ticket + * A ticket that was issued by Authlete's {@code /api/backchannel/authentication} + * API. + * + * @param user + * An end-user to be authenticated and asked to authorize the client + * application. + * + * @param clientName + * The name of the client application. + * + * @param acrs + * The requested ACRs. + * + * @param scopes + * The requested scopes. + * + * @param claimNames + * The names of the requested claims. + * + * @param bindingMessage + * The binding message to be shown to the end-user on the authentication + * device. + * + * @param authReqId + * The authentication request ID ({@code auth_req_id}) issued to the + * client. + * + * @param expiresIn + * The duration of the issued authentication request ID ({@code auth_req_id}) + * in seconds. + * + * @return + * A processor that communicates with the authentication device simulator + * for end-user authentication and authorization. + */ + public static AuthenticationDeviceProcessor create(Mode mode, String ticket, + User user, String clientName, String[] acrs, Scope[] scopes, String[] claimNames, + String bindingMessage, String authReqId, int expiresIn) + { + if (mode == null) + { + throw new IllegalArgumentException("Mode must be specified."); + } + + switch (mode) + { + case SYNC: + // Create a processor that communicates with the authentication + // device in synchronous mode. + return new SyncAuthenticationDeviceProcessor(ticket, user, clientName, + acrs, scopes, claimNames, bindingMessage, authReqId, expiresIn); + + case ASYNC: + // Create a processor that communicates with the authentication + // device in asynchronous mode. + return new AsyncAuthenticationDeviceProcessor(ticket, user, clientName, + acrs, scopes, claimNames, bindingMessage, authReqId, expiresIn); + + case POLL: + // Create a processor that communicates with the authentication + // device in poll mode. + return new PollAuthenticationDeviceProcessor(ticket, user, clientName, + acrs, scopes, claimNames, bindingMessage, authReqId, expiresIn); + + default: + // Undefined authentication device mode. This never happens. + throw new RuntimeException("Undefined authentication device mode."); + } + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCallbackEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCallbackEndpoint.java new file mode 100644 index 0000000..c39cfbb --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCallbackEndpoint.java @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.backchannel; + + +import static com.authlete.jaxrs.server.util.ExceptionUtil.badRequestException; +import static com.authlete.jaxrs.server.util.ExceptionUtil.internalServerErrorException; +import static com.authlete.jaxrs.server.util.ResponseUtil.noContent; +import java.util.Date; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.BackchannelAuthenticationCompleteRequest.Result; +import com.authlete.common.types.User; +import com.authlete.jakarta.BackchannelAuthenticationCompleteRequestHandler; +import com.authlete.jaxrs.server.ad.dto.AsyncAuthenticationCallbackRequest; + + +/** + * The endpoint called back from + * Authlete CIBA authentication device simulator when the authentication device + * simulator is used in asynchronous mode. + * + *

+ * Note that it is assumed that the authorization server has made a request to the + * authentication device simulator for end-user authentication and authorization + * in {@link AsyncAuthenticationDeviceProcessor} before this endpoint is called + * back from the authentication device simulator. The result of the end-user + * authentication and authorization is expected to be contained in the request to + * this endpoint. + *

+ * + * @see Authlete CIBA authentication device + * simulator + * + * @see Authlete + * CIBA authentication device simulator API + * + * @see AsyncAuthenticationDeviceProcessor + * + * @author Hideki Ikeda + */ +@Path("/api/backchannel/authentication/callback") +public class BackchannelAuthenticationCallbackEndpoint +{ + /** + * The callback endpoint called back from Authlete CIBA authentication device + * simulator when it is used in in asynchronous mode. + */ + @POST + @Consumes(MediaType.APPLICATION_JSON) + public Response post(AsyncAuthenticationCallbackRequest request) + { + try + { + return doProcess(request); + } + catch (WebApplicationException e) + { + throw e; + } + catch (Throwable t) + { + throw internalServerErrorException("unexpected error: " + t.getMessage()); + } + } + + + private Response doProcess(AsyncAuthenticationCallbackRequest request) + { + // Get the result of end-user authentication and authorization. + Result result = getResult(request); + + // Get the ID of the request that this authorization server made to the + // authentication device in AsyncAuthenticationDeviceProcessor. + String requestId = getRequestId(request); + + // Retrieve information that was stored in AsyncAuthenticationDeviceProcessor. + AuthInfo authInfo = getAuthInfo(requestId); + + // Get some variables from the stored information. + String ticket = authInfo.getTicket(); + User user = authInfo.getUser(); + String[] claimNames = authInfo.getClaimNames(); + String[] acrs = authInfo.getAcrs(); + Date authTime = (result == Result.AUTHORIZED) ? new Date() : null; + String errorDescription = determineErrorDescription(request); + + // Complete the authentication and authorization process. + new BackchannelAuthenticationCompleteRequestHandler( + ResilientAuthleteApiFactory.getDefaultApi(), + new BackchannelAuthenticationCompleteHandlerSpiImpl( + result, user, authTime, acrs, errorDescription, null) + ) + .handle(ticket, claimNames); + + // Delete the stored information. + removeAuthInfo(requestId); + + // 204 No Content. + return noContent(); + } + + + private Result getResult(AsyncAuthenticationCallbackRequest request) + { + com.authlete.jaxrs.server.ad.type.Result result = request.getResult(); + + if (result == null) + { + // Invalid result. + throw badRequestException("The result must not be empty."); + } + + switch (result) + { + case allow: + // The user authorized the client. + return Result.AUTHORIZED; + + case deny: + // The user denied the client. + return Result.ACCESS_DENIED; + + case timeout: + // Timeout occurred while the authentication device was authenticating + // the user. + return Result.TRANSACTION_FAILED; + + default: + // An unknown result returned from the authentication device. + // This should never happen. + throw badRequestException("Unknown result."); + } + } + + + private String getRequestId(AsyncAuthenticationCallbackRequest request) + { + // The ID of the request that this authorization server made to the + // authentication device. + String requestId = request.getRequestId(); + + if (requestId == null || requestId.length() == 0) + { + // The request ID is empty. + throw badRequestException("The request ID must not be empty."); + } + + return requestId; + } + + + private AuthInfo getAuthInfo(String requestId) + { + // Retrieve the information that was stored when this authorization server + // made the request to the authentication device in the asynchronous mode + // at '/api/backchannel/authentication' API. + AuthInfo info = AuthInfoHolder.get(requestId); + + if (info == null) + { + // The information for the request ID doesn't exist. + throw badRequestException("The request ID is invalid."); + } + + return info; + } + + + private String determineErrorDescription(AsyncAuthenticationCallbackRequest request) + { + com.authlete.jaxrs.server.ad.type.Result result = request.getResult(); + + if (result == null) + { + return null; + } + + switch (result) + { + case allow: + return null; + + case deny: + return "The backchannel authentication request was denied by the end-user."; + + case timeout: + return "Timeout occurred on the authentication device."; + + default: + return "An unrecognizable result was returned from the authentication device."; + } + } + + + private void removeAuthInfo(String requestId) + { + // Remove the information for the request ID from the holder. + AuthInfoHolder.remove(requestId); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCompleteHandlerSpiImpl.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCompleteHandlerSpiImpl.java new file mode 100644 index 0000000..11f825b --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCompleteHandlerSpiImpl.java @@ -0,0 +1,297 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.backchannel; + + +import static com.authlete.jaxrs.server.util.ExceptionUtil.internalServerErrorException; +import java.net.URI; +import java.util.Date; +import javax.net.ssl.SSLContext; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import org.glassfish.jersey.client.ClientProperties; +import com.authlete.common.dto.BackchannelAuthenticationCompleteRequest.Result; +import com.authlete.common.dto.BackchannelAuthenticationCompleteResponse; +import com.authlete.common.types.User; +import com.authlete.jakarta.spi.BackchannelAuthenticationCompleteRequestHandlerSpiAdapter; + + +/** + * Implementation of {@link com.authlete.jakarta.spi.BackchannelAuthenticationCompleteRequestHandlerSpi + * BackchannelAuthenticationCompleteRequestHandlerSpi} interface which needs to + * be given to the constructor of {@link com.authlete.jakarta.BackchannelAuthenticationCompleteRequestHandler + * BackchannelAuthenticationCompleteRequestHandler}. + * + * @author Hideki Ikeda + */ +public class BackchannelAuthenticationCompleteHandlerSpiImpl extends BackchannelAuthenticationCompleteRequestHandlerSpiAdapter +{ + /** + * The result of end-user authentication and authorization. + */ + private final Result mResult; + + + /** + * The authenticated user. + */ + private final User mUser; + + + /** + * The time when the user was authenticated in seconds since Unix epoch. + */ + private long mUserAuthenticatedAt; + + + /** + * Requested ACRs. + */ + private String[] mAcrs; + + + /** + * The description of the error. + */ + private String mErrorDescription; + + + /** + * The URI of a document which describes the error in detail. + */ + private URI mErrorUri; + + + public BackchannelAuthenticationCompleteHandlerSpiImpl( + Result result, User user, Date userAuthenticatedAt, String[] acrs, + String errorDescription, URI errorUri) + { + // The result of end-user authentication and authorization. + mResult = result; + + // The end-user. + mUser = user; + + if (result != Result.AUTHORIZED) + { + // The description of the error. + mErrorDescription = errorDescription; + + // The URI of a document which describes the error in detail. + mErrorUri = errorUri; + + // The end-user has not authorized the client. + return; + } + + // The time at which end-user has been authenticated. + mUserAuthenticatedAt = (userAuthenticatedAt == null) ? 0 : userAuthenticatedAt.getTime() / 1000L; + + // The requested ACRs. + mAcrs = acrs; + } + + + @Override + public Result getResult() + { + return mResult; + } + + + @Override + public String getUserSubject() + { + return mUser.getSubject(); + } + + + @Override + public long getUserAuthenticatedAt() + { + return mUserAuthenticatedAt; + } + + + @Override + public String getAcr() + { + // Note that this is a dummy implementation. Regardless of whatever + // the actual authentication was, this implementation returns the + // first element of the requested ACRs if it is available. + // + // Of course, this implementation is not suitable for commercial use. + + if (mAcrs == null || mAcrs.length == 0) + { + return null; + } + + // The first element of the requested ACRs. + String acr = mAcrs[0]; + + if (acr == null || acr.length() == 0) + { + return null; + } + + // Return the first element of the requested ACRs. Again, + // this implementation is not suitable for commercial use. + return acr; + } + + + @Override + public Object getUserClaim(String claimName) + { + return mUser.getClaim(claimName, null); + } + + + @Override + public void sendNotification(BackchannelAuthenticationCompleteResponse info) + { + // The URL of the consumption device's notification endpoint. + URI clientNotificationEndpointUri = info.getClientNotificationEndpoint(); + + // The token that is needed for client authentication at the consumption + // device's notification endpoint. + String notificationToken = info.getClientNotificationToken(); + + // The notification content (JSON) to send to the consumption device. + String notificationContent = info.getResponseContent(); + + // Send the notification to the consumption device's notification endpoint. + Response response = + doSendNotification(clientNotificationEndpointUri, notificationToken, notificationContent); + + // The status of the response from the consumption device. + Status status = Status.fromStatusCode(response.getStatusInfo().getStatusCode()); + + // TODO: CIBA specification does not specify how to deal with responses + // returned from the consumption device in case of error push notification. + // Then, even in case of error push notification, the current implementation + // treats the responses as in the case of successful push notification. + + // Check if the "HTTP 200 OK" or "HTTP 204 No Content". + if (status == Status.OK || status == Status.NO_CONTENT) + { + // In this case, the request was successfully processed by the consumption + // device since the specification says as follows. + // + // CIBA Core spec, 10.2. Ping Callback and 10.3. Push Callback + // For valid requests, the Client Notification Endpoint SHOULD + // respond with an HTTP 204 No Content. The OP SHOULD also accept + // HTTP 200 OK and any body in the response SHOULD be ignored. + // + return; + } + + if (status.getFamily() == Status.Family.REDIRECTION) + { + // HTTP 3xx code. This case must be ignored since the specification + // says as follows. + // + // CIBA Core spec, 10.2. Ping Callback, 10.3. Push Callback + // The Client MUST NOT return an HTTP 3xx code. The OP MUST + // NOT follow redirects. + // + return; + } + } + + + private Response doSendNotification(URI clientNotificationEndpointUri, + String notificationToken, String notificationContent) + { + // A web client to send a notification to the consumption device's notification + // endpoint. + Client webClient = createClient(); + + try + { + // Send the notification to the consumption device. + return webClient.target(clientNotificationEndpointUri).request() + // CIBA Core says "The OP MUST NOT follow redirects." + .property(ClientProperties.FOLLOW_REDIRECTS, Boolean.FALSE) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + notificationToken) + .post(Entity.json(notificationContent)); + } + catch (Throwable t) + { + // Failed to send the notification to the consumption device. + throw internalServerErrorException( + t.getMessage() + ": Failed to send the notification to the consumption device"); + } + finally + { + // Close the web client. + webClient.close(); + } + } + + + @Override + public String getErrorDescription() + { + return mErrorDescription; + } + + + @Override + public URI getErrorUri() + { + return mErrorUri; + } + + + private Client createClient() + { + // SSLContext's for older TLS versions ("TLSv1" and "TLSv1.1") may not + // include any FAPI cipher suites. Here we create an SSLContext with + // "TLSv1.3" that includes the required version 1.2 and the recommended + // 1.3 version. Both of whose getDefaultSSLParameters().getCipherSuites() + // should include FAPI compatible cipher suites. + SSLContext sc = createSslContext("TLSv1.3"); + + return ClientBuilder.newBuilder().sslContext(sc).build(); + } + + + private SSLContext createSslContext(String protocol) + { + try + { + // Get an SSL context for the protocol. + SSLContext sc = SSLContext.getInstance(protocol); + + // Initialize the SSL context. + sc.init(null, null, null); + + return sc; + } + catch (Exception e) + { + throw internalServerErrorException( + "Failed to get an SSLContext for " + protocol + ": " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationEndpoint.java new file mode 100644 index 0000000..ecd323c --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationEndpoint.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2019-2024 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.backchannel; + + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.jakarta.BackchannelAuthenticationRequestHandler.Params; +import com.authlete.jakarta.BaseBackchannelAuthenticationEndpoint; + + +/** + * An implementation of backchannel authentication endpoint of CIBA (Client Initiated + * Backchannel Authentication). + * + * @author Hideki Ikeda + */ +@Path("/api/backchannel/authentication") +public class BackchannelAuthenticationEndpoint extends BaseBackchannelAuthenticationEndpoint +{ + /** + * The backchannel authentication endpoint. + */ + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response post( + @Context HttpServletRequest request, + MultivaluedMap parameters) + { + // Authlete API + AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi(); + + // Parameters for Authlete's /backchannel/authentication API + Params params = buildParams(request, parameters); + + // Handle the backchannel authentication request. + return handle(authleteApi, + new BackchannelAuthenticationRequestHandlerSpiImpl(), params); + } + + + private Params buildParams( + HttpServletRequest request, MultivaluedMap parameters) + { + Params params = new Params(); + + // RFC 6749 + // The OAuth 2.0 Authorization Framework + params.setParameters(parameters) + .setAuthorization(request.getHeader(HttpHeaders.AUTHORIZATION)) + ; + + // MTLS + // RFC 8705 : OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens + params.setClientCertificatePath(extractClientCertificateChain(request)); + + // OAuth 2.0 Attestation-Based Client Authentication + params.setClientAttestation( request.getHeader("OAuth-Client-Attestation")) + .setClientAttestationPop(request.getHeader("OAuth-Client-Attestation-PoP")) + ; + + return params; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationRequestHandlerSpiImpl.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationRequestHandlerSpiImpl.java new file mode 100644 index 0000000..f80a4be --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationRequestHandlerSpiImpl.java @@ -0,0 +1,289 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.backchannel; + + +import java.util.concurrent.Executors; +import jakarta.ws.rs.WebApplicationException; +import com.authlete.common.dto.BackchannelAuthenticationIssueResponse; +import com.authlete.common.dto.BackchannelAuthenticationResponse; +import com.authlete.common.dto.Scope; +import com.authlete.common.types.User; +import com.authlete.common.types.UserIdentificationHintType; +import com.authlete.jaxrs.server.ServerConfig; +import com.authlete.jaxrs.server.ad.type.Mode; +import com.authlete.jaxrs.server.db.UserDao; +import com.authlete.jakarta.spi.BackchannelAuthenticationRequestHandlerSpiAdapter; + + +public class BackchannelAuthenticationRequestHandlerSpiImpl extends BackchannelAuthenticationRequestHandlerSpiAdapter +{ + /** + * The flag to show whether communication with the authentication device has + * started or not. + */ + private boolean communicationWithAuthenticationDeviceStarted = false; + + + @Override + public User getUserByHint(UserIdentificationHintType hintType, String hint, String sub) + { + if (hintType == null) + { + // This won't happen. + return null; + } + + switch (hintType) + { + case LOGIN_HINT: + // Get a user with the login hint. + return getUserByLoginHint(hint); + + case LOGIN_HINT_TOKEN: + // Get a user with the login hint token. + return getUserByLoginHintToken(hint); + + case ID_TOKEN_HINT: + // Get a user with the ID token hint. + return getUserByIdTokenHint(hint, sub); + + default: + // Unknown hint type. This never happens. + return null; + } + } + + + private User getUserByLoginHint(String hint) + { + // Find a user using the login hint. A login hint is a value which identifies + // the end-user. In this implementation, we're assuming subject, email + // address and phone number can be a login hint. + + // First, find a user assuming the login hint value is a subject. + User user = UserDao.getBySubject(hint); + + if (user != null) + { + // OK. Found a user. + return user; + } + + // Second, find a user assuming the login hint value is an email address. + user = UserDao.getByEmail(hint); + + if (user != null) + { + // OK. Found a user. + return user; + } + + // Lastly, find a user assuming the login hint value is a phone number. + return UserDao.getByPhoneNumber(hint); + } + + + private User getUserByLoginHintToken(String hint) + { + // This implementation doesn't use login hint token. + return null; + } + + + private User getUserByIdTokenHint(String hint, String sub) + { + // The value of 'sub' parameter is the value of 'sub' claim contained in + // the ID token that was included in the authentication request as an + // 'id_token_hint' request parameter. In this implementation, we only use + // the value of 'sub' parameter to find a user but you may use the value + // of 'hint' parameter (, which is equivalent to the value of the payload + // of the 'id_token_hint' request parameter). + return UserDao.getBySubject(sub); + } + + + @Override + public boolean isLoginHintTokenExpired(String loginHintToken) + { + // This implementation doesn't use login hint token. + return false; + } + + + @Override + public boolean shouldCheckUserCode(User user, BackchannelAuthenticationResponse info) + { + // This implementation checks a user code only when the value of "userCodeRequired" + // parameter is true (i.e. both the "backchannel_user_code_parameter" metadata + // of the client (= Client's "bcUserCodeRequired" property) and the + // "backchannel_user_code_parameter_supported" metadata of the service + // (= Service's "backchannelUserCodeParameterSupported" property) are + // true). However, you may require a user code in some particular cases + // even if the value of the "userCodeRequired" parameter is false. + return info.isUserCodeRequired(); + } + + + @Override + public boolean isValidUserCode(User user, String userCode) + { + // The actual code of the user. + String uc = (String)user.getAttribute("code"); + + if (uc == null || uc.length() == 0) + { + // The user does not have a code. + return false; + } + + return uc.equals(userCode); + } + + + @Override + public boolean isValidBindingMessage(String bindingMessage) + { + // In this implementation, any value is regarded as a valid binding message. + // You may add additional checks here according to the following excerpt + // from the specification. + // + // CIBA Core spec, 7.1. Authentication Request + // binding_message + // ... + // The value SHOULD contain something that enables the end-user to + // reliably discern that the transaction is related across the consumption + // device and the authentication device, such as a random value of + // reasonable entropy (e.g. a transactional approval code). Because + // the various devices involved may have limited display abilities + // and the message is intending for visual inspection by the end-user, + // the binding_message value SHOULD be relatively short and use a + // limited set of plain text characters. + // + return true; + } + + + @Override + public void startCommunicationWithAuthenticationDevice(User user, BackchannelAuthenticationResponse baRes, + BackchannelAuthenticationIssueResponse baiRes) + { + // Ensure that the authorization server has not started communicating with + // the authentication device yet so that the following authentication/authorization + // process will never be performed more than once. + synchronized (this) + { + if (communicationWithAuthenticationDeviceStarted) + { + // The communication with authentication device has already started. + return; + } + + communicationWithAuthenticationDeviceStarted = true; + } + + // To process the end-user authentication and authorization, we use Authlete + // authentication device simulator (https://cibasim.authlete.com) as an + // authentication device (AD) here. + // According to API documents (https://app.swaggerhub.com/apis-docs/Authlete/cibasim), + // the simulator has three communication modes as follows. + // + // 1. synchronous mode + // 2. asynchronous mode + // 3. poll mode + // + // For example, in synchronous mode, the authorization server ask the AD + // to authenticate the user and get authorization from the user by sending + // a HTTP request and wait to get the HTTP response that contains an authentication + // and authorization result. These are processed by SyncAuthenticationDeviceProcessor. + // We also have other types of processors for other modes. For more details, + // see 'com.authlete.jaxrs.server.api.backchannel.XxxProcessor'. + + // The ticket to call Authlete's /api/backchannel/authentication/complete + // API after processing end-user authentication and authorization. + String ticket = baRes.getTicket(); + + // The name of the client. + String clientName = baRes.getClientName(); + + // The acr values requested by the client. + String[] acrs = baRes.getAcrs(); + + // The scopes requested by the client. + Scope[] scopes = baRes.getScopes(); + + // The claims requested by the client. + String[] claimNames = baRes.getClaimNames(); + + // The biding message to be shown to the user on authentication. + String bindingMessage = baRes.getBindingMessage(); + + // The auth_req_id issued to the client. This is used to programmatically + // complete the authentication on the authentication device. + String authReqId = baiRes.getAuthReqId(); + + // The duration of the issued auth_req_id in seconds. + int expiresIn = baiRes.getExpiresIn(); + + // The mode in which this authorization server communicates with the + // authentication device. + Mode mode = ServerConfig.getAuthleteAdMode(); + + // Get a processor to process end-user authentication and authorization + // by communicating with the authentication device. + AuthenticationDeviceProcessor processor = AuthenticationDeviceProcessorFactory.create( + mode, ticket, user, clientName, acrs, scopes, claimNames, bindingMessage, authReqId, expiresIn); + + // Start executing the process in the background. + Executors.newSingleThreadExecutor().execute(new AuthTask(processor)); + } + + + /** + * A class representing a task in which end-user authentication and authorization + * is performed by communicating with the authentication device. + */ + private static class AuthTask implements Runnable + { + private final AuthenticationDeviceProcessor mProcessor; + + + private AuthTask(AuthenticationDeviceProcessor processor) + { + mProcessor = processor; + } + + + @Override + public void run() + { + try + { + // Execute the processor. + mProcessor.process(); + } + catch (WebApplicationException e) + { + // Do something. + } + catch (Throwable t) + { + // Do something. + } + } + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BaseAuthenticationDeviceProcessor.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BaseAuthenticationDeviceProcessor.java new file mode 100644 index 0000000..218fa68 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BaseAuthenticationDeviceProcessor.java @@ -0,0 +1,449 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.backchannel; + + +import java.net.URI; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.Scope; +import com.authlete.common.dto.BackchannelAuthenticationCompleteRequest.Result; +import com.authlete.common.types.User; +import com.authlete.jakarta.BackchannelAuthenticationCompleteRequestHandler; +import com.authlete.jaxrs.server.ServerConfig; +import com.authlete.jaxrs.server.ad.AuthenticationDevice; + + +/** + * A base class for processors that communicate with + * Authlete CIBA authentication device simulator for end-user authentication + * and authorization. + * + * @see Authlete CIBA authentication device + * simulator + * + * @see Authlete + * CIBA authentication device simulator API + * + * @see com.authlete.jaxrs.server.api.backchannel.BackchannelAuthenticationCallbackEndpoint + * BackchannelAuthenticationCallbackEndpoint + * + * @author Hideki Ikeda + */ +public abstract class BaseAuthenticationDeviceProcessor implements AuthenticationDeviceProcessor +{ + /** + * The ratio of timeout for end-user authentication/authorization on the authentication + * device to the duration of an 'auth_req_id' + */ + private static final float AUTH_TIMEOUT_RATIO = ServerConfig.getAuthleteAdAuthTimeoutRatio(); + + + protected final String mTicket; + protected final User mUser; + protected final String mClientName; + protected final String[] mAcrs; + protected final Scope[] mScopes; + protected final String[] mClaimNames; + protected final String mBindingMessage; + protected final String mAuthReqId; + protected final int mExpiresIn; + + + /** + * The constructor of this class. + * + * @param ticket + * A ticket that was issued by Authlete's {@code /api/backchannel/authentication} + * API. + * + * @param user + * An end-user to be authenticated and asked to authorize the client + * application. + * + * @param clientName + * The name of the client application. + * + * @param acrs + * The requested ACRs. + * + * @param scopes + * The requested scopes. + * + * @param claimNames + * The names of the requested claims. + * + * @param bindingMessage + * The binding message to be shown to the end-user on the authentication + * device. + * + * @param authReqId + * The authentication request ID ({@code auth_req_id}) issued to the + * client. + * + * @param expiresIn + * The duration of the issued authentication request ID ({@code auth_req_id}) + * in seconds. + * + * @return + * An instance of this class. + */ + public BaseAuthenticationDeviceProcessor(String ticket, User user, String clientName, + String[] acrs, Scope[] scopes, String[] claimNames, String bindingMessage, + String authReqId, int expiresIn) + { + mTicket = ticket; + mUser = user; + mClientName = clientName; + mAcrs = acrs; + mScopes = scopes; + mClaimNames = claimNames; + mBindingMessage = bindingMessage; + mAuthReqId = authReqId; + mExpiresIn = expiresIn; + } + + + /** + * Delegate the process to {@link com.authlete.jakarta.BackchannelAuthenticationCompleteRequestHandler + * BackchannelAuthenticationCompleteRequestHandler} with the result of {@link + * Result#AUTHORIZED AUTHORIZED}. This method is equivalent to {@link #complete(Result, + * Date) complete}({@link Result}.{@link Result#AUTHORIZED AUTHORIZED}, {@code authTime}, + * {@code null}, {@code null}). + * + * @param authTime + * The time when end-user authentication occurred. The number of + * seconds since Unix epoch (1970-01-01). This value is used as + * the value of {@code auth_time} claim in an ID token that may + * be issued. Pass 0 if the time is unknown. + */ + protected void completeWithAuthorized(Date authTime) + { + complete(Result.AUTHORIZED, authTime, null, null); + } + + + /** + * Delegate the process to {@link com.authlete.jakarta.BackchannelAuthenticationCompleteRequestHandler + * BackchannelAuthenticationCompleteRequestHandler} with the result of {@link + * Result#ACCESS_DENIED ACCESS_DENIED}, the description of the error and the + * URI of a document which describes the error in detail. This method is + * equivalent to {@link #complete(String, URI) complete}({@link Result}.{@link + * Result#ACCESS_DENIED ACESS_DENIED}, {@code null}, {@code errorDescription}, + * {@code errorUri}). + * + * @param errorDescription + * The description of the error. + * + * @param errorUri + * The URI of a document which describes the error in detail. + */ + protected void completeWithAccessDenied(String errorDescription, URI errorUri) + { + complete(Result.ACCESS_DENIED, null, errorDescription, errorUri); + } + + + /** + * Delegate the process to {@link com.authlete.jakarta.BackchannelAuthenticationCompleteRequestHandler + * BackchannelAuthenticationCompleteRequestHandler} with the result of {@link + * Result#ACCESS_DENIED ACCESS_DENIED} and the description of the error. This + * method is equivalent to {@link #completeWithAccessDenied(String, URI) + * completeWithAccessDenied}({@code errorDescription}, {@code null}). + * + * @param errorDescription + * The description of the error. + */ + protected void completeWithAccessDenied(String errorDescription) + { + completeWithAccessDenied(errorDescription, null); + } + + + /** + * Delegate the process to {@link com.authlete.jakarta.BackchannelAuthenticationCompleteRequestHandler + * BackchannelAuthenticationCompleteRequestHandler} with the result of {@link + * Result#ACCESS_DENIED ACCESS_DENIED} and the URI of a document which describes + * the error in detail. This method is equivalent to {@link #completeWithAccessDenied(String, URI) + * completeWithAccessDenied}({@code null}, {@code errorUri}). + * + * @param errorUri + * The URI of a document which describes the error in detail. + */ + protected void completeWithAccessDenied(URI errorUri) + { + completeWithAccessDenied(null, errorUri); + } + + + /** + * Delegate the process to {@link com.authlete.jakarta.BackchannelAuthenticationCompleteRequestHandler + * BackchannelAuthenticationCompleteRequestHandler} with the result of {@link + * Result#ACCESS_DENIED ACCESS_DENIED}. This method is equivalent to {@link + * #completeWithAccessDenied(String, URI) completeWithAccessDenied}({@code null} + * , {@code null}). + */ + protected void completeWithAccessDenied() + { + completeWithAccessDenied(null, null); + } + + + /** + * Delegate the process to {@link com.authlete.jakarta.BackchannelAuthenticationCompleteRequestHandler + * BackchannelAuthenticationCompleteRequestHandler} with the result of {@link + * Result#TRANSACTION_FAILED TRANSACTION_FAILED}, the description of the error + * and the URI of a document which describes the error in detail. This method + * is equivalent to {@link #complete(String, URI) complete}({@link Result}.{@link + * Result#TRANSACTION_FAILED TRANSACTION_FAILED}, {@code null}, {@code errorDescription}, + * {@code errorUri}). + * + * @param errorDescription + * The description of the error. + * + * @param errorUri + * The URI of a document which describes the error in detail. + */ + protected void completeWithTransactionFailed(String errorDescription, URI errorUri) + { + complete(Result.TRANSACTION_FAILED, null, errorDescription, errorUri); + } + + + /** + * Delegate the process to {@link com.authlete.jakarta.BackchannelAuthenticationCompleteRequestHandler + * BackchannelAuthenticationCompleteRequestHandler} with the result of {@link + * Result#TRANSACTION_FAILED TRANSACTION_FAILED} and the description of the + * error. This method is equivalent to {@link #completeWithTransactionFailed + * (String, URI) completeWithTransactionFailed}({@code errorDescription}, + * {@code null}). + * + * @param errorDescription + * The description of the error. + */ + protected void completeWithTransactionFailed(String errorDescription) + { + completeWithTransactionFailed(errorDescription, null); + } + + + /** + * Delegate the process to {@link com.authlete.jakarta.BackchannelAuthenticationCompleteRequestHandler + * BackchannelAuthenticationCompleteRequestHandler} with the result of {@link + * Result#TRANSACTION_FAILED TRANSACTION_FAILED} and the URI of a document + * which describes the error in detail. This method is equivalent to {@link + * #completeWithTransactionFailed(String, URI) completeWithTransactionFailed} + * ({@code null}, {@code errorUri}). + * + * @param errorUri + * The URI of a document which describes the error in detail. + */ + protected void completeWithTransactionFailed(URI errorUri) + { + completeWithTransactionFailed(null, errorUri); + } + + + /** + * Delegate the process to {@link com.authlete.jakarta.BackchannelAuthenticationCompleteRequestHandler + * BackchannelAuthenticationCompleteRequestHandler} with the result of {@link + * Result#TRANSACTION_FAILED TRANSACTION_FAILED}. This method is equivalent + * to {@link #completeWithTransactionFailed(String, URI) completeWithTransactionFailed} + * ({@code null}, {@code null}). + */ + protected void completeWithTransactionFailed() + { + completeWithTransactionFailed(null, null); + } + + + /** + * Delegate the process to {@link com.authlete.jakarta.BackchannelAuthenticationCompleteRequestHandler + * BackchannelAuthenticationCompleteRequestHandler}. + * + * @param result + * The result of the end-user authentication and authorization. + * + * @param authTime + * The time when end-user authentication occurred. The number of + * seconds since Unix epoch (1970-01-01). This value is used as + * the value of {@code auth_time} claim in an ID token that may + * be issued. Pass 0 if the time is unknown. + * + * @param errorDescription + * The description of the error. + * + * @param errorUri + * The URI of a document which describes the error in detail. + * + */ + protected void complete(Result result, Date authTime, String errorDescription, URI errorUri) + { + new BackchannelAuthenticationCompleteRequestHandler( + ResilientAuthleteApiFactory.getDefaultApi(), + new BackchannelAuthenticationCompleteHandlerSpiImpl( + result, mUser, authTime, mAcrs, errorDescription, errorUri) + ) + .handle(mTicket, mClaimNames); + } + + + /** + * Handle the result of end-user authentication and authorization on the + * authentication device. + * + * @param result + * The result the end-user authentication and authorization on the + * authentication device. + */ + protected void handleResult(com.authlete.jaxrs.server.ad.type.Result result) + { + if (result == null) + { + // The result returned from the authentication device is empty. + // This should never happen. + completeWithTransactionFailed( + "The result returned from the authentication device is empty."); + return; + } + + switch (result) + { + case allow: + // The user authorized the client. + completeWithAuthorized(new Date()); + return; + + case deny: + // The user denied the client. + completeWithAccessDenied( + "The end-user denied the backchannel authentication request."); + return; + + case timeout: + // Timeout occurred on the authentication device. + completeWithTransactionFailed( + "The task delegated to the authentication device timed out."); + return; + + default: + // An unknown result returned from the authentication device. + completeWithTransactionFailed( + "The authentication device returned an unrecognizable result."); + return; + } + } + + + /** + * Build a simple message to be shown to the end-user on the authentication + * device. + * + * @return + * A message to be shown to the end-user on the authentication device. + */ + protected String buildMessage() + { + StringBuilder messageFormatBuilder = new StringBuilder(); + List messageArgs = new ArrayList(); + + // Add client information to the message. + messageFormatBuilder.append("Client App (%s) is requesting the following permissions."); + messageArgs.add(mClientName); + + // Add scope information to the message. + messageFormatBuilder.append("[Requested scopes]: %s"); + messageArgs.add(extractScopeNames()); + + // Add binding message to the message if available. + if (mBindingMessage != null) + { + messageFormatBuilder.append("[Binding message]: %s"); + messageArgs.add(mBindingMessage); + } + + // Build a message to be shown to the end-user. + return String.format(messageFormatBuilder.toString(), messageArgs.toArray()); + } + + + /** + * Compute the value of timeout for end-user authentication/authorization + * on the authentication device. + * + * @return + * The value of timeout for end-user authentication/authorization + * on the authentication device. + */ + protected int computeAuthTimeout() + { + // End-user authentication/authorization on the authentication device should + // be done before the 'auth_req_id' expires. This means the value of timeout + // for end-user authentication/authorization should be shorter than the + // duration of the 'auth_req_id'. + + // First, compute the value of the timeout based on the duration of the + // 'auth_req_id'. + int authTimeout = (int)(AUTH_TIMEOUT_RATIO * mExpiresIn); + + // If the computed timeout is shorter than the minimum value allowed by + // the authentication device. + if (authTimeout < AuthenticationDevice.AUTH_TIMEOUT_MIN) + { + // In this case, the computed timeout value is too short to perform + // end-user authentication/authorization on the authentication device. + + // TODO: For now, we throw an exception here but there might be better + // ways for this case. + throw new IllegalStateException( + "The timeout for end-user authentication/authorization on the " + + "authentication device was computed based on the duration of " + + "the 'auth_req_id' but the computed timeout value is shorter " + + "than the minimum value allowed by the authentication device."); + } + + // If the computed timeout value is larger than the maximum value allowed + // by the authentication device. + if (AuthenticationDevice.AUTH_TIMEOUT_MAX < authTimeout) + { + // Use the maximum value. + return AuthenticationDevice.AUTH_TIMEOUT_MAX; + } + + return authTimeout; + } + + + private String extractScopeNames() + { + if (mScopes == null || mScopes.length == 0) + { + return null; + } + + List scopeNames = new ArrayList(); + + for (Scope scope : mScopes) + { + scopeNames.add(scope.getName()); + } + + return String.join(",", scopeNames); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/PollAuthenticationDeviceProcessor.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/PollAuthenticationDeviceProcessor.java new file mode 100644 index 0000000..5179ba9 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/PollAuthenticationDeviceProcessor.java @@ -0,0 +1,242 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.backchannel; + + +import com.authlete.common.dto.Scope; +import com.authlete.common.types.User; +import com.authlete.jaxrs.server.ServerConfig; +import com.authlete.jaxrs.server.ad.AuthenticationDevice; +import com.authlete.jaxrs.server.ad.dto.PollAuthenticationResponse; +import com.authlete.jaxrs.server.ad.dto.PollAuthenticationResultResponse; + + +/** + * A processor that communicates with + * Authlete CIBA authentication device simulator for end-user authentication + * and authorization in poll mode. + * + * @see Authlete CIBA authentication device + * simulator + * + * @see Authlete + * CIBA authentication device simulator API + * + * @author Hideki Ikeda + */ +public class PollAuthenticationDeviceProcessor extends BaseAuthenticationDeviceProcessor +{ + /** + * The maximum number of polling trials. + */ + private static final int POLL_MAX_COUNT = ServerConfig.getAuthleteAdPollMaxCount(); + + + /** + * The period of time in milliseconds for which this authorization server waits + * between polling trials. + */ + private static final int POLL_INTERVAL = ServerConfig.getAuthleteAdPollInterval(); + + + /** + * Construct a processor that communicates with the authentication device + * simulator for end-user authentication and authorization in poll mode. + * + * @param ticket + * A ticket that was issued by Authlete's {@code /api/backchannel/authentication} + * API. + * + * @param user + * An end-user to be authenticated and asked to authorize the client + * application. + * + * @param clientName + * The name of the client application. + * + * @param acrs + * The requested ACRs. + * + * @param scopes + * The requested scopes. + * + * @param claimNames + * The names of the requested claims. + * + * @param bindingMessage + * The binding message to be shown to the end-user on the authentication + * device. + * + * @param authReqId + * The authentication request ID ({@code auth_req_id}) issued to the + * client. + * + * @param expiresIn + * The duration of the issued authentication request ID ({@code auth_req_id}) + * in seconds. + * + * @return + * A processor that communicates with the authentication device simulator + * for end-user authentication and authorization in poll mode. + */ + public PollAuthenticationDeviceProcessor(String ticket, User user, String clientName, + String[] acrs, Scope[] scopes, String[] claimNames, String bindingMessage, + String authReqId, int expiresIn) + { + super(ticket, user, clientName, acrs, scopes, claimNames, bindingMessage, + authReqId, expiresIn); + } + + + @Override + public void process() + { + // The response to be returned from the authentication device. + PollAuthenticationResponse response; + + try + { + // Communicate with the authentication device for end-user authentication + // and authorization. + response = AuthenticationDevice.poll(mUser.getSubject(), buildMessage(), + computeAuthTimeout(), mAuthReqId); + } + catch (Throwable t) + { + // An unexpected error occurred when communicating with the authentication + // device. + completeWithTransactionFailed( + "Failed to communicate with the authentication device in poll mode."); + return; + } + + // OK. The communication between this authorization server and the authentication + // device has been successfully done. + + // The ID of the request sent to the authentication device above. + String requestId = response.getRequestId(); + + // Check the request ID. + if (requestId == null || requestId.length() == 0) + { + // The request ID was invalid. This should never happen. + completeWithTransactionFailed( + "The request ID returned from the authentication device is invalid."); + return; + } + + // Start polling against the authentication device to fetch the result of + // the end-user authentication and authorization. + poll(requestId); + } + + + private void poll(String requestId) + { + PollAuthenticationResultResponse response = null; + + for (int count = 1; count <= POLL_MAX_COUNT; count++) + { + try + { + // Get the result of the end-user authentication and authorization + // from the authentication device in poll mode. + response = AuthenticationDevice.pollResult(requestId); + } + catch (Throwable t) + { + // Failed to fetch the result. + completeWithTransactionFailed( + "Failed to fetch the result of the end-user authentication " + + "and authorization from the authentication device"); + return; + } + + // The status of the end-user authentication authorization on the + // authentication device. + com.authlete.jaxrs.server.ad.type.Status status = response.getStatus(); + + if (status == null) + { + // The status returned from the authentication device is empty. + // This should never happen. + completeWithTransactionFailed( + "The status returned from the authentication device is empty."); + return; + } + + switch (status) + { + // + // When the end-user authentication and authorization has not + // been done yet. + // + case active: + if (count == POLL_MAX_COUNT) + { + // The poll trial count reached the maximum count. + completeWithTransactionFailed( + "The authentication device returned status of 'active' " + + "but the authorization server gave up polling the " + + "result of the end-user authentication and authorization " + + "since polling count reached the maximum count."); + return; + } + + // Retry to fetch the result after an interval. + sleepForInterval(POLL_INTERVAL); + break; + + // + // When the end-user authentication and authorization was done. + // + case complete: + handleResult(response.getResult()); + return; + + // + // When the end-user authentication and authorization was timed + // out. + // + case timeout: + completeWithTransactionFailed( + "The task delegated to the authentication device timed out."); + return; + + // + // When an unknown result returned from the authentication device. + // + default: + completeWithTransactionFailed( + "The authentication device returned an unrecognizable status."); + return; + } + } + } + + + private void sleepForInterval(int interval) + { + try + { + Thread.sleep(interval); + } + catch (InterruptedException e) + { + } + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/SyncAuthenticationDeviceProcessor.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/SyncAuthenticationDeviceProcessor.java new file mode 100644 index 0000000..7ac00b2 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/SyncAuthenticationDeviceProcessor.java @@ -0,0 +1,100 @@ +package com.authlete.jaxrs.server.api.backchannel; + + +import com.authlete.common.dto.Scope; +import com.authlete.common.types.User; +import com.authlete.jaxrs.server.ad.AuthenticationDevice; +import com.authlete.jaxrs.server.ad.dto.SyncAuthenticationResponse; + + +/** + * A processor that communicates with + * Authlete CIBA authentication device simulator for end-user authentication + * and authorization in synchronous mode. + * + * @see Authlete CIBA authentication device + * simulator + * + * @see Authlete + * CIBA authentication device simulator API + * + * @author Hideki Ikeda + */ +public class SyncAuthenticationDeviceProcessor extends BaseAuthenticationDeviceProcessor +{ + /** + * Construct a processor that communicates with the authentication device simulator + * for end-user authentication and authorization in synchronous mode. + * + * @param ticket + * A ticket that was issued by Authlete's {@code /api/backchannel/authentication} + * API. + * + * @param user + * An end-user to be authenticated and asked to authorize the client + * application. + * + * @param clientName + * The name of the client application. + * + * @param acrs + * The requested ACRs. + * + * @param scopes + * The requested scopes. + * + * @param claimNames + * The names of the requested claims. + * + * @param bindingMessage + * The binding message to be shown to the end-user on the authentication + * device. + * + * @param authReqId + * The authentication request ID ({@code auth_req_id}) issued to the + * client. + * + * @param expiresIn + * The duration of the issued authentication request ID ({@code auth_req_id}) + * in seconds. + * + * @return + * A processor that communicates with the authentication device simulator + * for end-user authentication and authorization in synchronous mode. + */ + public SyncAuthenticationDeviceProcessor(String ticket, User user, String clientName, + String[] acrs, Scope[] scopes, String[] claimNames, String bindingMessage, + String authReqId, int expiresIn) + { + super(ticket, user, clientName, acrs, scopes, claimNames, bindingMessage, + authReqId, expiresIn); + } + + + @Override + public void process() + { + // The response from the authentication device. + SyncAuthenticationResponse response; + + try + { + // Perform the end-user authentication and authorization by communicating + // with the authentication device in the sync mode. + response = AuthenticationDevice.sync(mUser.getSubject(), buildMessage(), + computeAuthTimeout(), mAuthReqId); + } + catch (Throwable t) + { + // An unexpected error occurred when communicating with the authentication + // device. + completeWithTransactionFailed( + "Failed to communicate with the authentication device synchronously."); + return; + } + + // Handle the authentication/authorization result returned from the authentication + // device. + handleResult(response.getResult()); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceAuthorizationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceAuthorizationEndpoint.java new file mode 100644 index 0000000..0ea86ab --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceAuthorizationEndpoint.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2019-2024 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.device; + + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.jakarta.BaseDeviceAuthorizationEndpoint; +import com.authlete.jakarta.DeviceAuthorizationRequestHandler.Params; + + +/** + * An implementation of device authorization endpoint of OAuth 2.0 Device Authorization + * Grant (Device Flow). + * + * @see RFC 8628: OAuth 2.0 Device Authorization Grant + * + * @author Hideki Ikeda + */ +@Path("/api/device/authorization") +public class DeviceAuthorizationEndpoint extends BaseDeviceAuthorizationEndpoint +{ + /** + * The device authorization endpoint. + */ + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response post( + @Context HttpServletRequest request, + MultivaluedMap parameters) + { + // Authlete API + AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi(); + + // Parameters for Authlete's /device/authorization API + Params params = buildParams(request, parameters); + + // Handle the device authorization request. + return handle(authleteApi, params); + } + + + private Params buildParams( + HttpServletRequest request, MultivaluedMap parameters) + { + Params params = new Params(); + + // RFC 6749 + // The OAuth 2.0 Authorization Framework + params.setParameters(parameters) + .setAuthorization(request.getHeader(HttpHeaders.AUTHORIZATION)) + ; + + // MTLS + // RFC 8705 : OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens + params.setClientCertificatePath(extractClientCertificateChain(request)); + + // OAuth 2.0 Attestation-Based Client Authentication + params.setClientAttestation( request.getHeader("OAuth-Client-Attestation")) + .setClientAttestationPop(request.getHeader("OAuth-Client-Attestation-PoP")) + ; + + return params; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceCompleteEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceCompleteEndpoint.java new file mode 100644 index 0000000..93b0e21 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceCompleteEndpoint.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.device; + + +import static com.authlete.jaxrs.server.util.ExceptionUtil.badRequestException; +import java.util.Date; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.types.User; +import com.authlete.jakarta.BaseDeviceCompleteEndpoint; + + +/** + * The endpoint that receives a request from the form in the authorization page + * in OAuth 2.0 Device Authorization Grant (Device Flow). + * + * @author Hideki Ikeda + */ +@Path("/api/device/complete") +public class DeviceCompleteEndpoint extends BaseDeviceCompleteEndpoint +{ + /** + * Process a request from the form in the authorization page in OAuth 2.0 + * Device Authorization Grant (Device Flow). + */ + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response post( + @Context HttpServletRequest request, + MultivaluedMap parameters) + { + // Get the existing session. + HttpSession session = getSession(request); + + // Get the information from the session. + String userCode = getUserCode(session); + User user = getUser(session); + Date authTime = (Date)session.getAttribute("authTime"); + String[] claimNames = (String[])takeAttribute(session, "claimNames"); + String[] acrs = (String[])takeAttribute(session, "acrs"); + + // Handle the device complete request. + return handle(parameters, user, authTime, acrs, userCode, claimNames); + } + + + /** + * Get the existing session. + */ + private HttpSession getSession(HttpServletRequest request) + { + // Get the existing session. + HttpSession session = request.getSession(false); + + // If there exists a session. + if (session != null) + { + // OK. + return session; + } + + // A session does not exist. Make a response of "400 Bad Request". + throw badRequestException("A session does not exist. Re-initiate the flow again."); + } + + + private String getUserCode(HttpSession session) + { + // Get and remove the user code from the session. + String userCode = (String)takeAttribute(session, "userCode"); + + if (userCode != null) + { + return userCode; + } + + // A user code was not found in the session. + throw badRequestException("A user code was not found in the session. Re-initiate the flow again."); + } + + + private User getUser(HttpSession session) + { + // Look up the user in the session to see if the user is already logged in. + User sessionUser = (User)session.getAttribute("user"); + + if (sessionUser != null) + { + // OK. The user has been already authenticated. + return sessionUser; + } + + // TODO: In this case, should we invalidate the user code here by calling + // Authlete /api/device/complete API with result='TRANSACTION_FAILED'? + + // An authenticated user was not found in the session. + throw badRequestException("An authenticated user was not found in the session. Re-initiate the flow again."); + } + + + private Response handle( + MultivaluedMap parameters, User user, Date userAuthenticatedAt, + String[] acrs, String userCode, String[] claimNames) + { + return handle(ResilientAuthleteApiFactory.getDefaultApi(), new DeviceCompleteRequestHandlerSpiImpl( + parameters, user, userAuthenticatedAt, acrs), userCode, claimNames); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceCompleteRequestHandlerSpiImpl.java b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceCompleteRequestHandlerSpiImpl.java new file mode 100644 index 0000000..8655e1d --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceCompleteRequestHandlerSpiImpl.java @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.device; + + +import static com.authlete.jaxrs.server.util.ResponseUtil.badRequest; +import static com.authlete.jaxrs.server.util.ResponseUtil.internalServerError; +import static com.authlete.jaxrs.server.util.ResponseUtil.ok; +import java.util.Date; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import com.authlete.common.dto.DeviceCompleteRequest.Result; +import com.authlete.common.types.User; +import com.authlete.jakarta.spi.DeviceCompleteRequestHandlerSpiAdapter; + + +/** + * Implementation of {@link com.authlete.jakarta.spi.DeviceCompleteRequestHandlerSpi + * DeviceCompleteRequestHandlerSpi} interface which needs to be given to the constructor + * of {@link com.authlete.jakarta.DeviceCompleteRequestHandler DeviceCompleteRequestHandler}. + * + * @author Hideki Ikeda + */ +public class DeviceCompleteRequestHandlerSpiImpl extends DeviceCompleteRequestHandlerSpiAdapter +{ + /** + * The result of end-user authentication and authorization. + */ + private final Result mResult; + + + /** + * The authenticated user. + */ + private User mUser; + + + /** + * The time when the user was authenticated in seconds since Unix epoch. + */ + private long mUserAuthenticatedAt; + + + /** + * Requested ACRs. + */ + private String[] mAcrs; + + + public DeviceCompleteRequestHandlerSpiImpl( + MultivaluedMap parameters, User user, Date userAuthenticatedAt, + String[] acrs) + { + // Check the result of end-user authentication and authorization. + mResult = parameters.containsKey("authorized") ? Result.AUTHORIZED : Result.ACCESS_DENIED; + + if (mResult != Result.AUTHORIZED) + { + // The end-user has not authorized the client. + return; + } + + // OK. The end-user has successfully authorized the client. + + // The end-user. + mUser = user; + + // The time at which end-user has been authenticated. + mUserAuthenticatedAt = (userAuthenticatedAt == null) ? 0 : userAuthenticatedAt.getTime() / 1000L; + + // The requested ACRs. + mAcrs = acrs; + } + + + @Override + public Result getResult() + { + return mResult; + } + + + @Override + public String getUserSubject() + { + return mUser.getSubject(); + } + + + @Override + public long getUserAuthenticatedAt() + { + return mUserAuthenticatedAt; + } + + + @Override + public String getAcr() + { + // Note that this is a dummy implementation. Regardless of whatever + // the actual authentication was, this implementation returns the + // first element of the requested ACRs if it is available. + // + // Of course, this implementation is not suitable for commercial use. + + if (mAcrs == null || mAcrs.length == 0) + { + return null; + } + + // The first element of the requested ACRs. + String acr = mAcrs[0]; + + if (acr == null || acr.length() == 0) + { + return null; + } + + // Return the first element of the requested ACRs. Again, + // this implementation is not suitable for commercial use. + return acr; + } + + + @Override + public Object getUserClaim(String claimName) + { + return mUser.getClaim(claimName, null); + } + + + @Override + public Response onSuccess() + { + // The user has authorized or denied the client. + // Return a response of "200 OK". + return ok("OK. The user authorization process has been done."); + } + + + @Override + public Response onInvalidRequest() + { + // The API call to Authlete was invalid. There should be some bugs in the + // implementation of this authorization sever. + // Return a response of "500 Internal Server Error". + return internalServerError("Server Error. Please re-initiate the flow again."); + } + + + @Override + public Response onUserCodeExpired() + { + // The user code has already expired. + // Return a response of "400 Bad Request". + return badRequest("The user code has expired. Please re-initiate the flow again."); + } + + + @Override + public Response onUserCodeNotExist() + { + // The user code does not exist (= invalidated). + // Return a response of "400 Bad Request". + return badRequest("The user code has been invalidated. Please re-initiate the flow again."); + } + + + @Override + public Response onServerError() + { + // An error has occurred on Authlete. + // Return a response of "500 Internal Server Error". + return internalServerError("Server Error. Please re-initiate the flow again."); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceVerificationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceVerificationEndpoint.java new file mode 100644 index 0000000..76e44c3 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceVerificationEndpoint.java @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.device; + + +import static com.authlete.jaxrs.server.util.ResponseUtil.ok; +import static com.authlete.jaxrs.server.util.ExceptionUtil.unauthorizedException; +import java.util.Date; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import org.glassfish.jersey.server.mvc.Viewable; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.types.User; +import com.authlete.jakarta.BaseDeviceVerificationEndpoint; +import com.authlete.jakarta.DeviceVerificationPageModel; +import com.authlete.jaxrs.server.db.UserDao; + + +/** + * An implementation of verification endpoint of OAuth 2.0 Device Authorization + * Grant (Device Flow). + * + * @author Hideki Ikeda + */ +@Path("/api/device/verification") +public class DeviceVerificationEndpoint extends BaseDeviceVerificationEndpoint +{ + /** + * The page template to ask the end-user for a user code. + */ + private static final String TEMPLATE = "/device/verification"; + + + /** + * The value for {@code WWW-Authenticate} header on 401 Unauthorized. + */ + private static final String CHALLENGE = "Basic realm=\"device/verification\""; + + + /** + * The verification endpoint for {@code GET} method. This method returns a + * verification page where the end-user is asked to input her login credentials + * (if not authenticated) and a user code. + */ + @GET + public Response get( + @Context HttpServletRequest request, + @Context UriInfo uriInfo) + { + // Get user information from the existing session if present. + User user = getUserFromSessionIfPresent(request); + + // Get the user code from the query parameters if present. + String userCode = uriInfo.getQueryParameters().getFirst("user_code"); + + // The model for rendering the verification page. + DeviceVerificationPageModel model = new DeviceVerificationPageModel() + .setUser(user) + .setUserCode(userCode); + + // Create a response of "200 OK" having the verification page. + return ok(new Viewable(TEMPLATE, model)); + } + + + private User getUserFromSessionIfPresent(HttpServletRequest request) + { + // Get the existing session. + HttpSession session = request.getSession(false); + + if (session == null) + { + // No existing session. + return null; + } + + // Get the user from the existing session. This may be null. + return (User)session.getAttribute("user"); + } + + + /** + * The verification endpoint for {@code POST} method. This method receives a + * request from the form in the verification page. + */ + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response post( + @Context HttpServletRequest request, + MultivaluedMap parameters) + { + // Get the existing session or create a new one. + HttpSession session = request.getSession(true); + + // Authenticate the user. + authenticateUser(session, parameters); + + // Get the user code from the parameters. + String userCode = parameters.getFirst("userCode"); + + // Handle the verification request. + return handle(session, userCode); + } + + + private void authenticateUser(HttpSession session, MultivaluedMap parameters) + { + // Look up the user in the session to see if they're already logged in. + User sessionUser = (User)session.getAttribute("user"); + + if (sessionUser != null) + { + // OK. The user has been already authenticated. + return; + } + + // The user has not been authenticated yet. Then, check the user credentials + // in the submitted parameters + + // Look up an end-user who has the login credentials. + User loginUser = UserDao.getByCredentials(parameters.getFirst("loginId"), + parameters.getFirst("password")); + + if (loginUser != null) + { + // OK. The user having the credentials was found. + + // Set the login information about the user in the session. + session.setAttribute("user", loginUser); + session.setAttribute("authTime", new Date()); + + return; + } + + // Error. The user authentication has failed. + // Urge the user to input valid login credentials again. + + // The model for rendering the verification page. + DeviceVerificationPageModel model = new DeviceVerificationPageModel() + .setLoginId(parameters.getFirst("loginId")) + .setUserCode(parameters.getFirst("userCode")) + .setNotification("User authentication failed."); + + // Throw a "401 Unauthorized" exception and show the verification page. + throw unauthorizedException(new Viewable(TEMPLATE, model), CHALLENGE); + } + + + /** + * Handle the device verification request. + */ + private Response handle(HttpSession session, String userCode) + { + return handle(ResilientAuthleteApiFactory.getDefaultApi(), + new DeviceVerificationRequestHandlerSpiImpl(session, userCode)); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceVerificationRequestHandlerSpiImpl.java b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceVerificationRequestHandlerSpiImpl.java new file mode 100644 index 0000000..9b2687e --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceVerificationRequestHandlerSpiImpl.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.device; + + +import static com.authlete.jaxrs.server.util.ResponseUtil.badRequest; +import static com.authlete.jaxrs.server.util.ResponseUtil.internalServerError; +import static com.authlete.jaxrs.server.util.ResponseUtil.notFound; +import static com.authlete.jaxrs.server.util.ResponseUtil.ok; +import jakarta.servlet.http.HttpSession; +import jakarta.ws.rs.core.Response; +import org.glassfish.jersey.server.mvc.Viewable; +import com.authlete.common.dto.DeviceVerificationResponse; +import com.authlete.common.types.User; +import com.authlete.jakarta.DeviceAuthorizationPageModel; +import com.authlete.jakarta.DeviceVerificationPageModel; +import com.authlete.jakarta.spi.DeviceVerificationRequestHandlerSpiAdapter; + + +/** + * Empty implementation of {@link DeviceVerificationRequestHandlerSpi} interface. + * + * @author Hideki Ikeda + */ +public class DeviceVerificationRequestHandlerSpiImpl extends DeviceVerificationRequestHandlerSpiAdapter +{ + /** + * The page template to ask the end-user for a user code. + */ + private static final String VERIFICATION_PAGE_TEMPLATE = "/device/verification"; + + + /** + * The page template to ask the end-user for authorization. + */ + private static final String AUTHORIZATION_PAGE_TEMPLATE = "/device/authorization"; + + + /** + * The current user session. + */ + private HttpSession mSession; + + + /** + * The user code given by the user.. + */ + private String mUserCode; + + + public DeviceVerificationRequestHandlerSpiImpl(HttpSession session, String userCode) + { + mSession = session; + mUserCode = userCode; + } + + + @Override + public String getUserCode() + { + return mUserCode; + } + + + @Override + public Response onValid(DeviceVerificationResponse info) + { + // Ask the user to authorize the client. + + // Store some information to the user's session for later use. + mSession.setAttribute("userCode", mUserCode); + mSession.setAttribute("claimNames", info.getClaimNames()); + mSession.setAttribute("acrs", info.getAcrs()); + + // The model for rendering the authorization page. + DeviceAuthorizationPageModel model = new DeviceAuthorizationPageModel(info); + + // Create a response having the page. + return ok(new Viewable(AUTHORIZATION_PAGE_TEMPLATE, model)); + } + + + @Override + public Response onExpired() + { + // Urge the user to re-initiate the device flow. + return badRequest("The user Code Expired. Please re-initiate the flow again."); + } + + + @Override + public Response onNotExist() + { + // Urge the user to re-input a valid user code. + + // The user. + User user = (User)mSession.getAttribute("user"); + + // The model for rendering the verification page. + DeviceVerificationPageModel model = new DeviceVerificationPageModel() + .setUserCode(mUserCode) + .setUser(user) + .setNotification("The user code does not exist."); + + // Return a response of "404 Not Found" having the verification page and + // urge the user to re-input a valid user code. + return notFound(new Viewable(VERIFICATION_PAGE_TEMPLATE, model)); + } + + + @Override + public Response onServerError() + { + // Urge the user to re-initiate device flow. + return internalServerError("Server Error. Please re-initiate the flow again."); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/obb/AccountsEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/obb/AccountsEndpoint.java new file mode 100644 index 0000000..55c4ee2 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/obb/AccountsEndpoint.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.obb; + + +import static com.authlete.common.util.FapiUtils.X_FAPI_INTERACTION_ID; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.IntrospectionResponse; +import com.authlete.jaxrs.server.obb.model.AccountData; +import com.authlete.jaxrs.server.obb.model.Links; +import com.authlete.jaxrs.server.obb.model.Meta; +import com.authlete.jaxrs.server.obb.model.ResponseAccountList; +import com.authlete.jaxrs.server.obb.util.ObbUtils; + + +/** + * Sample implementation of Accounts API of Open Banking Brasil. + */ +@Path("/api/obb/accounts") +public class AccountsEndpoint +{ + @GET + public Response read( + @Context HttpServletRequest request, + @HeaderParam(X_FAPI_INTERACTION_ID) String incomingInteractionId) + { + String code = "Accounts Read"; + + // Compute a value for the "x-fapi-interaction-id" HTTP response header. + String outgoingInteractionId = + ObbUtils.computeOutgoingInteractionId(code, incomingInteractionId); + + // Validate the access token. + AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi(); + IntrospectionResponse info = ObbUtils.validateAccessToken( + outgoingInteractionId, code, authleteApi, request, "accounts"); + + // Make sure that the access token has a "consent:{consentId}" scope. + ensureConsentScope(outgoingInteractionId, code, info); + + // Build a response body. + ResponseAccountList body = buildResponseBody(); + + // Build a successful response. + return ObbUtils.ok(outgoingInteractionId, body); + } + + + private static void ensureConsentScope( + String outgoingInteractionId, String code, IntrospectionResponse info) + { + // Extract a "consent:{consentId}" scope from the scope list of + // the access token. + String consentScope = ObbUtils.extractConsentScope(info); + + if (consentScope != null) + { + // Okay. The access token has a consent scope. + return; + } + + // The access token does not have a consent scope. + throw ObbUtils.forbiddenException(outgoingInteractionId, code, + "The access token does not have a consent scope."); + } + + + private static ResponseAccountList buildResponseBody() + { + // Build dummy accounts.. + AccountData account = buildAccount(); + AccountData[] data = new AccountData[] { account }; + Links links = new Links().setSelf("/"); + Meta meta = new Meta(1, 1, ObbUtils.formatNow()); + + return new ResponseAccountList(data, links, meta); + } + + + private static AccountData buildAccount() + { + // Build a dummy account. + return new AccountData() + .setBrandName("Authlete Bank") + .setCompanyCnpj("40156018000100") + .setType("CONTA_DEPOSITO_A_VISTA") + .setCompeCode("123") + .setBranchCode("6272") + .setNumber("94088392") + .setCheckDigit("4") + .setAccountId("291e5a29-49ed-401f-a583-193caa7aceee") + ; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/obb/ConsentsEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/obb/ConsentsEndpoint.java new file mode 100644 index 0000000..9795c13 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/obb/ConsentsEndpoint.java @@ -0,0 +1,210 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.obb; + + +import static com.authlete.common.util.FapiUtils.X_FAPI_INTERACTION_ID; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.common.api.AuthleteApiException; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.IntrospectionResponse; +import com.authlete.jaxrs.server.obb.database.ConsentDao; +import com.authlete.jaxrs.server.obb.model.Consent; +import com.authlete.jaxrs.server.obb.model.CreateConsent; +import com.authlete.jaxrs.server.obb.model.ResponseConsent; +import com.authlete.jaxrs.server.obb.util.ObbUtils; + + +/** + * Sample implementation of Consents API of Open Banking Brasil. + */ +@Path("/api/obb/consents") +public class ConsentsEndpoint +{ + @POST + @Consumes(MediaType.APPLICATION_JSON) + public Response create( + @Context HttpServletRequest request, + @HeaderParam(X_FAPI_INTERACTION_ID) String incomingInteractionId, + CreateConsent createConsent) + { + String code = "Consent Create"; + + // Compute a value for the "x-fapi-interaction-id" HTTP response header. + String outgoingInteractionId = + ObbUtils.computeOutgoingInteractionId(code, incomingInteractionId); + + // Validate the access token. + AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi(); + IntrospectionResponse info = ObbUtils.validateAccessToken( + outgoingInteractionId, code, authleteApi, request, "consents"); + + // Validate the input. + validateCreateConsent( + outgoingInteractionId, code, createConsent); + + // Create "consent". + Consent consent = ConsentDao.getInstance() + .create(createConsent, info.getClientId()); + + // Build a response body. + ResponseConsent rc = ResponseConsent.create(consent); + + // Build a successful response. + return ObbUtils.created(outgoingInteractionId, rc); + } + + + @GET + @Path("{consentId}") + public Response read( + @Context HttpServletRequest request, + @HeaderParam(X_FAPI_INTERACTION_ID) String incomingInteractionId, + @PathParam("consentId") String consentId) + { + String code = "Consent Read"; + + // Compute a value for the "x-fapi-interaction-id" HTTP response header. + String outgoingInteractionId = + ObbUtils.computeOutgoingInteractionId(code, incomingInteractionId); + + // Validate the access token. + AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi(); + IntrospectionResponse info = ObbUtils.validateAccessToken( + outgoingInteractionId, code, authleteApi, request, "consents"); + + // Find "consent". + Consent consent = ConsentDao.getInstance().read(consentId); + + // Validate the consent. + validateConsent(outgoingInteractionId, code, consent, info); + + // Build a response body. + ResponseConsent rc = ResponseConsent.create(consent); + + // Build a successful response. + return ObbUtils.ok(outgoingInteractionId, rc); + } + + + @DELETE + @Path("{consentId}") + public Response delete( + @Context HttpServletRequest request, + @HeaderParam(X_FAPI_INTERACTION_ID) String incomingInteractionId, + @PathParam("consentId") String consentId) + { + String code = "Consent Delete"; + + // Compute a value for the "x-fapi-interaction-id" HTTP response header. + String outgoingInteractionId = + ObbUtils.computeOutgoingInteractionId(code, incomingInteractionId); + + // Validate the access token. + AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi(); + IntrospectionResponse info = ObbUtils.validateAccessToken( + outgoingInteractionId, code, authleteApi, request, "consents"); + + // Find "consent". + Consent consent = ConsentDao.getInstance().read(consentId); + + // Validate the consent. + validateConsent(outgoingInteractionId, code, consent, info); + + // Delete the refresh token associated with the consent. + deleteRefreshToken( + outgoingInteractionId, code, authleteApi, consent.getRefreshToken()); + + // Delete the consent. + ConsentDao.getInstance().delete(consentId); + + // Build a successful response. + return ObbUtils.noContent(outgoingInteractionId); + } + + + private static void validateCreateConsent( + String outgoingInteractionId, String code, + CreateConsent createConsent) + { + if (createConsent == null) + { + throw ObbUtils.badRequestException(outgoingInteractionId, + code, "The request has no body."); + } + + // This sample implementation does not validate the content. + } + + + private static void validateConsent( + String outgoingInteractionId, String code, + Consent consent, IntrospectionResponse info) + { + // If there is no consent corresponding to the presented consent ID. + if (consent == null) + { + throw ObbUtils.notFoundException(outgoingInteractionId, + code, "The consent ID does not exist."); + } + + // If the client Id of the consent does not match the client ID + // of the access token. + if (consent.getClientId() != info.getClientId()) + { + throw ObbUtils.forbiddenException(outgoingInteractionId, + code, "Cannot access the consent with the access token."); + } + } + + + private static void deleteRefreshToken( + String outgoingInteractionId, String code, + AuthleteApi authleteApi, String refreshToken) + { + if (refreshToken == null) + { + return; + } + + try + { + // Delete the refresh token by calling Authlete's + // /api/auth/token/delete/{tokenIdentifier} API. + authleteApi.tokenDelete(refreshToken); + } + catch (AuthleteApiException e) + { + // Failed to delete the token. + e.printStackTrace(); + + throw ObbUtils.internalServerErrorException( + outgoingInteractionId, code, e.getMessage()); + } + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/obb/FAPI2BaseAccountsEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/obb/FAPI2BaseAccountsEndpoint.java new file mode 100644 index 0000000..5ff1af6 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/obb/FAPI2BaseAccountsEndpoint.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.obb; + + +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.IntrospectionResponse; +import com.authlete.jaxrs.server.obb.model.AccountData; +import com.authlete.jaxrs.server.obb.model.Links; +import com.authlete.jaxrs.server.obb.model.Meta; +import com.authlete.jaxrs.server.obb.model.ResponseAccountList; +import com.authlete.jaxrs.server.obb.util.ObbUtils; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; + +import static com.authlete.common.util.FapiUtils.X_FAPI_INTERACTION_ID; + + +/** + * Sample implementation of Accounts API of Open Banking Brasil. + * + * This is an alternative for FAPI2Baseline - it expects the 'fapi2base-accounts' scope, which has been set to + * fapi2baseline. + */ +@Path("/api/obb/fapi2base-accounts") +public class FAPI2BaseAccountsEndpoint +{ + @GET + public Response read( + @Context HttpServletRequest request, + @HeaderParam(X_FAPI_INTERACTION_ID) String incomingInteractionId) + { + String code = "Accounts Read"; + + // Compute a value for the "x-fapi-interaction-id" HTTP response header. + String outgoingInteractionId = + ObbUtils.computeOutgoingInteractionId(code, incomingInteractionId); + + // Validate the access token. + AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi(); + IntrospectionResponse info = ObbUtils.validateAccessToken( + outgoingInteractionId, code, authleteApi, request, "fapi2base-accounts"); + + // Make sure that the access token has a "consent:{consentId}" scope. + ensureConsentScope(outgoingInteractionId, code, info); + + // Build a response body. + ResponseAccountList body = buildResponseBody(); + + // Build a successful response. + return ObbUtils.ok(outgoingInteractionId, body); + } + + + private static void ensureConsentScope( + String outgoingInteractionId, String code, IntrospectionResponse info) + { + // Extract a "consent:{consentId}" scope from the scope list of + // the access token. + String consentScope = ObbUtils.extractConsentScope(info); + + if (consentScope != null) + { + // Okay. The access token has a consent scope. + return; + } + + // The access token does not have a consent scope. + throw ObbUtils.forbiddenException(outgoingInteractionId, code, + "The access token does not have a consent scope."); + } + + + private static ResponseAccountList buildResponseBody() + { + // Build dummy accounts.. + AccountData account = buildAccount(); + AccountData[] data = new AccountData[] { account }; + Links links = new Links().setSelf("/"); + Meta meta = new Meta(1, 1, ObbUtils.formatNow()); + + return new ResponseAccountList(data, links, meta); + } + + + private static AccountData buildAccount() + { + // Build a dummy account. + return new AccountData() + .setBrandName("Authlete Bank") + .setCompanyCnpj("40156018000100") + .setType("CONTA_DEPOSITO_A_VISTA") + .setCompeCode("123") + .setBranchCode("6272") + .setNumber("94088392") + .setCheckDigit("4") + .setAccountId("291e5a29-49ed-401f-a583-193caa7aceee") + ; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/obb/ResourcesEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/obb/ResourcesEndpoint.java new file mode 100644 index 0000000..d66a869 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/obb/ResourcesEndpoint.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.obb; + + +import static com.authlete.common.util.FapiUtils.X_FAPI_INTERACTION_ID; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.IntrospectionResponse; +import com.authlete.jaxrs.server.obb.model.Links; +import com.authlete.jaxrs.server.obb.model.Meta; +import com.authlete.jaxrs.server.obb.model.Resource; +import com.authlete.jaxrs.server.obb.model.ResponseResourceList; +import com.authlete.jaxrs.server.obb.util.ObbUtils; + + +/** + * Sample implementation of Resources API of Open Banking Brasil. + */ +@Path("/api/obb/resources") +public class ResourcesEndpoint +{ + @GET + public Response read( + @Context HttpServletRequest request, + @HeaderParam(X_FAPI_INTERACTION_ID) String incomingInteractionId) + { + String code = "Resources Read"; + + // Compute a value for the "x-fapi-interaction-id" HTTP response header. + String outgoingInteractionId = + ObbUtils.computeOutgoingInteractionId(code, incomingInteractionId); + + // Validate the access token. + AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi(); + IntrospectionResponse info = ObbUtils.validateAccessToken( + outgoingInteractionId, code, authleteApi, request, "resources"); + + // Make sure that the access token has a "consent:{consentId}" scope. + ensureConsentScope(outgoingInteractionId, code, info); + + // Build a response body. + ResponseResourceList body = buildResponseBody(); + + // Build a successful response. + return ObbUtils.ok(outgoingInteractionId, body); + } + + + private static void ensureConsentScope( + String outgoingInteractionId, String code, IntrospectionResponse info) + { + // Extract a "consent:{consentId}" scope from the scope list of + // the access token. + String consentScope = ObbUtils.extractConsentScope(info); + + if (consentScope != null) + { + // Okay. The access token has a consent scope. + return; + } + + // The access token does not have a consent scope. + throw ObbUtils.forbiddenException(outgoingInteractionId, code, + "The access token does not have a consent scope."); + } + + + private static ResponseResourceList buildResponseBody() + { + // Build dummy resources. + Resource resource = new Resource("resourceId", "type", "status"); + Resource[] data = new Resource[] { resource }; + Links links = new Links().setSelf("/"); + Meta meta = new Meta(1, 1, ObbUtils.formatNow()); + + return new ResponseResourceList(data, links, meta); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/vci/AbstractCredentialEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/vci/AbstractCredentialEndpoint.java new file mode 100644 index 0000000..d2f5213 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/vci/AbstractCredentialEndpoint.java @@ -0,0 +1,310 @@ +/* + * Copyright (C) 2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.vci; + + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.WebApplicationException; +import com.authlete.common.api.AuthleteApi; +import com.authlete.common.dto.CredentialIssuanceOrder; +import com.authlete.common.dto.CredentialIssuerMetadataRequest; +import com.authlete.common.dto.CredentialIssuerMetadataResponse; +import com.authlete.common.dto.CredentialRequestInfo; +import com.authlete.common.dto.IntrospectionRequest; +import com.authlete.common.dto.IntrospectionResponse; +import com.authlete.common.types.ErrorCode; +import com.authlete.jakarta.BaseResourceEndpoint; +import com.authlete.jaxrs.server.util.ExceptionUtil; +import com.authlete.jaxrs.server.vc.InvalidCredentialRequestException; +import com.authlete.jaxrs.server.vc.OrderContext; +import com.authlete.jaxrs.server.vc.OrderFormat; +import com.authlete.jaxrs.server.vc.UnsupportedCredentialFormatException; +import com.authlete.jaxrs.server.vc.UnsupportedCredentialTypeException; +import com.google.gson.Gson; + + +public abstract class AbstractCredentialEndpoint extends BaseResourceEndpoint +{ + /** + * Get the configured value of the endpoint of the credential issuer. + * The value is used as the expected value of the {@code htu} claim + * in the DPoP proof JWT. + * + *

+ * When {@code dpop} is null, this method returns null. Otherwise, this + * method calls the {@code /vci/metadata} API to get the metadata of the + * credential issuer, and extracts the value of the specified endpoint + * from the metadata. + *

+ * + * @param api + * An instance of the {@link AuthleteApi} instance. + * + * @param dpop + * A DPoP proof JWT, specified by the {@code DPoP} HTTP header. + * + * @param endpointName + * The name of an endpoint, such as "{@code credential_endpoint}". + * + * @return + * The configured value of the endpoint. If {@code dpop} is null, + * this method returns null. + */ + protected String computeHtu(AuthleteApi api, String dpop, String endpointName) + { + if (dpop == null) + { + // When a DPoP proof JWT is not available, computing the value + // of "htu" is meaningless. We skip the computation to avoid + // making a call to the /vci/metadata API. + return null; + } + + // Get the credential issuer metadata and extract the value of the + // endpoint from the metadata. + return (String)getCredentialIssuerMetadata(api).get(endpointName); + } + + + /** + * Get the credential issuer metadata by calling the {@code /vci/metadata} API. + * + * @param api + * An instance of the {@link AuthleteApi} instance. + * + * @return + * The credential issuer metadata. + */ + @SuppressWarnings("unchecked") + private Map getCredentialIssuerMetadata(AuthleteApi api) + { + // Call the /vci/metadata API to get the metadata of the credential issuer. + CredentialIssuerMetadataResponse response = + api.credentialIssuerMetadata(new CredentialIssuerMetadataRequest()); + + // The response content. + String content = response.getResponseContent(); + + // If something wrong was reported by the /vci/metadata API. + if (response.getAction() != CredentialIssuerMetadataResponse.Action.OK) + { + // 500 Internal Server Error + application/json + throw ExceptionUtil.internalServerErrorExceptionJson(content); + } + + // Convert the credential issuer metadata into a Map instance. + return new Gson().fromJson(content, Map.class); + } + + + /** + * Validate the access token and get the information about it. + * + * @param req + * The HTTP request that this endpoint has received. + * + * @param api + * An instance of the {@link AuthleteApi} interface. + * + * @param at + * The access token. + * + * @param dpop + * A DPoP proof JWT, specified by the {@code DPoP} HTTP header. + * + * @param htu + * The URL of this endpoint, the expected value of the {@code htu} + * claim in the DPoP proof JWT. + * + * @return + * The response from the {@code /auth/introspection} API. + */ + protected IntrospectionResponse introspect( + HttpServletRequest req, AuthleteApi api, + String at, String dpop, String htu) + { + // The client certificate. This is needed for certificate-bound + // access tokens. See RFC 8705 for details. + String certificate = extractClientCertificate(req); + + // The request to the /auth/introspection API. + IntrospectionRequest request = + new IntrospectionRequest() + .setToken(at) + .setClientCertificate(certificate) + .setDpop(dpop) + .setHtm("POST") + .setHtu(htu) + ; + + // Validate the access token. + return validateAccessToken(api, request); + } + + + /** + * Prepare additional HTTP headers that the response from this endpoint + * should include. + * + * @param introspection + * The response from the {@code /auth/introspection} API. + * + * @return + * A map including pairs of a header name and a header value. + */ + protected Map prepareHeaders(IntrospectionResponse introspection) + { + Map headers = new LinkedHashMap<>(); + + // The expected nonce value for DPoP proof JWT. + String dpopNonce = introspection.getDpopNonce(); + if (dpopNonce != null) + { + headers.put("DPoP-Nonce", dpopNonce); + } + + return headers; + } + + + /** + * Prepare a credential issuance order. + * + * @param context + * The context in which this method is called. + * + * @param introspection + * The response from the {@code /auth/introspection} API. + * + * @param info + * The information about the credential request. + * + * @param headers + * The additional headers that should be included in the response + * from this endpoint. + * + * @return + * A credential issuance order. + */ + protected CredentialIssuanceOrder prepareOrder( + OrderContext context, + IntrospectionResponse introspection, CredentialRequestInfo info, + Map headers) + { + try + { + // Get an OrderFormat instance corresponding to the credential format. + OrderFormat format = getOrderFormat(info); + + // Let the processor for the format create a credential issuance + // order based on the credential request. + return format.getProcessor().toOrder(context, introspection, info); + } + catch (UnsupportedCredentialFormatException cause) + { + // 400 Bad Request + "error":"unsupported_credential_format" + throw ExceptionUtil.badRequestExceptionJson( + errorJson(ErrorCode.unsupported_credential_format, cause), headers); + } + catch (UnsupportedCredentialTypeException cause) + { + // 400 Bad Request + "error":"unsupported_credential_type" + throw ExceptionUtil.badRequestExceptionJson( + errorJson(ErrorCode.unsupported_credential_type, cause), headers); + } + catch (InvalidCredentialRequestException cause) + { + // 400 Bad Request + "error":"invalid_credential_request" + throw ExceptionUtil.badRequestExceptionJson( + errorJson(ErrorCode.invalid_credential_request, cause), headers); + } + catch (WebApplicationException cause) + { + throw cause; + } + catch (Exception cause) + { + // 500 Internal Server Error + "error":"server_error" + throw ExceptionUtil.internalServerErrorExceptionJson( + errorJson(ErrorCode.server_error, cause), headers); + } + } + + + /** + * Prepare credential issuance orders. The method is supposed to be called + * from the implementation of the batch credential endpoint. + * + * @param introspection + * The response from the {@code /auth/introspection} API. + * + * @param infos + * The list of credential requests. + * + * @param headers + * The additional headers that should be included in the response + * from this endpoint. + * + * @return + * The list of credential issuance orders. + */ + protected CredentialIssuanceOrder[] prepareOrders( + IntrospectionResponse introspection, CredentialRequestInfo[] infos, + Map headers) + { + // Convert the array of CredentialRequestInfo instances + // into an array of CredentialIssuanceOrder instances. + return Arrays.stream(infos) + .map(info -> prepareOrder(OrderContext.BATCH, introspection, info, headers)) + .collect(Collectors.toList()) + .toArray(new CredentialIssuanceOrder[infos.length]); + } + + + private OrderFormat getOrderFormat(CredentialRequestInfo info) throws UnsupportedCredentialFormatException + { + // Get an OrderFormat instance that corresponds to the credential format. + OrderFormat format = OrderFormat.byId(info.getFormat()); + + // If the format is not supported. + if (format == null) + { + throw new UnsupportedCredentialFormatException(String.format( + "The credential format '%s' is not supported.", info.getFormat())); + } + + return format; + } + + + protected String errorJson(ErrorCode errorCode, Throwable cause) + { + if (cause == null) + { + return String.format( + "{%n \"error\": \"%s\"%n}%n", errorCode.name()); + } + + return String.format( + "{%n \"error\": \"%s\",%n \"error_description\": \"%s\"%n}%n", + errorCode.name(), cause.getMessage()); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/vci/BatchCredentialEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/vci/BatchCredentialEndpoint.java new file mode 100644 index 0000000..567188d --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/vci/BatchCredentialEndpoint.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2023-2024 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.vci; + + +import java.util.Map; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.CredentialBatchIssueRequest; +import com.authlete.common.dto.CredentialBatchIssueResponse; +import com.authlete.common.dto.CredentialBatchParseRequest; +import com.authlete.common.dto.CredentialBatchParseResponse; +import com.authlete.common.dto.CredentialIssuanceOrder; +import com.authlete.common.dto.CredentialRequestInfo; +import com.authlete.common.dto.IntrospectionResponse; +import com.authlete.jaxrs.server.util.ExceptionUtil; +import com.authlete.jaxrs.server.util.ResponseUtil; + + +@Path("/api/batch_credential") +public class BatchCredentialEndpoint extends AbstractCredentialEndpoint +{ + @POST + @Consumes({ MediaType.APPLICATION_JSON, "application/jwt" }) + public Response post( + @Context HttpServletRequest request, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, + @HeaderParam("DPoP") String dpop, + @QueryParam("deferred") String deferred, + String requestContent) + { + final AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); + + // Extract the access token from the request. + String accessToken = extractAccessToken(authorization, null); + + // The expected value of the 'htu' claim in the DPoP proof JWT. + String htu = computeHtu(api, dpop, "batch_credential_endpoint"); + + // Validate the access token. + IntrospectionResponse introspection = + introspect(request, api, accessToken, dpop, htu); + + // The headers that the response from this endpoint should include. + Map headers = prepareHeaders(introspection); + + // Parse the batch credential request. + CredentialRequestInfo[] infos = parseRequest( + api, requestContent, accessToken, headers); + + // Prepare credential issuance orders. + CredentialIssuanceOrder[] orders = prepareOrders(introspection, infos, headers); + + // Defer the issuance if it is explicitly requested. + // Note that the 'deferred' query parameter is not a standardized one. + boolean issuanceDeferred = Boolean.parseBoolean(deferred); + if (issuanceDeferred) + { + for (CredentialIssuanceOrder order : orders) + { + order.setIssuanceDeferred(issuanceDeferred); + } + } + + // Issue credentials and return a batch credential response. + return issue(api, orders, accessToken, headers); + } + + + private CredentialRequestInfo[] parseRequest( + AuthleteApi api, String requestContent, String accessToken, + Map headers) throws WebApplicationException + { + // Prepare a request to the /vci/batch/parse API. + CredentialBatchParseRequest request = + new CredentialBatchParseRequest() + .setRequestContent(requestContent) + .setAccessToken(accessToken); + + // Call the /vci/batch/parse API and get the response. + CredentialBatchParseResponse response = api.credentialBatchParse(request); + + // The response content. + String content = response.getResponseContent(); + + switch (response.getAction()) + { + case BAD_REQUEST: + throw ExceptionUtil.badRequestExceptionJson(content, headers); + + case UNAUTHORIZED: + throw ExceptionUtil.unauthorizedException(accessToken, content, headers); + + case FORBIDDEN: + throw ExceptionUtil.forbiddenExceptionJson(content, headers); + + case OK: + return response.getInfo(); + + case INTERNAL_SERVER_ERROR: + default: + throw ExceptionUtil.internalServerErrorExceptionJson(content, headers); + } + } + + + private Response issue( + AuthleteApi api, CredentialIssuanceOrder[] orders, String accessToken, + Map headers) throws WebApplicationException + { + // Prepare a request to the /vci/batch/issue API. + CredentialBatchIssueRequest request = + new CredentialBatchIssueRequest() + .setAccessToken(accessToken) + .setOrders(orders); + + // Call the /vci/batch/issue API and get the response. + CredentialBatchIssueResponse response = api.credentialBatchIssue(request); + + // The response content. + String content = response.getResponseContent(); + + switch (response.getAction()) + { + case CALLER_ERROR: + return ResponseUtil.internalServerErrorJson(content, headers); + + case BAD_REQUEST: + return ResponseUtil.badRequestJson(content, headers); + + case UNAUTHORIZED: + return ResponseUtil.unauthorized(accessToken, content, headers); + + case FORBIDDEN: + return ResponseUtil.forbiddenJson(content, headers); + + case OK: + return ResponseUtil.okJson(content, headers); + + case OK_JWT: + return ResponseUtil.okJwt(content, headers); + + case ACCEPTED: + return ResponseUtil.acceptedJson(content, headers); + + case ACCEPTED_JWT: + return ResponseUtil.acceptedJwt(content, headers); + + case INTERNAL_SERVER_ERROR: + default: + return ResponseUtil.internalServerErrorJson(content, headers); + } + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialEndpoint.java new file mode 100644 index 0000000..59d9fad --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialEndpoint.java @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2023-2024 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.vci; + + +import java.util.Map; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.CredentialIssuanceOrder; +import com.authlete.common.dto.CredentialRequestInfo; +import com.authlete.common.dto.CredentialSingleIssueRequest; +import com.authlete.common.dto.CredentialSingleIssueResponse; +import com.authlete.common.dto.CredentialSingleParseRequest; +import com.authlete.common.dto.CredentialSingleParseResponse; +import com.authlete.common.dto.IntrospectionResponse; +import com.authlete.jaxrs.server.util.ExceptionUtil; +import com.authlete.jaxrs.server.util.ResponseUtil; +import com.authlete.jaxrs.server.vc.OrderContext; + + +@Path("/api/credential") +public class CredentialEndpoint extends AbstractCredentialEndpoint +{ + @POST + @Consumes({ MediaType.APPLICATION_JSON, "application/jwt" }) + public Response post( + @Context HttpServletRequest request, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, + @HeaderParam("DPoP") String dpop, + @QueryParam("deferred") String deferred, + String requestContent) + { + final AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); + + // Extract the access token from the request. + String accessToken = extractAccessToken(authorization, null); + + // The expected value of the 'htu' claim in the DPoP proof JWT. + String htu = computeHtu(api, dpop, "credential_endpoint"); + + // Validate the access token. + IntrospectionResponse introspection = + introspect(request, api, accessToken, dpop, htu); + + // The headers that the response from this endpoint should include. + Map headers = prepareHeaders(introspection); + + // Parse the credential request. + CredentialRequestInfo info = parseRequest( + api, requestContent, accessToken, headers); + + // Prepare a credential issuance order. + CredentialIssuanceOrder order = + prepareOrder(OrderContext.SINGLE, introspection, info, headers); + + // Defer the issuance if it is explicitly requested. + // Note that the 'deferred' query parameter is not a standardized one. + boolean issuanceDeferred = Boolean.parseBoolean(deferred); + if (issuanceDeferred) + { + order.setIssuanceDeferred(issuanceDeferred); + } + + // Issue a credential and return a credential response. + return issue(api, order, accessToken, headers); + } + + + private CredentialRequestInfo parseRequest( + AuthleteApi api, String requestContent, String accessToken, + Map headers) throws WebApplicationException + { + // Prepare a request to the /vci/single/parse API. + CredentialSingleParseRequest request = + new CredentialSingleParseRequest() + .setRequestContent(requestContent) + .setAccessToken(accessToken); + + // Call the /vci/single/parse API and get the response. + CredentialSingleParseResponse response = api.credentialSingleParse(request); + + // The response content. + String content = response.getResponseContent(); + + switch (response.getAction()) + { + case BAD_REQUEST: + throw ExceptionUtil.badRequestExceptionJson(content, headers); + + case UNAUTHORIZED: + throw ExceptionUtil.unauthorizedException(accessToken, content, headers); + + case FORBIDDEN: + throw ExceptionUtil.forbiddenExceptionJson(content, headers); + + case OK: + return response.getInfo(); + + case INTERNAL_SERVER_ERROR: + default: + throw ExceptionUtil.internalServerErrorExceptionJson(content, headers); + } + } + + + private Response issue( + AuthleteApi api, CredentialIssuanceOrder order, String accessToken, + Map headers) throws WebApplicationException + { + // Prepare a request to the /vci/single/issue API. + CredentialSingleIssueRequest request = + new CredentialSingleIssueRequest() + .setAccessToken(accessToken) + .setOrder(order); + + // Call the /vci/single/issue API and get the response. + CredentialSingleIssueResponse response = api.credentialSingleIssue(request); + + // The response content. + String content = response.getResponseContent(); + + switch (response.getAction()) + { + case CALLER_ERROR: + return ResponseUtil.internalServerErrorJson(content, headers); + + case BAD_REQUEST: + return ResponseUtil.badRequestJson(content, headers); + + case UNAUTHORIZED: + return ResponseUtil.unauthorized(accessToken, content, headers); + + case FORBIDDEN: + return ResponseUtil.forbiddenJson(content, headers); + + case OK: + return ResponseUtil.okJson(content, headers); + + case OK_JWT: + return ResponseUtil.okJwt(content, headers); + + case ACCEPTED: + return ResponseUtil.acceptedJson(content, headers); + + case ACCEPTED_JWT: + return ResponseUtil.acceptedJwt(content, headers); + + case INTERNAL_SERVER_ERROR: + default: + return ResponseUtil.internalServerErrorJson(content, headers); + } + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialJWKSetEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialJWKSetEndpoint.java new file mode 100644 index 0000000..3280df8 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialJWKSetEndpoint.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.vci; + + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.CredentialIssuerJwksRequest; +import com.authlete.common.dto.CredentialIssuerJwksResponse; +import com.authlete.jaxrs.server.util.ExceptionUtil; +import com.authlete.jaxrs.server.util.ResponseUtil; + + +@Path("/api/vci/jwks") +public class CredentialJWKSetEndpoint extends AbstractCredentialEndpoint +{ + @GET + public Response get() + { + final AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); + + return process(api); + } + + + private Response process(final AuthleteApi api) + throws WebApplicationException + { + final CredentialIssuerJwksRequest request = + new CredentialIssuerJwksRequest() + .setPretty(false); + + final CredentialIssuerJwksResponse response = + api.credentialIssuerJwks(request); + final String content = response.getResponseContent(); + + switch (response.getAction()) + { + case NOT_FOUND: + return ResponseUtil.notFoundJson(content); + + case OK: + return ResponseUtil.okJson(response.getResponseContent()); + + case INTERNAL_SERVER_ERROR: + default: + throw ExceptionUtil.internalServerErrorExceptionJson(content); + } + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialJwtIssuerEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialJwtIssuerEndpoint.java new file mode 100644 index 0000000..2e9bbbc --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialJwtIssuerEndpoint.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2023-2025 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.vci; + + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.CredentialJwtIssuerMetadataRequest; +import com.authlete.jakarta.BaseCredentialJwtIssuerMetadataEndpoint; + + +@Path("/.well-known/{path : jwt-issuer|jwt-vc-issuer}") +public class CredentialJwtIssuerEndpoint extends BaseCredentialJwtIssuerMetadataEndpoint +{ + @GET + public Response get() + { + // Authlete API interface + AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); + + // Request to the Authlete's /api/{service-id}/vci/jwtissuer API + CredentialJwtIssuerMetadataRequest request = + new CredentialJwtIssuerMetadataRequest() + .setPretty(true); + + // Process the request. + return handle(api, request); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialMetadataEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialMetadataEndpoint.java new file mode 100644 index 0000000..4fe6759 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialMetadataEndpoint.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2023-2025 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.vci; + + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.CredentialIssuerMetadataRequest; +import com.authlete.jakarta.BaseCredentialIssuerMetadataEndpoint; + + +@Path("/.well-known/openid-credential-issuer") +public class CredentialMetadataEndpoint extends BaseCredentialIssuerMetadataEndpoint +{ + @GET + public Response get() + { + // Authlete API interface + AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); + + // Request to the Authlete's /api/{service-id}/vci/metadata API + CredentialIssuerMetadataRequest request = + new CredentialIssuerMetadataRequest() + .setPretty(true); + + // Process the request. + return handle(api, request); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialNonceEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialNonceEndpoint.java new file mode 100644 index 0000000..5eb41e6 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialNonceEndpoint.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2025 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.vci; + + +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.CredentialNonceRequest; +import com.authlete.jakarta.BaseCredentialNonceEndpoint; + + +/** + * An implementation of the nonce endpoint defined in the OpenID for Verifiable Credential Issuance 1.0 specification. + * + * @see + * OpenID for Verifiable Credential Issuance 1.0 + */ +@Path("/api/nonce") +public class CredentialNonceEndpoint extends BaseCredentialNonceEndpoint +{ + /** + * The nonce endpoint. + * + *

+ * From Section 7.1. Nonce Request of OpenID for Verifiable Credential Issuance 1.0: + *

+ * + *
+ *

+ * A request for a nonce is made by sending an HTTP POST request to the URL + * provided in the {@code nonce_endpoint} Credential Issuer Metadata parameter. + * The Nonce Endpoint is not a protected resource, meaning the Wallet does + * not need to supply an access token to access it. + *

+ *
+ * + * @return + * A response from the nonce endpoint. + */ + @POST + public Response post() + { + // Authlete API interface + AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); + + // Request to the Authlete's /api/{service-id}/vci/nonce API + CredentialNonceRequest request = + new CredentialNonceRequest() + .setPretty(true); + + // Process the request. + return handle(api, request); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialOfferEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialOfferEndpoint.java new file mode 100644 index 0000000..9871dcc --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialOfferEndpoint.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.vci; + + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.core.Response; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.CredentialOfferInfoRequest; +import com.authlete.jakarta.BaseCredentialOfferUriEndpoint; + + +@Path("/api/offer/{identifier}") +public class CredentialOfferEndpoint extends BaseCredentialOfferUriEndpoint +{ + @GET + public Response get( + @PathParam("identifier") String identifier) + { + return this.handle(ResilientAuthleteApiFactory.getDefaultApi(), + new CredentialOfferInfoRequest().setIdentifier(identifier)); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialOfferIssueEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialOfferIssueEndpoint.java new file mode 100644 index 0000000..5fecc8a --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialOfferIssueEndpoint.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.vci; + + +import java.util.Map; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import org.glassfish.jersey.server.mvc.Viewable; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.CredentialOfferCreateRequest; +import com.authlete.common.dto.CredentialOfferCreateResponse; +import com.authlete.common.types.User; +import com.authlete.jakarta.BaseEndpoint; +import com.authlete.jaxrs.server.util.ExceptionUtil; +import com.authlete.jaxrs.server.util.ProcessingUtil; + + +@Path("/api/offer/issue") +public class CredentialOfferIssueEndpoint extends BaseEndpoint +{ + @GET + public Response get() + { + // Create a Viewable instance that represents the credential offer page. + // Viewable is a class provided by Jersey for MVC. + final Viewable viewable = new Viewable("/credential-offer", new CredentialOfferPageModel()); + + // Create a response that has the viewable as its content. + return Response.ok(viewable, MediaType.TEXT_HTML_TYPE.withCharset("UTF-8")).build(); + } + + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response post( + @Context HttpServletRequest request, + MultivaluedMap parameters) + { + // Get the existing session. + final HttpSession session = ProcessingUtil.getSession(request); + + // Read request + final Map flatMap = ProcessingUtil.flattenMultivaluedMap(parameters); + final CredentialOfferPageModel model = new CredentialOfferPageModel() + .setValues(flatMap); + + final AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); + final User user = ProcessingUtil.getUser(session, parameters); + + if (user == null) + { + throw ExceptionUtil.badRequestException("Bad authentication."); + } + + final CredentialOfferCreateRequest createRequest = model.toRequest(user); + final CredentialOfferCreateResponse response = api.credentialOfferCreate(createRequest); + + switch (response.getAction()) + { + case CREATED: + model.setInfo(response.getInfo()); + model.setUser(user); + + // Create a Viewable instance that represents the credential offer page. + // Viewable is a class provided by Jersey for MVC. + final Viewable viewable = new Viewable("/credential-offer", model); + + // Create a response that has the viewable as its content. + return Response.ok(viewable, MediaType.TEXT_HTML_TYPE.withCharset("UTF-8")).build(); + + default: + throw ExceptionUtil.badRequestException("An exception occured: " + response.getResultMessage()); + } + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialOfferPageModel.java b/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialOfferPageModel.java new file mode 100644 index 0000000..941a0ba --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/vci/CredentialOfferPageModel.java @@ -0,0 +1,441 @@ +/* + * Copyright (C) 2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.api.vci; + + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URLEncoder; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import javax.imageio.ImageIO; +import com.authlete.common.dto.CredentialOfferCreateRequest; +import com.authlete.common.dto.CredentialOfferInfo; +import com.authlete.common.types.User; +import com.authlete.jakarta.AuthorizationPageModel; +import com.authlete.jaxrs.server.util.ExceptionUtil; +import com.authlete.jaxrs.server.util.ProcessingUtil; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import com.google.zxing.BarcodeFormat; +import com.google.zxing.WriterException; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; + + +/** + * Data used to render the credential offer page. + */ +public class CredentialOfferPageModel extends AuthorizationPageModel +{ + private static final long serialVersionUID = 2L; + + + private static final String DEFAULT_ENDPOINT = "openid-credential-offer://"; + private static final String CREDENTIAL_OFFER_QR_PATTERN = "%s?credential_offer=%s"; + private static final String CREDENTIAL_OFFER_URI_QR_PATTERN = "%s?credential_offer_uri=%s"; + private static final int QR_CODE_WIDTH = 300; + public static final int QR_CODE_HEIGHT = 300; + + + private static final String DEFAULT_CREDENTIAL_CONFIGURATION_IDS = + "[\n" + + " \"DigitalCredential\",\n" + + " \"IdentityCredential\",\n" + + " \"org.iso.18013.5.1.mDL\"\n" + + "]"; + + + private String credentialConfigurationIds; + private boolean authorizationCodeGrantIncluded; + private boolean issuerStateIncluded; + private boolean preAuthorizedCodeGrantIncluded; + private String txCode; + private String txCodeInputMode; + private String txCodeDescription; + private int duration; + private String credentialOfferEndpoint; + private CredentialOfferInfo info; + private String credentialOfferLink; + private String credentialOfferQrCode; + private String credentialOfferContent; + private String credentialOfferUri; + private String credentialOfferUriLink; + private String credentialOfferUriQrCode; + + + public CredentialOfferPageModel() + { + this.authorizationCodeGrantIncluded = false; + this.issuerStateIncluded = true; + this.preAuthorizedCodeGrantIncluded = true; + this.duration = 0; + this.credentialConfigurationIds = DEFAULT_CREDENTIAL_CONFIGURATION_IDS; + this.credentialOfferEndpoint = DEFAULT_ENDPOINT; + } + + + public CredentialOfferPageModel setValues(final Map values) + { + this.credentialConfigurationIds = values.getOrDefault("credentialConfigurationIds", this.credentialConfigurationIds); + this.authorizationCodeGrantIncluded = fromCheckBox(values, "authorizationCodeGrantIncluded"); + this.issuerStateIncluded = fromCheckBox(values, "issuerStateIncluded"); + this.preAuthorizedCodeGrantIncluded = fromCheckBox(values, "preAuthorizedCodeGrantIncluded"); + this.txCode = values.getOrDefault("txCode", this.txCode); + this.txCodeInputMode = values.getOrDefault("txCodeInputMode", this.txCodeInputMode); + this.txCodeDescription = values.getOrDefault("txCodeDescription", this.txCodeDescription); + this.duration = extractInt(values, "duration", this.duration); + this.credentialOfferEndpoint = values.getOrDefault("credentialOfferEndpoint", this.credentialOfferEndpoint); + + return this; + } + + + private static boolean fromCheckBox(Map values, String key) + { + return ProcessingUtil.fromFormCheckbox(values, key); + } + + + private Integer extractInt(final Map values, + final String key, final Integer def) + { + final String value = values.getOrDefault(key, Integer.toString(def)); + + try + { + final Integer intVal = Integer.parseInt(value); + + if (intVal < 0) + { + throw ExceptionUtil.badRequestException( + String.format("%s should be positive.", key)); + } + + return intVal; + } + catch (NumberFormatException e) + { + throw ExceptionUtil.badRequestException( + String.format("%s should be a number.", key)); + } + } + + + private String asQrCode(final String text) throws IOException, WriterException + { + final QRCodeWriter qrCodeWriter = new QRCodeWriter(); + final BitMatrix bitMatrix = qrCodeWriter.encode(text, BarcodeFormat.QR_CODE, + QR_CODE_WIDTH, QR_CODE_HEIGHT); + + final BufferedImage qrCode = MatrixToImageWriter.toBufferedImage(bitMatrix); + + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + ImageIO.write(qrCode, "png", output); + return Base64.getEncoder().encodeToString(output.toByteArray()); + } + + + private String prettifyJson(final String json) + { + final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + final JsonElement je = JsonParser.parseString(json); + return gson.toJson(je); + } + + + public CredentialOfferCreateRequest toRequest(final User user) + { + return new CredentialOfferCreateRequest() + .setAuthorizationCodeGrantIncluded(this.authorizationCodeGrantIncluded) + .setIssuerStateIncluded(this.issuerStateIncluded) + .setPreAuthorizedCodeGrantIncluded(this.preAuthorizedCodeGrantIncluded) + .setTxCode(this.txCode) + .setTxCodeInputMode(this.txCodeInputMode) + .setTxCodeDescription(this.txCodeDescription) + .setDuration(this.duration) + .setCredentialConfigurationIds( + parseAsStringArray("credentialConfigurationIds", this.credentialConfigurationIds)) + .setSubject(user.getSubject()); + } + + + private String[] parseAsStringArray(String name, String json) + { + List list = parseAsList(name, json); + + if (list == null) + { + return null; + } + + int size = list.size(); + + for (int i = 0; i < size; i++) + { + Object element = list.get(i); + + if (!(element instanceof String)) + { + throw ExceptionUtil.badRequestException(String.format( + "All the elements in the '%s' array must be a string, but the element at the index %d is not.", + name, i)); + } + } + + return list.stream().map(element -> (String)element).toArray(String[]::new); + } + + + private List parseAsList(String name, String json) + { + try + { + // Parse as a JSON array. + return new Gson().fromJson(json, List.class); + } + catch (Exception cause) + { + throw ExceptionUtil.badRequestException(String.format( + "The value of '%s' should be a JSON array.", name)); + } + } + + + public String getCredentialConfigurationIds() + { + return credentialConfigurationIds; + } + + + public void setCredentialConfigurationIds(String ids) + { + this.credentialConfigurationIds = ids; + } + + + public boolean isAuthorizationCodeGrantIncluded() + { + return authorizationCodeGrantIncluded; + } + + + public void setAuthorizationCodeGrantIncluded(boolean authorizationCodeGrantIncluded) + { + this.authorizationCodeGrantIncluded = authorizationCodeGrantIncluded; + } + + + public boolean isIssuerStateIncluded() + { + return issuerStateIncluded; + } + + + public void setIssuerStateIncluded(boolean issuerStateIncluded) + { + this.issuerStateIncluded = issuerStateIncluded; + } + + + public boolean isPreAuthorizedCodeGrantIncluded() + { + return preAuthorizedCodeGrantIncluded; + } + + + public void setPreAuthorizedCodeGrantIncluded(boolean preAuthorizedCodeGrantIncluded) + { + this.preAuthorizedCodeGrantIncluded = preAuthorizedCodeGrantIncluded; + } + + + public String getTxCode() + { + return txCode; + } + + + public void setTxCode(String txCode) + { + this.txCode = txCode; + } + + + public String getTxCodeInputMode() + { + return txCodeInputMode; + } + + + public void setTxCodeInputMode(String inputMode) + { + this.txCodeInputMode = inputMode; + } + + + public String getTxCodeDescription() + { + return txCodeDescription; + } + + + public void setTxCodeDescription(String description) + { + this.txCodeDescription = description; + } + + + public int getDuration() + { + return duration; + } + + + public void setDuration(int duration) + { + this.duration = duration; + } + + + public String getCredentialOfferEndpoint() + { + return credentialOfferEndpoint; + } + + + public void setCredentialOfferEndpoint(String credentialOfferEndpoint) + { + this.credentialOfferEndpoint = credentialOfferEndpoint; + } + + + public CredentialOfferInfo getInfo() + { + return info; + } + + + public void setInfo(CredentialOfferInfo info) + { + this.info = info; + + try + { + this.credentialOfferLink = String.format(CREDENTIAL_OFFER_QR_PATTERN, credentialOfferEndpoint, + URLEncoder.encode(info.getCredentialOffer(), "UTF-8")); + this.credentialOfferQrCode = asQrCode(this.credentialOfferLink); + + this.credentialOfferUri = String.format("%s/api/offer/%s", + info.getCredentialIssuer().toString(), + info.getIdentifier()); + this.credentialOfferUriLink = String.format(CREDENTIAL_OFFER_URI_QR_PATTERN, credentialOfferEndpoint, + URLEncoder.encode(credentialOfferUri, "UTF-8")); + this.credentialOfferUriQrCode = asQrCode(this.credentialOfferUriLink); + } + catch (IOException | WriterException e) + { + throw ExceptionUtil.internalServerErrorException("Can't generate QR code."); + } + + this.credentialOfferContent = info.getCredentialOffer(); + + try + { + this.credentialOfferContent = prettifyJson(this.credentialOfferContent); + } + catch (JsonParseException ignored) + {} + } + + + public String getCredentialOfferLink() + { + return credentialOfferLink; + } + + + public void setCredentialOfferLink(String credentialOfferLink) + { + this.credentialOfferLink = credentialOfferLink; + } + + + public String getCredentialOfferQrCode() + { + return credentialOfferQrCode; + } + + + public void setCredentialOfferQrCode(String credentialOfferQrCode) + { + this.credentialOfferQrCode = credentialOfferQrCode; + } + + + public String getCredentialOfferContent() + { + return credentialOfferContent; + } + + + public void setCredentialOfferContent(String credentialOfferContent) + { + this.credentialOfferContent = credentialOfferContent; + } + + + public String getCredentialOfferUri() + { + return credentialOfferUri; + } + + + public void setCredentialOfferUri(String credentialOfferUri) + { + this.credentialOfferUri = credentialOfferUri; + } + + + public String getCredentialOfferUriLink() + { + return credentialOfferUriLink; + } + + + public void setCredentialOfferUriLink(String credentialOfferUriLink) + { + this.credentialOfferUriLink = credentialOfferUriLink; + } + + + public String getCredentialOfferUriQrCode() + { + return credentialOfferUriQrCode; + } + + + public void setCredentialOfferUriQrCode(String credentialOfferUriQrCode) + { + this.credentialOfferUriQrCode = credentialOfferUriQrCode; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/api/vci/DeferredCredentialEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/vci/DeferredCredentialEndpoint.java new file mode 100644 index 0000000..d923604 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/api/vci/DeferredCredentialEndpoint.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.api.vci; + + +import java.util.Map; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import com.authlete.common.api.AuthleteApi; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; +import com.authlete.common.dto.CredentialDeferredIssueRequest; +import com.authlete.common.dto.CredentialDeferredIssueResponse; +import com.authlete.common.dto.CredentialDeferredParseRequest; +import com.authlete.common.dto.CredentialDeferredParseResponse; +import com.authlete.common.dto.CredentialIssuanceOrder; +import com.authlete.common.dto.CredentialRequestInfo; +import com.authlete.common.dto.IntrospectionResponse; +import com.authlete.common.types.ErrorCode; +import com.authlete.jaxrs.server.util.ExceptionUtil; +import com.authlete.jaxrs.server.util.ResponseUtil; +import com.authlete.jaxrs.server.vc.OrderContext; + + +@Path("/api/deferred_credential") +public class DeferredCredentialEndpoint extends AbstractCredentialEndpoint +{ + @POST + @Consumes({ MediaType.APPLICATION_JSON, "application/jwt" }) + public Response post( + @Context HttpServletRequest request, + @HeaderParam(HttpHeaders.AUTHORIZATION) String authorization, + @HeaderParam("DPoP") String dpop, + String requestContent) + { + final AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); + + // Extract the access token from the request. + String accessToken = extractAccessToken(authorization, null); + + // The expected value of the 'htu' claim in the DPoP proof JWT. + String htu = computeHtu(api, dpop, "deferred_credential_endpoint"); + + // Validate the access token + IntrospectionResponse introspection = + introspect(request, api, accessToken, dpop, htu); + + // The headers that the response from this endpoint should include. + Map headers = prepareHeaders(introspection); + + // Parse the deferred credential request. + CredentialRequestInfo info = parseRequest( + api, requestContent, accessToken, headers); + + // Prepare a credential issuance order. + CredentialIssuanceOrder order = + prepareOrder(OrderContext.DEFERRED, introspection, info, headers); + + // If the requested credential is not ready yet. + if (order.isIssuanceDeferred()) + { + // 400 Bad Request + "error":"issuance_pending" + throw ExceptionUtil.badRequestExceptionJson( + errorJson(ErrorCode.issuance_pending, null), headers); + } + + // Issue a credential and return a deferred credential response. + return issue(api, order, headers); + } + + + private CredentialRequestInfo parseRequest( + AuthleteApi api, String requestContent, String accessToken, + Map headers) throws WebApplicationException + { + // Prepare a request to the /vci/deferred/parse API. + CredentialDeferredParseRequest request = + new CredentialDeferredParseRequest() + .setRequestContent(requestContent) + .setAccessToken(accessToken); + + // Call the /vci/deferred/parse API and get the response. + CredentialDeferredParseResponse response = api.credentialDeferredParse(request); + + // The response content. + String content = response.getResponseContent(); + + switch (response.getAction()) + { + case BAD_REQUEST: + throw ExceptionUtil.badRequestExceptionJson(content, headers); + + case UNAUTHORIZED: + throw ExceptionUtil.unauthorizedException(accessToken, content, headers); + + case FORBIDDEN: + throw ExceptionUtil.forbiddenExceptionJson(content, headers); + + case OK: + return response.getInfo(); + + case INTERNAL_SERVER_ERROR: + default: + throw ExceptionUtil.internalServerErrorExceptionJson(content, headers); + } + } + + + private Response issue( + AuthleteApi api, CredentialIssuanceOrder order, + Map headers) throws WebApplicationException + { + // Prepare a request to the /vci/deferred/issue API. + CredentialDeferredIssueRequest request = + new CredentialDeferredIssueRequest() + .setOrder(order); + + // Call the /vci/deferred/issue API and get the response. + CredentialDeferredIssueResponse response = api.credentialDeferredIssue(request); + + // The response content. + String content = response.getResponseContent(); + + switch (response.getAction()) + { + case CALLER_ERROR: + return ResponseUtil.internalServerErrorJson(content, headers); + + case BAD_REQUEST: + return ResponseUtil.badRequestJson(content, headers); + + case FORBIDDEN: + return ResponseUtil.forbiddenJson(content, headers); + + case OK: + return ResponseUtil.okJson(content, headers); + + case OK_JWT: + return ResponseUtil.okJwt(content, headers); + + case INTERNAL_SERVER_ERROR: + default: + return ResponseUtil.internalServerErrorJson(content, headers); + } + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/core/AppContextListener.java b/src/main/java/com/authlete/jaxrs/server/core/AppContextListener.java new file mode 100644 index 0000000..14038ee --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/core/AppContextListener.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.core; + + +import java.security.Provider; +import java.security.Security; +import jakarta.servlet.ServletContextEvent; +import jakarta.servlet.ServletContextListener; +import com.nimbusds.jose.crypto.bc.BouncyCastleProviderSingleton; + + +public class AppContextListener implements ServletContextListener +{ + @Override + public void contextInitialized(ServletContextEvent context) + { + // Initialize BouncyCastle library. + Provider bc = BouncyCastleProviderSingleton.getInstance(); + Security.addProvider(bc); + } + + + @Override + public void contextDestroyed(ServletContextEvent context) + { + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/core/SessionTracker.java b/src/main/java/com/authlete/jaxrs/server/core/SessionTracker.java new file mode 100644 index 0000000..5358936 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/core/SessionTracker.java @@ -0,0 +1,112 @@ +package com.authlete.jaxrs.server.core; + + +import java.util.HashSet; +import java.util.Set; +import jakarta.servlet.annotation.WebListener; +import jakarta.servlet.http.HttpSessionEvent; +import jakarta.servlet.http.HttpSessionListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + +/** + * Session Tracker to track active session IDs. + * + *

+ * This class is designed to check whether a session corresponding to a given + * session ID exists. + *

+ * + *

+ * To support the "OpenID Connect + * Native SSO for Mobile Apps 1.0" specification (a.k.a. "Native SSO"), it + * is necessary for the token endpoint implementation to verify whether the + * session ID associated with a presented refresh token or subject token + * exists. For this purpose, the {@link #isActiveSessionId(String)} method is + * used. + *

+ * + *

+ * When a token request compliant with Native SSO is processed by Authlete's + * {@code /auth/token} API, the {@code action} field in the API response will + * be {@link com.authlete.common.dto.TokenResponse.Action#NATIVE_SSO NATIVE_SSO}, + * and the session ID corresponding to the refresh token or subject token will + * be included as the value of the {@code sessionId} parameter. This value + * should be passed to the {@link #isActiveSessionId(String)} method to + * determine whether the session ID is still active. + *

+ * + *

+ * Support for the Native SSO specification was introduced in Authlete 3.0. + *

+ * + * @see OpenID Connect Native SSO for Mobile Apps 1.0 + */ +@WebListener +public class SessionTracker implements HttpSessionListener +{ + private static final Set activeSessionIds = new HashSet<>(); + private static final Logger logger = LoggerFactory.getLogger(SessionTracker.class); + + + @Override + public void sessionCreated(HttpSessionEvent se) + { + // The session ID. + String sessionId = retrieveSessionId(se); + + logger.debug("A session with the session ID '{}' was created.", sessionId); + + // Add the session ID to the list of active session IDs. + activeSessionIds.add(sessionId); + } + + + @Override + public void sessionDestroyed(HttpSessionEvent se) + { + // The session ID. + String sessionId = retrieveSessionId(se); + + logger.debug("The session with the session ID '{}' was destroyed.", sessionId); + + // Remove the session ID from the list of active session IDs. + activeSessionIds.remove(sessionId); + } + + + private static String retrieveSessionId(HttpSessionEvent se) + { + return se.getSession().getId(); + } + + + /** + * Check whether the session corresponding to the specified session ID is + * active. + * + * @param sessionId + * A session ID. + * + * @return + * {@code true} if the session corresponding to the specified + * session ID is active. + */ + public static boolean isActiveSessionId(String sessionId) + { + if (sessionId == null) + { + return false; + } + + // Whether the session with the specified session ID is active. + boolean active = activeSessionIds.contains(sessionId); + + logger.debug("The session with the session ID '{}' is {}active.", sessionId, active ? "" : "not "); + + return active; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/db/BaseDao.java b/src/main/java/com/authlete/jaxrs/server/db/BaseDao.java new file mode 100644 index 0000000..0a74246 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/db/BaseDao.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.db; + + +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; + + +public class BaseDao +{ + /** + * Create a Reader instance that reads the specified resource. + */ + protected static Reader createReader(Class clazz, String resource) + { + return new InputStreamReader( + clazz.getResourceAsStream(resource), StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/db/DatasetDao.java b/src/main/java/com/authlete/jaxrs/server/db/DatasetDao.java new file mode 100644 index 0000000..29e2952 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/db/DatasetDao.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2022 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.db; + + +import java.io.IOException; +import java.io.Reader; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import com.google.gson.Gson; + + +/** + * Sources of datasets (contents of "verified_claims"). + */ +public class DatasetDao extends BaseDao +{ + // Sources of datasets. The JSON files have been copied from + // https://bitbucket.org/openid/ekyc-ida/src/master/examples/response/ + // + // For "Inga" (subject = 1004) + // DOCUMENT_800_63A (trust_framework = "nist_800_63A") + // DOCUMENT_UKTDIF (trust_framework = "uk_tfida") + // + private static final String DOCUMENT_800_63A = "/ekyc-ida/examples/response/document_800_63A.json"; + private static final String DOCUMENT_UKTDIF = "/ekyc-ida/examples/response/document_UKTDIF.json"; + + + // List of [subject, resource... ]. Each element is a list whose first + // element is "subject" (user identifier) and second & subsequent + // elements are names of resource files. + // + // Values of "subject" should be found in UserDao. + private static final List> SUBJECT_RESOURCES_LIST = Arrays.asList( + // Subject, Resource 0, Resource 1, ... + Arrays.asList("1004", DOCUMENT_800_63A, DOCUMENT_UKTDIF) + ); + + + /** + * Holder of the cache of datasets. + */ + private static final class SubjectDatasetsMapHolder + { + // Cache of datasets. Keys are subjects (user identifiers). + // Values are contents of "verified_claims" objects loaded + // from JSON files. + private static final Map>> INSTANCE = + createSubjectDatasetsMap(); + } + + + /** + * Create the content of SubjectDatasetsMapHolder.INSTANCE. + */ + private static Map>> createSubjectDatasetsMap() + { + Map>> map = new HashMap<>(); + + for (List subjectResources : SUBJECT_RESOURCES_LIST) + { + // Subject (user identifier) + String subject = subjectResources.get(0); + + // Datasets (loaded from resources) + List> datasets = subjectResources.stream().skip(1) + .map(resource -> loadDataset(resource)).collect(Collectors.toList()); + + map.put(subject, datasets); + } + + return map; + } + + + /** + * Load a dataset (the content of "verified_claims") from the resource. + */ + @SuppressWarnings("unchecked") + private static Map loadDataset(String resource) + { + // Create a Reader to read the resource. + try ( Reader reader = createReader(DatasetDao.class, resource) ) + { + // Convert the JSON in the resource into a Map instance. + Map map = new Gson().fromJson(reader, Map.class); + + // Return the content of "verified_claims". + return (Map)map.get("verified_claims"); + } + catch (IOException e) + { + // Failed to read the resource. + e.printStackTrace(); + + return Collections.emptyMap(); + } + } + + + /** + * Get the datasets of the subject (user identifier). + * + * @param subject + * The subject of a user. + * + * @return + * List of datasets. Each dataset corresponds to the content of + * "verified_claims". null is returned when datasets of the + * specified subject are unavailable. + */ + public static List> get(String subject) + { + return SubjectDatasetsMapHolder.INSTANCE.get(subject); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/db/ResourceServerDao.java b/src/main/java/com/authlete/jaxrs/server/db/ResourceServerDao.java new file mode 100644 index 0000000..7a5e0b3 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/db/ResourceServerDao.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.db; + + +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + + +/** + * Operations to access the resource server database. + */ +public class ResourceServerDao extends BaseDao +{ + private static final String RESOURCE_SERVER = "/resource_servers.json"; + + + /** + * Holder of the cache of resource server entities. + */ + private static final class ResourceServerEntityHolder + { + // Cache of resource server entities. Keys are resource server + // IDs. Values are ResourceServerEntity objects loaded from JSON + // files. + private static final Map INSTANCE = + createResourceServers(); + } + + + /** + * Create the content of ResourceServersHolder.INSTANCE. + */ + private static Map createResourceServers() + { + return loadResourceServers(RESOURCE_SERVER) + .stream() + .collect(Collectors.toMap(s -> s.getId(), s -> s)); + } + + + /** + * Load configurations of resource servers from the resource. + */ + private static List loadResourceServers(String resource) + { + // Create a Reader to read the resource. + try ( Reader reader = createReader(ResourceServerDao.class, resource) ) + { + // The type of the object to be loaded. + Type type = new TypeToken>(){}.getType(); + + // Convert the JSON in the resource into a list of ResourceServerEntity. + return new Gson().fromJson(reader, type); + } + catch (IOException e) + { + // Failed to read the resource. + e.printStackTrace(); + + return Collections.emptyList(); + } + } + + + /** + * Get a resource server entity. + * + * @param rsId + * The ID of a resource server. + * + * @return + * A resource server entity specified by the ID. null is + * returned when the resource server entity is unavailable. + */ + public static ResourceServerEntity get(String rsId) + { + return ResourceServerEntityHolder.INSTANCE.get(rsId); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/db/ResourceServerEntity.java b/src/main/java/com/authlete/jaxrs/server/db/ResourceServerEntity.java new file mode 100644 index 0000000..ac39a9f --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/db/ResourceServerEntity.java @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.db; + + +import java.io.Serializable; +import java.net.URI; +import com.authlete.common.types.JWEAlg; +import com.authlete.common.types.JWEEnc; +import com.authlete.common.types.JWSAlg; + + +/** + * Dummy resource server entity that represents a resource server record. + */ +public class ResourceServerEntity implements Serializable +{ + private static final long serialVersionUID = 1L; + + + /** + * The ID of the resource server. + */ + private String id; + + + /** + * The secret of the resource server. + */ + private String secret; + + + /** + * The URI of the resource server. + */ + private URI uri; + + + /** + * The JWS alg algorithm for signing introspection responses. + */ + private JWSAlg introspectionSignAlg; + + + /** + * The JWE alg algorithm for encrypting introspection responses. + */ + private JWEAlg introspectionEncryptionAlg; + + + /** + * The JWE enc algorithm for encrypting introspection responses. + */ + private JWEEnc introspectionEncryptionEnc; + + + /** + * The shared key for signing introspection responses. + */ + private String sharedKeyForIntrospectionResponseSign; + + + /** + * The shared key for encrypting introspection responses. + */ + private String sharedKeyForIntrospectionResponseEncryption; + + + /** + * The public key for signing introspection responses. + */ + private String publicKeyForIntrospectionResponseEncryption; + + + /** + * Constructor with initial values. + */ + public ResourceServerEntity( + String id, + String secret, + URI uri, + JWSAlg introspectionSignAlg, + JWEAlg introspectionEncryptionAlg, + JWEEnc introspectionEncryptionEnc, + String sharedKeyForIntrospectionResponseSign, + String sharedKeyForIntrospectionResponseEncryption, + String publicKeyForIntrospectionResponseEncryption) + { + this.id = id; + this.secret = secret; + this.uri = uri; + this.introspectionSignAlg = introspectionSignAlg; + this.introspectionEncryptionAlg = introspectionEncryptionAlg; + this.introspectionEncryptionEnc = introspectionEncryptionEnc; + this.sharedKeyForIntrospectionResponseSign = sharedKeyForIntrospectionResponseSign; + this.sharedKeyForIntrospectionResponseEncryption = sharedKeyForIntrospectionResponseEncryption; + } + + + /** + * Get the ID of the resource server. + * + * @return + * The ID of the resource server. + */ + public String getId() + { + return id; + } + + + /** + * Get the secret of the resource server. + * + * @return + * The secret of the resource server. + */ + public String getSecret() + { + return secret; + } + + + /** + * Get the URI of the resource server. + * + * @return + * The URI of the resource server. + */ + public URI getUri() + { + return uri; + } + + + /** + * Get the JWS alg algorithm for signing introspection + * responses. + * + * @return + * The JWS alg algorithm for signing introspection + * responses. + */ + public JWSAlg getIntrospectionSignAlg() + { + return introspectionSignAlg; + } + + + /** + * Get the JWE alg algorithm for encrypting introspection + * responses. + * + * @return + * The JWE alg algorithm for encrypting introspection + * responses. + */ + public JWEAlg getIntrospectionEncryptionAlg() + { + return introspectionEncryptionAlg; + } + + + /** + * Get the JWE enc algorithm for encrypting the introspection + * response. + * + * @return + * The JWE enc algorithm for encrypting the + * introspection response. + */ + public JWEEnc getIntrospectionEncryptionEnc() + { + return introspectionEncryptionEnc; + } + + + /** + * Get the shared key for signing introspection responses. + * + * @return + * The shared key for signing introspection responses. + */ + public String getSharedKeyForIntrospectionResponseSign() + { + return sharedKeyForIntrospectionResponseSign; + } + + + /** + * Get the shared key for encrypting introspection responses. + * + * @return + * The shared key for encrypting introspection responses. + */ + public String getSharedKeyForIntrospectionResponseEncryption() + { + return sharedKeyForIntrospectionResponseEncryption; + } + + + /** + * Get the public key for signing introspection responses. + * + * @return + * The public key for signing introspection responses. + */ + public String getPublicKeyForIntrospectionResponseEncryption() + { + return publicKeyForIntrospectionResponseEncryption; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/db/UserDao.java b/src/main/java/com/authlete/jaxrs/server/db/UserDao.java index c69e040..8d2cdad 100644 --- a/src/main/java/com/authlete/jaxrs/server/db/UserDao.java +++ b/src/main/java/com/authlete/jaxrs/server/db/UserDao.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 Authlete, Inc. + * Copyright (C) 2016-2023 Authlete, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,28 +17,193 @@ package com.authlete.jaxrs.server.db; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import com.authlete.common.dto.Address; import com.authlete.common.types.User; +import com.authlete.mdoc.constants.MDLClaimNames; +import com.authlete.mdoc.constants.MDLConstants; /** * Operations to access the user database. - * - * @author Takahiko Kawasaki */ public class UserDao { /** * Dummy user database. */ - private static final UserEntity[] sUserDB = { - new UserEntity("1001", "john", "john", "John Smith", "john@example.com", - new Address().setCountry("USA"), "+1 (425) 555-1212"), + private static final Map sUserDB = new HashMap<>(); + + static + { + addAll( + new UserEntity("1001", "john", "john", "John Flibble Smith", "john@example.com", + new Address().setCountry("USA Flibble"), "+1 (425) 555-1212", "675325", + "John", "Smith", "Doe", "Johnny", + "https://example.com/john/profile", "https://example.com/john/me.jpg", + "https://example.com/john/", "male", "Europe/London", + "en-US", "john", "0000-03-22", toDate("2020-01-01")), + new UserEntity("1002", "jane", "jane", "Jane Smith", "jane@example.com", - new Address().setCountry("Chile"), "+56 (2) 687 2400") + new Address().setCountry("Chile"), "+56 (2) 687 2400", "264209"), + + new UserEntity("1003", "max", "max", "Max Meier", "max@example.com", + new Address().setCountry("Germany").setRegion("Bavaria").setLocality("Augsburg"), + "+49 (30) 210 94-0", "12344", + "Max", "Meier", null, null, + "https://example.com/max/profile", "https://example.com/max/me.jpg", + "https://example.com/max/", "male", "Europe/Berlin", "de", + "max", "1956-01-28", toDate("2021-11-28")) + .setNationalities(Arrays.asList("USA", "DEU")), + + new UserEntity("1004", "inga", "inga", "Inga Silverstone", "inga@example.com", + new Address() + .setFormatted("114 Old State Hwy 127, Shoshone, CA 92384, USA") + .setCountry("USA") + .setLocality("Shoshone") + .setStreetAddress("114 Old State Hwy 127") + .setPostalCode("CA 92384"), + null, null, "Inga", "Silverstone", null, null, + "https://example.com/inga/profile", "https://example.com/inga/me.jpg", + "https://example.com/inga/", "female", "America/Toronto", "en-US", + "inga", "1991-11-06", toDate("2022-04-30")) + .setAttribute(MDLConstants.DOC_TYPE_MDL, createMDLData1004()) + + // POTENTIAL Interop Event Track 2 + // https://gitlab.opencode.de/potential/interop-event + .addExtraClaim("age_equal_or_over", mapOf("18", Boolean.TRUE)) + .addExtraClaim("place_of_birth", mapOf("locality", "Shoshone")) + .addExtraClaim("issuing_authority", "US") + .addExtraClaim("issuing_country", "US") + ); }; + /** + * A substitute for {@code Map.of}, which is unavailable in Java 8. + */ + private static Map mapOf(Object... keyValuePairs) + { + Map map = new LinkedHashMap<>(); + + for (int i = 0; i < keyValuePairs.length; i += 2) + { + map.put((String)keyValuePairs[i], keyValuePairs[i+1]); + } + + return map; + } + + + private static Map createMDLData1004() + { + // Some string claim values in the data below have the prefix "cbor:". + // They are interpreted by Authlete server. See the JavaDoc of the + // CredentialIssuanceOrder class in the authlete-java-common library + // for details. + // + // CredentialIssuerOrder JavaDoc + // https://authlete.github.io/authlete-java-common/com/authlete/common/dto/CredentialIssuanceOrder.html + // + + // { + // "vehicle_category_code" : "A", + // "issue_date" : "2023-01-01", + // "expiry_date" : "2043-01-01" + // } + Map vehicleA = new LinkedHashMap<>(); + vehicleA.put("vehicle_category_code", "A"); + vehicleA.put("issue_date", "cbor:1004(\"2023-01-01\")"); + vehicleA.put("expiry_date", "cbor:1004(\"2043-01-01\")"); + + // [ + // vehicleA + // ] + List> drivingPrivileges = new ArrayList<>(); + drivingPrivileges.add(vehicleA); + + // { + // "family_name" : "Silverstone", + // "given_name" : "Inga", + // "birth_date" : "1991-11-06", + // "issuing_country" : "US", + // "document_number" : "12345678", + // "driving_privileges" : drivingPrivileges + // } + Map nameSpace = new LinkedHashMap<>(); + nameSpace.put(MDLClaimNames.FAMILY_NAME, "Silverstone"); + nameSpace.put(MDLClaimNames.GIVEN_NAME, "Inga"); + nameSpace.put(MDLClaimNames.BIRTH_DATE, "cbor:1004(\"1991-11-06\")"); + nameSpace.put(MDLClaimNames.ISSUING_COUNTRY, "US"); + nameSpace.put(MDLClaimNames.DOCUMENT_NUMBER, "12345678"); + nameSpace.put(MDLClaimNames.DRIVING_PRIVILEGES, drivingPrivileges); + + // Additional mandatory mDL elements (ISO/IEC 18013-5). + nameSpace.put(MDLClaimNames.ISSUING_AUTHORITY, "US"); + nameSpace.put(MDLClaimNames.UN_DISTINGUISHING_SIGN, "USA"); + // "portrait": a small JPEG of the holder, as a CBOR byte string. + nameSpace.put(MDLClaimNames.PORTRAIT, "cbor:h'ffd8ffe000104a46494600010100000100010000ffdb0043000d090a0b0a080d0b0a0b0e0e0d0f13201513121213271c1e17202e2931302e292d2c333a4a3e333646372c2d405741464c4e525352323e5a615a50604a51524fffdb0043010e0e0e131113261515264f352d354f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4fffc00011080078006003012200021101031101ffc4001b00010003010101010000000000000000000004050607010302ffc4003f100001030204030406050b05000000000001000203041105122131064151132261a11432428191f0233571b2d1151624333643537382a2b1526292c1e1ffc4001801000301010000000000000000000000000003040201ffc4001f1100030002020301010000000000000000000102031131410412322122ffda000c03010002110311003f00dba22270b088880088880088880088880088880088880088b2bc598bc8c91d86d3bb28ca3b670dcdfd9fb2d6bf5bdbaac5d295b66a65d3d225625c534b4f9a3a26fa44a2e336cc075e7cf96da1eaa8e6e28c5657873258e116b65646083e3add532974986575600ea7a67b9a45c38f75a75b684e8a4acb4fb2b9c5289b171362d1c81ce9db201ecba36d8fc002ae30ee2d864b33108fb277f12304b79f2dc72ebee59da9c2311a56679a91e1b62496d9c001bded7b7bd415c9cb4bb078e5f4754639af635ec70735c2e0837042f562b85f17929aaa3a199d9a9e575997f61c76b7813cbc6fd6fb55645ab5b25b872f411116cc04444005cb6591f34cf964399ef71738dad72752ba7544cda7a69677825b130bc81bd80bae6b414fe955d053d9c448f0d765dc0bea7e1752f91d1460ecd070ee0714b08acae88bb31bc51bb4046f98f5bf43e775a9516b7b782901a1f458fb3dfb6b863580786dc957e0f8955e252676cf412c0c3693b212078d34d1df3ba9ca0ba5418fe0714f0c95747111520e67359fbceba75e7a6fe3752f19adaaa0689d92d1454e0004cf9cb8bae7401bbe9ff6beb84d4555553f6f3c949244f00c4ea7cdaef7bdfe77401cf974dc3e47cd87534b21ccf7c4c738dad72402560b1ea56d262f3c71b0b63243d808b0b117d3c2f71ee5b7c0e66cf82d23d8080220cd7ab7ba7cc27f8fcb119f844e444559304444015dc4333a0c0aadec0092cc9af4710d3e4563f86bebea6feafba56b38a3f67eabfa3ef8584a59bd1eae19f2e6ec9ed7e5bdaf637b28fc8fa2ac1f2740c5285b8961d351bde582403bc05ec4104798553c35c3d2e1334b5153331f2b9b91ad8ee5a1b706e6e37b8f9e57d148c9a264b19bb1ed0e69b6e0ecbf14f5505567ec5f98c672b8104169f10754adbd6877aedeca8c7f08763b4703e190453444e50e3dd20daf7d2fc87ced2f00c2ff002461c29dd2769239d9de46d98802c3c341f3a292e9a2a0a40eaa91ac6836bef724f25251b7ad07aadecc6f187d6b17f207de72bbe1099d2e0b91c05a295cc6db98d1dafbdc564b18aa656e293d4463b8e759be200b03efb5d6a782fea897f9e7eeb53307d8acdf26811115a4811110056f11c6f9701aa6c62e4343b7e40827c815cf1750ab87d2692683365ed6373335af6b8b5d72f52790bf5329c0ff001a355c1f533be39a94b73431f783f37aa4fb36e86c4fc7aab9aac329ea6613de48a71fbc89d95db5955f05d3fe87533e6f5e40cb5b6ca2f7feef25a0cc2e45f64972d24c72b4db48830e154f1d409e47cd512b6d95d33f365fb3e2a1715d4cf061a2389bdc99d91efcd6239dbdf63eebf55765c073553c594fda608e7e6b18646bed6dfd9b7f779214badb5d055a4d6cc32dc70746f8f06739e2c2495ce6ebb8b01fe415875d170087b0c129199b35e3cf7b5bd6ef5bcd3702fe85677fc960888ac250888800b138ee07583167be9609268ea1f99a5bad89dc1e9a9e7cb9eeb6c8b170ad699b8b72f688b85d1fa061b052e6cc58def1bdf526e6de17251febbbed51eaf1ec36941bd4095d6b86c5debebd76f355aee26a22e27b2a8d4ffa5bf8ace5c36e52946b16494dba65cafbd442da8a69607921b2b0b091bd88b2cf7e72d17f0aa3fe2dfc55852710e1b52434ca61713602519796f7dbcd730e1b9dfb23b9b24d6b4ccbd370ed73f1314b3c2e6c4d777e51ea96ff00b4db53d3cf9adeaf18e6bd81ec70735c2e0837042f56a31a8e0c5dbbe422226180888803e15b59050d33a7a876560d80ddc7a0f158ac571ca9c4af1feaa9cdbe881bdc8ea79ff8d97eb8871135f5e5ac7030424b63b5b5ea6fcee47c2caa5538f1a4b6c5556c222269908888027e178b54e1b27d13b3425d77c47677e07ff375b5c2f128312a6ed62eebc68f8c9d5a7f0f15ced4dc26bdf87573276eac3dd905af76df5b78a5de355fab93535a3a1a2f18e6bd81ec70735c2e0837042f54a342878c547a2e13532ddc08616b4b7704e80fc48445d9e51c7c1ced1115a24222200222200222200dd70c5476f82c6d25c5d138c64bbe22de16202b64451dfd31d3c1ffd9'"); + + // { + // "org.iso.18013.5.1" : nameSpace + // } + Map root = new LinkedHashMap<>(); + root.put(MDLConstants.NAME_SPACE_MDL, nameSpace); + + return root; + } + + + private static Date toDate(String input) + { + return Date.from(LocalDate.parse(input).atStartOfDay().toInstant(ZoneOffset.UTC)); + } + + + /** + * Condition for user search. + */ + private static interface SearchCondition + { + boolean check(UserEntity ue); + } + + + /** + * Get a user who meets the condition. + * + * @param condition + * The condition for searching a user. + * + * @return + * A user who meets the condition. + */ + private static User get(SearchCondition condition) + { + // For each user. + for (UserEntity ue : sUserDB.values()) + { + // If the condition is satisfied. + if (condition.check(ue)) + { + // Found the user who meets the condition. + return ue; + } + } + + // Not found any user who meets the condition. + return null; + } + + /** * Get a user entity by a pair of login ID and password. * @@ -53,20 +218,115 @@ public class UserDao * {@code null} is returned if there is no user who has * the login credentials. */ - public static User getByCredentials(String loginId, String password) + public static User getByCredentials(final String loginId, final String password) { - // For each user. - for (UserEntity ue : sUserDB) - { - // If the login credentials are valid. - if (ue.getLoginId().equals(loginId) && ue.getPassword().equals(password)) + return get(new SearchCondition() { + @Override + public boolean check(UserEntity ue) { - // Found the user who has the login credentials. - return ue; + String registeredLoginId = ue.getLoginId(); + String registeredPassword = ue.getPassword(); + + // Check if the user's credentials are the target ones. + return ((registeredLoginId != null) && registeredLoginId .equals(loginId )) && + ((registeredPassword != null) && registeredPassword.equals(password)); } - } + }); + } - // Not found any user who has the login credentials. - return null; + + /** + * Get a user by a subject. + * + * @param subject + * The subject of a user. + * + * @return + * A user entity that has the subject. + * {@code null} is returned if there is no user who has + * the subject. + */ + public static User getBySubject(final String subject) + { + return get(new SearchCondition() { + @Override + public boolean check(UserEntity ue) + { + // Check if the user's subject is the target one. + return ue.getSubject().equals(subject); + } + }); + } + + + /** + * Get a user by an email address. + * + * @param email + * An email address. + * + * @return + * A user entity that has the email address. + * {@code null} is returned if there is no user who has + * the email address. + */ + public static User getByEmail(final String email) + { + return get(new SearchCondition() { + @Override + public boolean check(UserEntity ue) + { + // Get the user's "email" claim. + String e = (String)ue.getClaim("email", null); + + // Check if the user's email is the target one. + return e != null && e.equals(email); + } + }); + } + + + /** + * Get a user by a phone number. + * + * @param phoneNumber + * A phone number. + * + * @return + * A user entity that has the phone number. + * {@code null} is returned if there is no user who has + * the phone number. + */ + public static User getByPhoneNumber(final String phoneNumber) + { + return get(new SearchCondition() { + @Override + public boolean check(UserEntity ue) + { + // Get the user's "phone_number" claim. + String ph = (String)ue.getClaim("phone_number", null); + + // Check if the user's phone number is the target one. + return ph != null && ph.equals(phoneNumber); + } + }); + } + + + /** + * Add a user. + */ + public static void add(UserEntity entity) + { + sUserDB.put(entity.getSubject(), entity); + } + + + private static void addAll(UserEntity... entities) + { + for (UserEntity entity : entities) + { + add(entity); + } } } diff --git a/src/main/java/com/authlete/jaxrs/server/db/UserEntity.java b/src/main/java/com/authlete/jaxrs/server/db/UserEntity.java index 59418b1..8852ce8 100644 --- a/src/main/java/com/authlete/jaxrs/server/db/UserEntity.java +++ b/src/main/java/com/authlete/jaxrs/server/db/UserEntity.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 Authlete, Inc. + * Copyright (C) 2016-2024 Authlete, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,18 +17,28 @@ package com.authlete.jaxrs.server.db; +import java.io.Serializable; +import java.net.URI; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import com.authlete.common.dto.Address; import com.authlete.common.types.StandardClaims; import com.authlete.common.types.User; +import com.google.gson.Gson; +import com.nimbusds.openid.connect.sdk.claims.Gender; +import com.nimbusds.openid.connect.sdk.claims.UserInfo; /** * Dummy user entity that represents a user record. - * - * @author Takahiko Kawasaki */ -public class UserEntity implements User +public class UserEntity implements User, Serializable { + private static final long serialVersionUID = 2L; + + /** * The subject (unique identifier) of the user. */ @@ -71,12 +81,66 @@ public class UserEntity implements User private String phoneNumber; + /** + * The code of the user. + */ + private String code; + + // Below are standard claims as defined in https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + private boolean phoneNumberVerified; + private boolean emailVerified; + private String givenName; + private String familyName; + private String middleName; + private String nickName; + private String profile; + private String picture; + private String website; + private String gender; + private String zoneinfo; + private String locale; + private String preferredUsername; + private String birthdate; + private Date updatedAt; + + + // Custom claims + private List nationalities; + + + // Attributes + private Map attributes = new HashMap<>(); + + + // Extra claims + private Map extraClaims = new HashMap<>(); + + /** * Constructor with initial values. */ public UserEntity( String subject, String loginId, String password, String name, - String email, Address address, String phoneNumber) + String email, Address address, String phoneNumber, String code) + { + this.subject = subject; + this.loginId = loginId; + this.password = password; + this.name = name; + this.email = email; + this.address = address; + this.phoneNumber = phoneNumber; + this.code = code; + } + + + public UserEntity( + String subject, String loginId, String password, String name, + String email, Address address, String phoneNumber, String code, + String givenName, String familyName, String middleName, + String nickName, String profile, String picture, String website, + String gender, String zoneinfo, String locale, + String preferredUsername, String birthdate, Date updatedAt) { this.subject = subject; this.loginId = loginId; @@ -85,6 +149,95 @@ public UserEntity( this.email = email; this.address = address; this.phoneNumber = phoneNumber; + this.code = code; + this.givenName = givenName; + this.familyName = familyName; + this.middleName = middleName; + this.nickName = nickName; + this.profile = profile; + this.picture = picture; + this.website = website; + this.gender = gender; + this.zoneinfo = zoneinfo; + this.locale = locale; + this.preferredUsername = preferredUsername; + this.birthdate = birthdate; + this.updatedAt = updatedAt; + } + + + public UserEntity(UserInfo userInfo) + { + if (userInfo == null) + { + return; + } + + this.subject = userInfo.getSubject().getValue(); + this.loginId = null; + this.password = null; + this.name = userInfo.getName(); + this.email = userInfo.getEmailAddress(); + this.address = extractAddress(userInfo); + this.phoneNumber = userInfo.getPhoneNumber(); + this.code = null; + this.givenName = userInfo.getGivenName(); + this.familyName = userInfo.getFamilyName(); + this.middleName = userInfo.getMiddleName(); + this.nickName = userInfo.getNickname(); + this.profile = toString(userInfo.getProfile()); + this.picture = toString(userInfo.getPicture()); + this.website = toString(userInfo.getWebsite()); + this.gender = extractGender(userInfo); + this.zoneinfo = userInfo.getZoneinfo(); + this.locale = userInfo.getLocale(); + this.preferredUsername = userInfo.getPreferredUsername(); + this.birthdate = userInfo.getBirthdate(); + this.updatedAt = userInfo.getUpdatedTime(); + } + + + private static Address extractAddress(UserInfo userInfo) + { + com.nimbusds.openid.connect.sdk.claims.Address addr = userInfo.getAddress(); + + if (addr == null) + { + return null; + } + + return new Address() + .setCountry(addr.getCountry()) + .setFormatted(addr.getFormatted()) + .setLocality(addr.getLocality()) + .setPostalCode(addr.getPostalCode()) + .setRegion(addr.getRegion()) + .setStreetAddress(addr.getStreetAddress()) + ; + } + + + private static String extractGender(UserInfo userInfo) + { + Gender gender = userInfo.getGender(); + + if (gender == null) + { + return null; + } + + return gender.getValue(); + } + + + private static String toString(URI uri) + { + if (uri == null) + { + return null; + } + + return uri.toString(); } @@ -119,6 +272,14 @@ public String getSubject() } + public UserEntity setSubject(String subject) + { + this.subject = subject; + + return this; + } + + @Override public Object getClaim(String claimName, String languageTag) { @@ -143,16 +304,136 @@ public Object getClaim(String claimName, String languageTag) case StandardClaims.ADDRESS: // "address" claim. This claim can be requested by including "address" // in "scope" parameter of an authorization request. - return address; + return toMap(address); case StandardClaims.PHONE_NUMBER: - // "phone_number" claim. This claim can be requested by included "phone" + // "phone_number" claim. This claim can be requested by including "phone" // in "scope" parameter of an authorization request. return phoneNumber; + case StandardClaims.PHONE_NUMBER_VERIFIED: + return phoneNumberVerified; + + case StandardClaims.EMAIL_VERIFIED: + return emailVerified; + + case StandardClaims.BIRTHDATE: + return birthdate; + + case StandardClaims.GIVEN_NAME: + return givenName; + + case StandardClaims.FAMILY_NAME: + return familyName; + + case StandardClaims.MIDDLE_NAME: + return middleName; + + case StandardClaims.NICKNAME: + return nickName; + + case StandardClaims.PROFILE: + return profile; + + case StandardClaims.PICTURE: + return picture; + + case StandardClaims.WEBSITE: + return website; + + case StandardClaims.GENDER: + return gender; + + case StandardClaims.ZONEINFO: + return zoneinfo; + + case StandardClaims.LOCALE: + return locale; + + case StandardClaims.UPDATED_AT: + return updatedAt.getTime() / 1000l; + + case StandardClaims.PREFERRED_USERNAME: + return preferredUsername; + + case "nationalities": + return nationalities; + default: - // Unsupported claim. - return null; + break; + } + + if (extraClaims.containsKey(claimName)) + { + return extraClaims.get(claimName); + } + + // Unsupported claim. + return null; + } + + + @Override + public Object getAttribute(String attributeName) + { + if (attributeName == null) + { + return null; } + + switch (attributeName) + { + case "code": + // The code of the user. + return code; + + default: + return attributes.get(attributeName); + } + } + + + public UserEntity setAttribute(String attributeName, Object attributeValue) + { + attributes.put(attributeName, attributeValue); + + return this; + } + + + public UserEntity addExtraClaim(String claimName, Object claimValue) + { + extraClaims.put(claimName, claimValue); + + return this; + } + + + public List getNationalities() + { + return nationalities; + } + + + public UserEntity setNationalities(List nationalities) + { + this.nationalities = nationalities; + + return this; + } + + + @SuppressWarnings("unchecked") + private static Map toMap(Address address) + { + if (address == null) + { + return null; + } + + // This Gson instance does not serialize properties with null values. + Gson gson = new Gson(); + + return gson.fromJson(gson.toJson(address), Map.class); } } diff --git a/src/main/java/com/authlete/jaxrs/server/db/VerifiedClaimsDao.java b/src/main/java/com/authlete/jaxrs/server/db/VerifiedClaimsDao.java new file mode 100644 index 0000000..e9529ed --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/db/VerifiedClaimsDao.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2019-2020 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.db; + + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.authlete.common.assurance.Claims; +import com.authlete.common.assurance.Document; +import com.authlete.common.assurance.IDDocument; +import com.authlete.common.assurance.Issuer; +import com.authlete.common.assurance.Verification; +import com.authlete.common.assurance.VerifiedClaims; +import com.authlete.common.assurance.constraint.VerifiedClaimsConstraint; + + +/** + * Operations to access the database of verified claims. + */ +public class VerifiedClaimsDao +{ + // Dummy database for verified claims. Keys are end-user subjects. + private static final Map sVerifiedClaimsDB = + buildVerifiedClaimsDB(); + + + private static Map buildVerifiedClaimsDB() + { + Map db = new HashMap(); + + setupVerifiedClaimsDB(db); + + return db; + } + + + private static void setupVerifiedClaimsDB(Map db) + { + db.put("1003", new VerifiedClaims() + .setVerification(new Verification() + .setTrustFramework("de_aml") + .setTime("2012-04-23T18:25:43+01") + .setVerificationProcess("676q3636461467647q8498785747q487") + .addEvidence(new IDDocument() + .setMethod("pipp") + .setDocument(new Document() + .setType("idcard") + .setIssuer(new Issuer() + .setName("Stadt Augsburg") + .setCountry("DE") + ) + .setNumber("53554554") + .setDateOfIssuance("2012-04-23") + .setDateOfExpiry("2022-04-22") + ) + ) + ) + .setClaims(new Claims() + .putClaim("given_name","Max") + .putClaim("family_name", "Meier") + .putClaim("birthdate", "1956-01-28") + .putClaim("nationalities", Arrays.asList("USA", "DEU")) + ) + ); + } + + + public static List get(String subject, VerifiedClaimsConstraint constraint) + { + // NOTE: + // Commercial implementations should have complex logic to construct + // verified claims based on the constraint. + VerifiedClaims vc = sVerifiedClaimsDB.get(subject); + + return Arrays.asList(vc); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/decorator/DecoratorPriorities.java b/src/main/java/com/authlete/jaxrs/server/decorator/DecoratorPriorities.java new file mode 100644 index 0000000..12a8772 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/decorator/DecoratorPriorities.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.decorator; + + +/** + * Filter and interceptor priorities that are used as a parameter of + * the {@link jakarta.annotation.Priority Priority} annotation. + * + * @see jakarta.annotation.Priority + * @see jakarta.ws.rs.Priorities + */ +public class DecoratorPriorities +{ + /* + * Priorities for ContainerResponseFilter implementations. + * + *

+ * The smaller the priority, the later the filter is executed. + *

+ */ + public static final int FAPI_INTERACTION_ID_RESPONSE_FILTER = 40200; +} diff --git a/src/main/java/com/authlete/jaxrs/server/decorator/FapiInteractionIdResponseFilter.java b/src/main/java/com/authlete/jaxrs/server/decorator/FapiInteractionIdResponseFilter.java new file mode 100644 index 0000000..ba23c93 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/decorator/FapiInteractionIdResponseFilter.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.decorator; + + +import java.io.IOException; +import java.util.UUID; +import jakarta.annotation.Priority; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerResponseContext; +import jakarta.ws.rs.container.ContainerResponseFilter; +import jakarta.ws.rs.ext.Provider; +import com.authlete.jaxrs.server.http.CustomHttpHeaders; +import com.authlete.jaxrs.server.http.RequestUtility; + + +/** + * A filter to add the {@code x-fapi-interaction-id} HTTP header to the HTTP + * response. + * + *

+ * When the HTTP request contains the {@code x-fapi-interaction-id} HTTP header, + * the same value is used. Otherwise, a random UUID is generated and used as the + * value of the {@code x-fapi-interaction-id} HTTP header of the HTTP response. + *

+ * + * @see + * FAPI 2.0 Implementation Advice + */ +@Provider +@Priority(DecoratorPriorities.FAPI_INTERACTION_ID_RESPONSE_FILTER) +public class FapiInteractionIdResponseFilter implements ContainerResponseFilter +{ + @Override + public void filter( + ContainerRequestContext requestContext, + ContainerResponseContext responseContext) throws IOException + { + // If the response already contains the x-fapi-interaction-id HTTP header + // (e.g., set by an endpoint such as the OBB endpoints), do nothing. + if (responseContext.getHeaders().containsKey(CustomHttpHeaders.X_FAPI_INTERACTION_ID)) + { + return; + } + + // The value of the x-fapi-interaction-id HTTP header in the HTTP request. + String interactionId = RequestUtility.extractFapiInteractionId(requestContext); + + // If the request does not contain the x-fapi-interaction-id HTTP header. + if (interactionId == null) + { + // Generate a random x-fapi-interaction-id. + interactionId = generateInteractionId(); + } + + // Add the x-fapi-interaction-id HTTP header to the HTTP response. + // + // Note that even if the value of the x-fapi-interaction-id HTTP header + // in the HTTP request is malformed, the malformed value is used as is. + responseContext.getHeaders().add( + CustomHttpHeaders.X_FAPI_INTERACTION_ID, interactionId); + } + + + private static final String generateInteractionId() + { + return UUID.randomUUID().toString(); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/federation/ClientConfig.java b/src/main/java/com/authlete/jaxrs/server/federation/ClientConfig.java new file mode 100644 index 0000000..a3fadc2 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/federation/ClientConfig.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2022 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.federation; + + +import static com.authlete.jaxrs.server.federation.ConfigValidationHelper.ensureNotEmpty; +import static com.authlete.jaxrs.server.federation.ConfigValidationHelper.ensureUri; +import java.io.Serializable; + + +/** + * Client configuration for ID federation. + * + *
+ * {
+ *     "clientId": "(client ID issued by the OpenID Provider)",
+ *     "clientSecret": "(client secret issued by the OpenID Provider)",
+ *     "redirectUri": "(redirect URI registered to the OpenID Provider)",
+ *     "idTokenSignedResponseAlg": "(algorithm of ID Token signature)"
+ * }
+ * 
+ * + *

+ * {@code "clientId"} is the client ID issued to your client application by + * the OpenID Provider. + *

+ * + *

+ * If {@code "clientSecret"} is set, token requests made by {@link Federation} + * will include an {@code Authorization} header for client authentication. + * This behavior assumes that the token endpoint of the OpenID Provider + * supports {@code client_secret_basic} as a method of client authentication. + *

+ * + *

+ * {@code "redirectUri"} must be a redirect URI that you have registered into + * the OpenID Provider. For example, + * http://localhost:8080/api/federation/callback/okta. + *

+ * + *

+ * If {@code "idTokenSignedResponseAlg"} is omitted, {@code "RS256"} is used + * as the default value. See technical documents of the OpenID Provider + * about the actual algorithm it uses for signing ID tokens. + *

+ * + * @see FederationConfig + */ +public class ClientConfig implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private String clientId; + private String clientSecret; + private String redirectUri; + private String idTokenSignedResponseAlg; + + + public String getClientId() + { + return clientId; + } + + + public ClientConfig setClientId(String clientId) + { + this.clientId = clientId; + + return this; + } + + + public String getClientSecret() + { + return clientSecret; + } + + + public ClientConfig setClientSecret(String clientSecret) + { + this.clientSecret = clientSecret; + + return this; + } + + + public String getRedirectUri() + { + return redirectUri; + } + + + public ClientConfig setRedirectUri(String redirectUri) + { + this.redirectUri = redirectUri; + + return this; + } + + + public String getIdTokenSignedResponseAlg() + { + return idTokenSignedResponseAlg; + } + + + public ClientConfig setIdTokenSignedResponseAlg(String idTokenSignedResponseAlg) + { + this.idTokenSignedResponseAlg = idTokenSignedResponseAlg; + + return this; + } + + + public void validate() throws IllegalStateException + { + ensureNotEmpty("client/clientId", clientId); + ensureNotEmpty("client/redirectUri", redirectUri); + ensureUri("client/redirectUri", redirectUri); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/federation/ConfigValidationHelper.java b/src/main/java/com/authlete/jaxrs/server/federation/ConfigValidationHelper.java new file mode 100644 index 0000000..9a48605 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/federation/ConfigValidationHelper.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2022 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.federation; + + +import java.net.URI; +import java.net.URISyntaxException; +import java.text.MessageFormat; + + +/** + * Helper class for validation on configuration of ID federations. + */ +class ConfigValidationHelper +{ + public static IllegalStateException illegalState(String format, Object... arguments) + { + String message = MessageFormat.format(format, arguments); + + return new IllegalStateException(message); + } + + + public static IllegalStateException lack(String key) + { + return illegalState("The ID federation configuration lacks ''{0}'' or its value is empty.", key); + } + + + public static void ensureNotEmpty(String key, Object value) throws IllegalStateException + { + if (value == null) + { + throw lack(key); + } + } + + + public static void ensureNotEmpty(String key, String value) throws IllegalStateException + { + if (value == null || value.isEmpty()) + { + throw lack(key); + } + } + + + public static void ensureNotEmpty(String key, T[] array) throws IllegalStateException + { + if (array == null || array.length == 0) + { + throw lack(key); + } + } + + + public static void ensureUri(String key, String value) throws IllegalStateException + { + try + { + new URI(value); + } + catch (URISyntaxException e) + { + throw illegalState("The value of ''{0}'' in the ID federation configuration is malformed: {1}", key, value); + } + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/federation/Federation.java b/src/main/java/com/authlete/jaxrs/server/federation/Federation.java new file mode 100644 index 0000000..17d6330 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/federation/Federation.java @@ -0,0 +1,810 @@ +/* + * Copyright (C) 2022 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.federation; + + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.text.MessageFormat; +import java.util.function.Function; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jwt.JWT; +import com.nimbusds.oauth2.sdk.AbstractRequest; +import com.nimbusds.oauth2.sdk.AuthorizationCode; +import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant; +import com.nimbusds.oauth2.sdk.AuthorizationGrant; +import com.nimbusds.oauth2.sdk.ErrorObject; +import com.nimbusds.oauth2.sdk.ErrorResponse; +import com.nimbusds.oauth2.sdk.ParseException; +import com.nimbusds.oauth2.sdk.ResponseType; +import com.nimbusds.oauth2.sdk.Scope; +import com.nimbusds.oauth2.sdk.TokenRequest; +import com.nimbusds.oauth2.sdk.TokenResponse; +import com.nimbusds.oauth2.sdk.auth.ClientAuthentication; +import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; +import com.nimbusds.oauth2.sdk.auth.Secret; +import com.nimbusds.oauth2.sdk.http.HTTPResponse; +import com.nimbusds.oauth2.sdk.id.ClientID; +import com.nimbusds.oauth2.sdk.id.Issuer; +import com.nimbusds.oauth2.sdk.id.State; +import com.nimbusds.oauth2.sdk.id.Subject; +import com.nimbusds.oauth2.sdk.pkce.CodeChallengeMethod; +import com.nimbusds.oauth2.sdk.pkce.CodeVerifier; +import com.nimbusds.oauth2.sdk.token.AccessToken; +import com.nimbusds.openid.connect.sdk.AuthenticationErrorResponse; +import com.nimbusds.openid.connect.sdk.AuthenticationRequest; +import com.nimbusds.openid.connect.sdk.AuthenticationResponse; +import com.nimbusds.openid.connect.sdk.AuthenticationResponseParser; +import com.nimbusds.openid.connect.sdk.OIDCScopeValue; +import com.nimbusds.openid.connect.sdk.OIDCTokenResponse; +import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser; +import com.nimbusds.openid.connect.sdk.UserInfoRequest; +import com.nimbusds.openid.connect.sdk.UserInfoResponse; +import com.nimbusds.openid.connect.sdk.claims.IDTokenClaimsSet; +import com.nimbusds.openid.connect.sdk.claims.UserInfo; +import com.nimbusds.openid.connect.sdk.op.OIDCProviderConfigurationRequest; +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; +import com.nimbusds.openid.connect.sdk.validators.IDTokenValidator; + + +/** + * Utility for ID federation. + * + *

+ * This utility class provides public methods for ID federation. The discovery + * document and the JWK set document of the OpenID Provider will be cached + * in an instance of this class. + *

+ * + * @see FederationConfig + */ +public class Federation +{ + private interface ResponseParser + { + T parse(HTTPResponse response) throws ParseException; + } + + private static final boolean REQUIRED = true; + private static final boolean OPTIONAL = false; + + + private final FederationConfig config; + private final Logger logger; + private OIDCProviderMetadata serverMetadata; + private IDTokenValidator idTokenValidator; + + + public Federation(FederationConfig config) + { + this.config = config; + this.logger = LoggerFactory.getLogger(getClass()); + } + + + //------------------------------------------------------------ + // Federation Configuration + //------------------------------------------------------------ + + + public FederationConfig getConfiguration() + { + return config; + } + + + private T fromFederationConfig( + String path, boolean required, + Function func) throws IOException + { + T value = null; + + try + { + value = func.apply(config); + } + catch (Exception e) + { + } + + if (value == null && required) + { + throw ioexception("''{0}'' is not found in the federation configuration.", path); + } + + return value; + } + + + private Issuer issuer() throws IOException + { + String value = fromFederationConfig( + "server/issuer", REQUIRED, + conf -> conf.getServer().getIssuer()); + + return new Issuer(value); + } + + + private ClientID clientId() throws IOException + { + String value = fromFederationConfig( + "client/clientId", REQUIRED, + conf -> conf.getClient().getClientId()); + + return new ClientID(value); + } + + + private Secret clientSecret() throws IOException + { + String value = fromFederationConfig( + "client/clientSecret", OPTIONAL, + conf -> conf.getClient().getClientSecret()); + + if (value == null) + { + return null; + } + + return new Secret(value); + } + + + private URI redirectUri() throws IOException + { + String value = fromFederationConfig( + "client/redirectUri", REQUIRED, + conf -> conf.getClient().getRedirectUri()); + + try + { + return new URI(value); + } + catch (URISyntaxException e) + { + throw ioexception(e, "The value of ''client/redirectUri'' is malformed: {0}", value); + } + } + + + private JWSAlgorithm idTokenSignedResponseAlg() throws IOException + { + String value = fromFederationConfig( + "client/idTokenSignedResponseAlg", OPTIONAL, + conf -> conf.getClient().getIdTokenSignedResponseAlg()); + + if (value == null) + { + return null; + } + + return JWSAlgorithm.parse(value); + } + + + //------------------------------------------------------------ + // Server Metadata + //------------------------------------------------------------ + + + private OIDCProviderMetadata getServerMetadata() throws IOException + { + // If the server metadata is not cached. + if (serverMetadata == null) + { + // Fetch the server metadata from the discovery endpoint. + serverMetadata = fetchServerMetadata(); + } + + return serverMetadata; + } + + + private OIDCProviderMetadata fetchServerMetadata() throws IOException + { + // The issuer identifier of the OpenID provider. + Issuer issuer = issuer(); + + // Prepare a request to the discovery endpoint. + OIDCProviderConfigurationRequest request = + new OIDCProviderConfigurationRequest(issuer); + + // Send the request and receive a response. + HTTPResponse response = sendRequest(request); + + // Parse the response. + OIDCProviderMetadata metadata = parseResponse(request, response, + res -> OIDCProviderMetadata.parse(res.getContentAsJSONObject())); + + // Validate the discovery document. + validateDiscoveryDocument(issuer, metadata); + + return metadata; + } + + + private void validateDiscoveryDocument( + Issuer issuer, OIDCProviderMetadata metadata) throws IOException + { + if (!issuer.equals(metadata.getIssuer())) + { + // 'issuer' in the discovery document is wrong. + throw ioexception( + "''issuer'' in the discovery document is wrong: expected={0}, actual={1}", + issuer, metadata.getIssuer()); + } + } + + + private T fromServerMetadata( + String path, boolean required, + Function func) throws IOException + { + OIDCProviderMetadata metadata; + + try + { + // Get the server metadata. + metadata = getServerMetadata(); + } + catch (IOException e) + { + throw e; + } + + T value = null; + + try + { + value = func.apply(metadata); + } + catch (Exception e) + { + } + + if (value == null && required) + { + throw ioexception("''{0}'' is not found in the server metadata.", path); + } + + return value; + } + + + private URI authorizationEndpoint() throws IOException + { + return fromServerMetadata( + "authorization_endpoint", REQUIRED, + metadata -> metadata.getAuthorizationEndpointURI()); + } + + + private URI tokenEndpoint() throws IOException + { + return fromServerMetadata( + "token_endpoint", REQUIRED, + metadata -> metadata.getTokenEndpointURI()); + } + + + private URI userInfoEndpoint() throws IOException + { + return fromServerMetadata( + "userinfo_endpoint", REQUIRED, + metadata -> metadata.getUserInfoEndpointURI()); + } + + + private URI jwksUri() throws IOException + { + return fromServerMetadata( + "jwks_uri", REQUIRED, + metadata -> metadata.getJWKSetURI()); + } + + + private Boolean authorizationResponseIssParameterSupported() throws IOException + { + String key = "authorization_response_iss_parameter_supported"; + + Object value = fromServerMetadata( + key, OPTIONAL, metadata -> metadata.getCustomParameter(key)); + + if (value == null) + { + return null; + } + + return Boolean.parseBoolean(value.toString()); + } + + + //------------------------------------------------------------ + // Common Methods + //------------------------------------------------------------ + + + private HTTPResponse sendRequest(AbstractRequest request) throws IOException + { + try + { + // Send the request to the endpoint. + return request.toHTTPRequest().send(); + } + catch (IOException e) + { + // The request to the endpoint failed. + throw ioexception(e, "The request to ''{0}'' failed: {1}", + request.getEndpointURI(), e.getMessage()); + } + } + + + private T parseResponse( + AbstractRequest request, HTTPResponse response, ResponseParser func) throws IOException + { + try + { + // Parse the response. + return func.parse(response); + } + catch (ParseException e) + { + // Failed to parse the response. + throw ioexception(e, "Failed to parse the response from ''{0}'': {1}", + request.getEndpointURI(), e.getMessage()); + } + } + + + private IOException processErrorResponse(URI endpoint, ErrorResponse response) + { + ErrorObject err = response.getErrorObject(); + + // Log the error and create an IOException. + return ioexception( + "The response from ''{0}'' indicates an error: error={1}, error_description={2}", + endpoint, err.getCode(), err.getDescription()); + } + + + private IOException ioexception(String pattern, Object... arguments) + { + return ioexception((Throwable)null, pattern, arguments); + } + + + private IOException ioexception(Throwable cause, String pattern, Object... arguments) + { + String message = MessageFormat.format(pattern, arguments); + + if (cause != null) + { + logger.error(message, cause); + + return new IOException(message, cause); + } + else + { + logger.error(message); + + return new IOException(message); + } + } + + + //------------------------------------------------------------ + // Authentication Request + //------------------------------------------------------------ + + + private AuthenticationRequest buildAuthenticationRequest( + State state, CodeVerifier verifier, CodeChallengeMethod method) throws IOException + { + // The authorization endpoint of the OpenID provider. + URI endpoint = authorizationEndpoint(); + + // response_type + ResponseType responseType = new ResponseType("code"); + + // scope + Scope scope = buildAuthenticationRequestScope(); + + // client_id (from federation configuration) + ClientID clientId = clientId(); + + // redirect_uri (from federation configuration) + URI redirectUri = redirectUri(); + + // Start to build an authentication request. + AuthenticationRequest.Builder builder = + new AuthenticationRequest.Builder(responseType, scope, clientId, redirectUri) + .endpointURI(endpoint) + ; + + // state + if (state != null) + { + builder.state(state); + } + + // nonce + // + // Optional unless "response_type" includes "id_token". + // See OIDC Core 1.0 Section 3.1.2.1 & Section 3.2.2.1. + // + // But in the Financial-grade API context, "nonce" is mandatory + // when "scope" includes "openid". "response_type" does not matter. + // See FAPI 1.0 Baseline Section 5.2.2.2. + + // code_challenge & code_challenge_method + if (verifier != null && method != null) + { + // code_challenge is computed from the verifier and the method. + builder.codeChallenge(verifier, method); + } + + return builder.build(); + } + + + private static Scope buildAuthenticationRequestScope() + { + return new Scope( + OIDCScopeValue.ADDRESS, + OIDCScopeValue.EMAIL, + OIDCScopeValue.OPENID, + OIDCScopeValue.PHONE, + OIDCScopeValue.PROFILE + ); + } + + + //------------------------------------------------------------ + // Authentication Response + //------------------------------------------------------------ + + + private AuthorizationCode extractAuthorizationCode( + URI response, State state) throws IOException + { + AuthenticationResponse authenticationResponse; + + try + { + // Parse the authentication response. + authenticationResponse = AuthenticationResponseParser.parse(response); + } + catch (ParseException e) + { + throw ioexception(e, "Failed to parse the response from ''{0}'': {1}", + authorizationEndpoint(), e.getMessage()); + } + + // Validate the authentication response. + validateAuthenticationResponse(authenticationResponse, state); + + // Extract the authorization code from the response. + return authenticationResponse.toSuccessResponse().getAuthorizationCode(); + } + + + private void validateAuthenticationResponse( + AuthenticationResponse response, State state) throws IOException + { + // If the 'state' included in the authentication response is + // different from the expected state. + if (state != null && !state.equals(response.getState())) + { + throw ioexception("Unexpected authentication response."); + } + + // If "authorization_response_iss_parameter_supported" is true. + if (authorizationResponseIssParameterSupported() == Boolean.TRUE) + { + // TODO + // Confirm that the "iss" response parameter is identical to + // the issuer identifier in the discovery document. + // See "OAuth 2.0 Authorization Server Issuer Identification". + } + + // If the authentication response indicates an error. + if (response instanceof AuthenticationErrorResponse) + { + // Process the error response. + throw processErrorResponse( + authorizationEndpoint(), response.toErrorResponse()); + } + } + + + //------------------------------------------------------------ + // Token Request + //------------------------------------------------------------ + + + private OIDCTokenResponse makeTokenRequest( + AuthorizationCode code, CodeVerifier verifier) throws IOException + { + // Prepare a token request. + TokenRequest request = buildTokenRequest(code, verifier); + + // Send the request and receive a response. + HTTPResponse response = sendRequest(request); + + // Parse the response. + TokenResponse tokenResponse = parseResponse(request, response, + res -> OIDCTokenResponseParser.parse(res)); + + // If the token response indicates an error. + if (!tokenResponse.indicatesSuccess()) + { + // Process the error response. + throw processErrorResponse( + request.getEndpointURI(), tokenResponse.toErrorResponse()); + } + + return (OIDCTokenResponse)tokenResponse.toSuccessResponse(); + } + + + private TokenRequest buildTokenRequest( + AuthorizationCode code, CodeVerifier verifier) throws IOException + { + // Client credentials from the federation configuration. + // The client secret may be null. + ClientID clientId = clientId(); + Secret clientSecret = clientSecret(); + + // grant_type=authorization_code + // + // Mandatory, specifying "authorization code grant". + // + // code + // + // Mandatory in the case of grant_type=authorization_code. + // + // redirect_uri + // + // Mandatory when the authorization request included "redirect_uri". + // + // code_verifier + // + // Mandatory when the authorization request included "code_challenge". + // + AuthorizationGrant grant = new AuthorizationCodeGrant(code, redirectUri(), verifier); + + // The token endpoint of the OpenID provider. + URI endpoint = tokenEndpoint(); + + if (clientSecret != null) + { + // Client authentication at the token endpoint, assuming that + // the endpoint supports "client_secret_basic. + ClientAuthentication clientAuth = new ClientSecretBasic(clientId, clientSecret); + + // A token request with client authentication. + return new TokenRequest(endpoint, clientAuth, grant); + } + else + { + // A token request without client authentication. + return new TokenRequest(endpoint, clientId, grant); + } + } + + + //------------------------------------------------------------ + // ID Token Validation + //------------------------------------------------------------ + + + private IDTokenValidator getIdTokenValidator() throws IOException + { + // If an ID token validator is not cached. + if (idTokenValidator == null) + { + // Create an ID token validator. + idTokenValidator = createIdTokenValidator(); + } + + return idTokenValidator; + } + + + private IDTokenValidator createIdTokenValidator() throws IOException + { + // id_token_signed_response_alg from the federation configuration. + JWSAlgorithm alg = idTokenSignedResponseAlg(); + if (alg == null) + { + alg = JWSAlgorithm.RS256; + } + + // jwks_uri from the server metadata. + URL jwksLocation = jwksUri().toURL(); + + // From "How to validate an OpenID Connect ID token" + // https://connect2id.com/blog/how-to-validate-an-openid-connect-id-token + // + // This ID token validator will automatically download the JWK set + // from the IdP and cache the keys to speed up processing. OpenID + // Providers may rotate keys (Google does it once per day), which + // will be detected by the validator, so you don't have to worry + // about this. + // + return new IDTokenValidator(issuer(), clientId(), alg, jwksLocation); + } + + + private IDTokenClaimsSet validateIdToken(JWT idToken) throws IOException + { + // ID token validator + IDTokenValidator validator = getIdTokenValidator(); + + try + { + // Validate the ID token. + return validator.validate(idToken, null); + } + catch (BadJOSEException e) + { + // Invalid signature or claims (iss, aud, exp...) + throw ioexception(e, "The ID token issued from ''{0}'' is invalid: {1}", + issuer(), e.getMessage()); + } + catch (JOSEException e) + { + // Internal processing exception + throw ioexception(e, "Failed to validate the ID token issued from ''{0}'': {1}", + issuer(), e.getMessage()); + } + } + + + //------------------------------------------------------------ + // UserInfo Request + //------------------------------------------------------------ + + + private UserInfo makeUserInfoRequest(AccessToken accessToken) throws IOException + { + // Prepare a userinfo request. + UserInfoRequest request = new UserInfoRequest(userInfoEndpoint(), accessToken); + + // Send the request and receive a response. + HTTPResponse response = sendRequest(request); + + // Parse the response. + UserInfoResponse userInfoResponse = parseResponse(request, response, + res -> UserInfoResponse.parse(response)); + + // If the userinfo response indicates an error. + if (!userInfoResponse.indicatesSuccess()) + { + // Process the error response. + throw processErrorResponse( + request.getEndpointURI(), userInfoResponse.toErrorResponse()); + } + + // Extract the user information from the response. + return userInfoResponse.toSuccessResponse().getUserInfo(); + } + + + private void validateUserInfo(UserInfo userInfo, Subject expectedSubject) throws IOException + { + // OpenID Connect Core 1.0, 5.3.2. Successful UserInfo Response + // + // NOTE: Due to the possibility of token substitution attacks + // (see Section 16.11), the UserInfo Response is not guaranteed + // to be about the End-User identified by the sub (subject) + // element of the ID Token. The sub Claim in the UserInfo + // Response MUST be verified to exactly match the sub Claim in + // the ID Token; if they do not match, the UserInfo Response + // values MUST NOT be used. + + // If the subject in the ID token does not match the subject in + // the userinfo response. + if (!expectedSubject.equals(userInfo.getSubject())) + { + throw ioexception( + "The subject in the ID token does not match the subject in the userinfo response."); + } + } + + + //------------------------------------------------------------ + // Federation Flow + //------------------------------------------------------------ + + + /** + * Create an authentication request that is to be sent to the authorization + * endpoint of the OpenID Provider. + */ + public URI createFederationRequest( + String state, String codeVerifier) throws IOException + { + // state + State st = (state != null) ? new State(state) : null; + + // Code verifier that is to be used to calculate code_challenge. + CodeVerifier verifier = (codeVerifier != null) + ? new CodeVerifier(codeVerifier) : null; + + // code_challenge_method + CodeChallengeMethod method = (verifier != null) + ? CodeChallengeMethod.S256 : null; + + // Create an authentication request that is to be sent to + // the authorization endpoint. + AuthenticationRequest request = + buildAuthenticationRequest(st, verifier, method); + + return request.toURI(); + } + + + /** + * Process the authentication response from the authorization endpoint of + * the OpenID Provider and retrieve user information from the userinfo + * endpoint of the OpenID Provider. + */ + public UserInfo processFederationResponse( + URI authenticationResponse, String state, String codeVerifier) throws IOException + { + // state + State st = (state != null) ? new State(state) : null; + + // code_verifier + CodeVerifier verifier = (codeVerifier != null) + ? new CodeVerifier(codeVerifier) : null; + + // Extract the authorization code from the authentication response. + AuthorizationCode authorizationCode = + extractAuthorizationCode(authenticationResponse, st); + + // Send a token request to the token endpoint and receive a response. + OIDCTokenResponse tokenResponse = + makeTokenRequest(authorizationCode, verifier); + + // ID token issued from the token endpoint. + JWT idToken = tokenResponse.getOIDCTokens().getIDToken(); + + // Validate the ID token. + IDTokenClaimsSet idTokenClaims = validateIdToken(idToken); + + // Access token issued from the token endpoint. + AccessToken accessToken = tokenResponse.getOIDCTokens().getAccessToken(); + + // Send a request to the userinfo endpoint and receive a response. + UserInfo userInfo = makeUserInfoRequest(accessToken); + + // Validate the userinfo. + validateUserInfo(userInfo, idTokenClaims.getSubject()); + + // User information obtained from the OpenID Provider. + return userInfo; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/federation/FederationConfig.java b/src/main/java/com/authlete/jaxrs/server/federation/FederationConfig.java new file mode 100644 index 0000000..5e60b7f --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/federation/FederationConfig.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2022 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.federation; + + +import static com.authlete.jaxrs.server.federation.ConfigValidationHelper.ensureNotEmpty; +import java.io.Serializable; + + +/** + * Configuration of ID federation. + * + *
+ * {
+ *     "id": "(unique identifier among the configurations)",
+ *     "server": {
+ *         (mapped to {@link ServerConfig})
+ *     },
+ *     "client": {
+ *         (mapped to {@link ClientConfig})
+ *     }
+ * }
+ * 
+ * + *

+ * The value of {@code "id"} is used as federationId + * in the following API paths. + *

+ * + *
    + *
  • /api/federation/initiation/federationId + *
  • /api/federation/callback/federationId + *
+ * + * @see FederationsConfig + */ +public class FederationConfig implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private String id; + private ServerConfig server; + private ClientConfig client; + + + public String getId() + { + return id; + } + + + public FederationConfig setId(String id) + { + this.id = id; + + return this; + } + + + public ServerConfig getServer() + { + return server; + } + + + public FederationConfig setServer(ServerConfig server) + { + this.server = server; + + return this; + } + + + public ClientConfig getClient() + { + return client; + } + + + public FederationConfig setClient(ClientConfig client) + { + this.client = client; + + return this; + } + + + public void validate() throws IllegalStateException + { + ensureNotEmpty("id", id); + ensureNotEmpty("server", server); + ensureNotEmpty("client", client); + + server.validate(); + client.validate(); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/federation/FederationManager.java b/src/main/java/com/authlete/jaxrs/server/federation/FederationManager.java new file mode 100644 index 0000000..22a7a12 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/federation/FederationManager.java @@ -0,0 +1,187 @@ +/* + * Copyright (C) 2022 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.federation; + + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + +/** + * Manager for ID federation. + * + *

+ * This manager loads configurations of ID federations from a file using + * {@link FederationsConfigLoader} and creates {@link Federation} instances + * for the configurations. The loaded configurations and created instances + * are cached for later use. + *

+ */ +public class FederationManager +{ + private static class Holder + { + private static final FederationManager INSTANCE = new FederationManager(); + } + + + private final Logger logger = LoggerFactory.getLogger(getClass()); + private final FederationConfig[] mConfigurations; + private final Map mFederations; + + + private FederationManager() + { + // Load configurations of ID federations from a file. + mConfigurations = loadConfigurations(); + + // Create Federation instances for the configurations. + mFederations = buildFederations(mConfigurations); + } + + + private FederationConfig[] loadConfigurations() + { + FederationsConfig config; + + try + { + // Load configurations of ID federations from a file. + // The default location of the file is "federations.json". + config = FederationsConfigLoader.load(); + } + catch (Exception e) + { + logger.warn("Failed to load configurations of ID federations: " + e.getMessage()); + return null; + } + + // "federations" in the configuration file. + FederationConfig[] configs = config.getFederations(); + + if (configs == null || configs.length == 0) + { + // If the configuration does not include "federations" or + // its value is an empty array. + logger.warn("The configuration of ID federations does not include 'federations' or its value is empty."); + return null; + } + + List validConfigs = new ArrayList<>(); + + // For each entry in the "federations". + for (int i = 0; i < configs.length; ++i) + { + FederationConfig cf = configs[i]; + + // If the configuration at the index is invalid. + if (!isConfigurationValid(cf, i)) + { + // Skip the entry in the configuration of ID federations. + continue; + } + + validConfigs.add(cf); + } + + if (validConfigs.size() == 0) + { + // No valid configuration in the "federations". + return null; + } + + return validConfigs.toArray(new FederationConfig[validConfigs.size()]); + } + + + private boolean isConfigurationValid(FederationConfig config, int index) + { + if (config == null) + { + logger.warn("The entry at the index {} in the configuration of ID federations is empty.", index); + return false; + } + + try + { + // Validate the content of the configuration. + config.validate(); + } + catch (Exception e) + { + logger.warn("The entry at the index {} in the configuration of ID federations is invalid: {}", index, e.getMessage()); + return false; + } + + return true; + } + + + private Map buildFederations(FederationConfig[] configs) + { + if (configs == null) + { + return null; + } + + Map federations = new HashMap<>(); + + // For each entry in the "federations". + for (FederationConfig config : configs) + { + // Create a Federation instance using the configuration. + // The instance provides public methods for ID federation. + // The discovery document and the JWK set document of the + // OpenID provider will be cached in the instance. + Federation federation = new Federation(config); + + // Register the Federation instance for later use. + federations.put(config.getId(), federation); + + logger.info("An ID federation configuration was loaded: id={}, issuer={}", + config.getId(), config.getServer().getIssuer()); + } + + return federations; + } + + + public static FederationManager getInstance() + { + return Holder.INSTANCE; + } + + + public FederationConfig[] getConfigurations() + { + return mConfigurations; + } + + + public Federation getFederation(String federationId) + { + if (mFederations == null || federationId == null) + { + return null; + } + + return mFederations.get(federationId); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/federation/FederationsConfig.java b/src/main/java/com/authlete/jaxrs/server/federation/FederationsConfig.java new file mode 100644 index 0000000..b52ab30 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/federation/FederationsConfig.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2022 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.federation; + + +import java.io.Serializable; + + +/** + * Configuration of ID federations. + * + *
+ * {
+ *     "federations": [
+ *         (each element is mapped to {@link FederationConfig})
+ *     ]
+ * }
+ * 
+ * + * @see FederationsConfigLoader + */ +public class FederationsConfig implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private FederationConfig[] federations; + + + public FederationConfig[] getFederations() + { + return federations; + } + + + public FederationsConfig setFederations(FederationConfig[] federations) + { + this.federations = federations; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/federation/FederationsConfigLoader.java b/src/main/java/com/authlete/jaxrs/server/federation/FederationsConfigLoader.java new file mode 100644 index 0000000..314886f --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/federation/FederationsConfigLoader.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2022 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.federation; + + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import com.google.gson.Gson; + + +/** + * Loader for configuration of ID federations. + * + *

+ * This loader loads configuration from a file. When the name of a configuration + * file is not explicitly specified, in other words, when {@link #load()} method + * is used, the file name is determined in the following order. + *

+ * + *
    + *
  1. Environment variable, {@code FEDERATIONS_FILE} + *
  2. System property, {@code federations.file} + *
  3. The default file name, {@code "federations.json"} + *
+ * + *

+ * The content of the configuration file should be a JSON object that contains + * {@code "federations"} as a top-level property. + *

+ * + *
+ * {
+ *     "federations": [
+ *         (each element is mapped to {@link FederationConfig})
+ *     ]
+ * }
+ * 
+ * + * @see FederationsConfig + */ +public class FederationsConfigLoader +{ + private static final String DEFAULT_FILE = "federations.json"; + private static final String SYSPROP_FILE = "federations.file"; + private static final String ENVVAR_FILE = "FEDERATIONS_FILE"; + + + private static String determineFile() + { + // From the environment variable. + String file = getFileFromEnv(); + + if (file == null) + { + // From the system property. + file = getFileFromSysProp(); + } + + if (file == null) + { + // The default file. + file = DEFAULT_FILE; + } + + return file; + } + + + private static String getFileFromEnv() + { + return System.getenv(ENVVAR_FILE); + } + + + private static String getFileFromSysProp() + { + return System.getProperty(SYSPROP_FILE); + } + + + public static FederationsConfig load() throws IOException + { + return load(determineFile()); + } + + + public static FederationsConfig load(String file) throws IOException + { + return load(Paths.get(file)); + } + + + public static FederationsConfig load(Path path) throws IOException + { + try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) + { + return load(reader); + } + } + + + public static FederationsConfig load(Reader reader) throws IOException + { + return new Gson().fromJson(reader, FederationsConfig.class); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/federation/ServerConfig.java b/src/main/java/com/authlete/jaxrs/server/federation/ServerConfig.java new file mode 100644 index 0000000..72808ad --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/federation/ServerConfig.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2022 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.federation; + + +import static com.authlete.jaxrs.server.federation.ConfigValidationHelper.ensureNotEmpty; +import static com.authlete.jaxrs.server.federation.ConfigValidationHelper.ensureUri; +import java.io.Serializable; + + +/** + * Server configuration for ID federation. + * + *
+ * {
+ *     "name": "(display name of the OpenID Provider)",
+ *     "issuer": "(issuer identifier of the OpenID Provider)"
+ * }
+ * 
+ * + *

+ * The value of {@code "issuer"} must match the value of {@code "issuer"} + * in the discovery document of the OpenID Provider. The OpenID Provider + * must expose its discovery document at + * {issuer}/.well-known/openid-configuration. + *

+ * + * @see FederationConfig + */ +public class ServerConfig implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private String name; + private String issuer; + + + public String getName() + { + return name; + } + + + public ServerConfig setName(String name) + { + this.name = name; + + return this; + } + + + public String getIssuer() + { + return issuer; + } + + + public ServerConfig setIssuer(String issuer) + { + this.issuer = issuer; + + return this; + } + + + public void validate() throws IllegalStateException + { + ensureNotEmpty("server/name", name); + ensureNotEmpty("server/issuer", issuer); + ensureUri("server/issuer", issuer); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/http/CustomHttpHeaders.java b/src/main/java/com/authlete/jaxrs/server/http/CustomHttpHeaders.java new file mode 100644 index 0000000..298e96d --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/http/CustomHttpHeaders.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.http; + + +/** + * Custom HTTP headers. + */ +public class CustomHttpHeaders +{ + /** + * The FAPI Interaction ID ({@code x-fapi-interaction-id}) + * + * @see + * FAPI 1.0 Baseline + * + * @see + * FAPI 2.0 Implementation Advice + */ + public static final String X_FAPI_INTERACTION_ID = "x-fapi-interaction-id"; +} diff --git a/src/main/java/com/authlete/jaxrs/server/http/RequestUtility.java b/src/main/java/com/authlete/jaxrs/server/http/RequestUtility.java new file mode 100644 index 0000000..80b806c --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/http/RequestUtility.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.http; + + +import jakarta.ws.rs.container.ContainerRequestContext; + + +public class RequestUtility +{ + /** + * Extract the value of the {@code x-fapi-interaction-id} header from the + * given {@link ContainerRequestContext} instance. + */ + public static String extractFapiInteractionId(ContainerRequestContext ctx) + { + return ctx.getHeaderString(CustomHttpHeaders.X_FAPI_INTERACTION_ID); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/nativesso/DeviceSecret.java b/src/main/java/com/authlete/jaxrs/server/nativesso/DeviceSecret.java new file mode 100644 index 0000000..cab20f5 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/nativesso/DeviceSecret.java @@ -0,0 +1,283 @@ +/* + * Copyright (C) 2025 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.nativesso; + + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + + +/** + * Device Secret. + * + *

+ * This class represents the concept of a "Device Secret" introduced by the "OpenID Connect + * Native SSO for Mobile Apps 1.0" specification ("Native SSO"). + *

+ * + *

+ * The following is an excerpt from the specification describing the concept: + *

+ * + *
+ *

+ * The device secret contains relevant data to the device and the current users + * authenticated with the device. The device secret is completely opaque to the + * client and as such the AS MUST adequately protect the value such as using a + * JWE if the AS is not maintaining state on the backend. + *

+ *
+ * + * @see OpenID Connect Native SSO for Mobile Apps 1.0, Section 3.2. Device Secret + */ +public class DeviceSecret +{ + /** + * The value of this device secret. + */ + private String value; + + + /** + * The value of the hash of this device secret. + */ + private String hash; + + + /** + * The identifier of the user's authentication session associated with + * this device secret. + */ + private String sessionId; + + + /** + * The identifier of the device associated with this device secret. + */ + private String deviceId; + + + /** + * Get the value of this device secret. This corresponds to the value + * of the {@code device_secret} parameter in token responses. + * + * @return + * The value of this device secret. + */ + public String getValue() + { + return value; + } + + + /** + * Set the value of this device secret. This corresponds to the value + * of the {@code device_secret} parameter in token responses. + * + * @param value + * The value of this device secret. + * + * @return + * {@code this} object. + */ + public DeviceSecret setValue(String value) + { + this.value = value; + + return this; + } + + + /** + * Get the value of the hash of this device secret. This corresponds to + * the value of the {@code ds_hash} claim in the Native SSO-compliant + * ID token. + * + * @return + * The value of the hash of this device secret. + */ + public String getHash() + { + return hash; + } + + + /** + * Set the value of the hash of this device secret. This corresponds to + * the value of the {@code ds_hash} claim in the Native SSO-compliant + * ID token. + * + * @param hash + * The value of the hash of this device secret. + * + * @return + * {@code this} object. + */ + public DeviceSecret setHash(String hash) + { + this.hash = hash; + + return this; + } + + + /** + * Get the identifier of the user's authentication session associated with + * this device secret. This corresponds to the {@code sid} claim in the + * Native SSO-compliant ID token. + * + * @return + * The identifier of the user's authentication session. + */ + public String getSessionId() + { + return sessionId; + } + + + /** + * Set the identifier of the user's authentication session associated with + * this device secret. This corresponds to the {@code sid} claim in the + * Native SSO-compliant ID token. + * + * @param sessionId + * The identifier of the user's authentication session. + * + * @return + * {@code this} object. + */ + public DeviceSecret setSessionId(String sessionId) + { + this.sessionId = sessionId; + + return this; + } + + + /** + * Get the identifier of the device associated with this device secret. + * + * @return + * The identifier of the device. + */ + public String getDeviceId() + { + return deviceId; + } + + + /** + * Set the identifier of the device associated with this device secret. + * + * @param deviceId + * The identifier of the device. + * + * @return + * {@code this} object. + */ + public DeviceSecret setDeviceId(String deviceId) + { + this.deviceId = deviceId; + + return this; + } + + + /** + * Compute the hash of the specified device secret value. + * + * @param deviceSecretValue + * A device secret value. + * + * @return + * The hash of the specified device secret value. + */ + public static String computeHash(String deviceSecretValue) + { + if (deviceSecretValue == null) + { + return null; + } + + // The Native SSO specification does not define any logic for computing + // the hash from a device secret. It explicitly states as follows: + // + // The exact binding between the ds_hash and device_secret is not + // specified by this profile. As this binding is managed solely by + // the Authorization Server, the AS can choose how to protect the + // relationship between the id_token and device_secret. + // + + // The following logic is specific to this implementation. + + // BASE64URL( SHA-256( deviceSecretValue ) ) + return toBase64Url(sha256(deviceSecretValue)); + } + + + /** + * Convert the input data into a base64url string without padding. + */ + private static String toBase64Url(byte[] input) + { + return Base64.getUrlEncoder().withoutPadding().encodeToString(input); + } + + + /** + * Compute the digest of the input data with the specified hash algorithm. + */ + private static byte[] digest(String algorithm, byte[] input) throws NoSuchAlgorithmException + { + return MessageDigest.getInstance(algorithm).digest(input); + } + + + /** + * Compute the digest of the input data with the SHA-256 algorithm. + */ + private static byte[] sha256(byte[] input) + { + try + { + return digest("SHA-256", input); + } + catch (NoSuchAlgorithmException cause) + { + // This error will never happen because every Java platform + // must support "SHA-256". + throw new UnsupportedOperationException( + "This Java platform does not support 'SHA-256' for message digest: " + + cause.getMessage(), cause); + } + } + + + /** + * Compute the digest of the input data with the SHA-256 algorithm. + */ + private static byte[] sha256(String input) + { + return sha256(input.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/nativesso/DeviceSecretManager.java b/src/main/java/com/authlete/jaxrs/server/nativesso/DeviceSecretManager.java new file mode 100644 index 0000000..b77cec1 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/nativesso/DeviceSecretManager.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2025 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.nativesso; + + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + + +public class DeviceSecretManager +{ + private static final Map byValueMap = new ConcurrentHashMap<>(); + + + private DeviceSecretManager() + { + } + + + public static DeviceSecret getByValue(String deviceSecret) + { + if (deviceSecret == null) + { + return null; + } + + return byValueMap.get(deviceSecret); + } + + + public static void register(DeviceSecret ds) + { + if (ds == null) + { + return; + } + + if (ds.getValue() == null) + { + throw new IllegalArgumentException("The value of the specified DeviceSecret is null."); + } + + byValueMap.put(ds.getValue(), ds); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/database/ConsentDao.java b/src/main/java/com/authlete/jaxrs/server/obb/database/ConsentDao.java new file mode 100644 index 0000000..7da75ea --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/database/ConsentDao.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.database; + + +import java.util.UUID; +import com.authlete.jaxrs.server.obb.model.Consent; +import com.authlete.jaxrs.server.obb.model.CreateConsent; +import com.authlete.jaxrs.server.obb.model.CreateConsentData; +import com.authlete.jaxrs.server.obb.util.ObbUtils; + + +public class ConsentDao +{ + private static final ConsentDao sInstance = new ConsentDao("example"); + + + private final String mNamespace; + private final ConsentStore mStore; + + + private ConsentDao(String namespace) + { + mNamespace = namespace; + mStore = new ConsentStore(); + } + + + private String getNamespace() + { + return mNamespace; + } + + + private ConsentStore getStore() + { + return mStore; + } + + + private String generateConsentId() + { + // '^urn:[a-zA-Z0-9][a-zA-Z0-9-]{0,31}:[a-zA-Z0-9()+,\-.:=@;$_!*''%\/?#]+$' + return String.format("urn:%s:%s", getNamespace(), UUID.randomUUID()); + } + + + public synchronized Consent create(CreateConsent createConsent, long clientId) + { + CreateConsentData data = createConsent.getData(); + String consentId = generateConsentId(); + String now = ObbUtils.formatNow(); + + Consent consent = new Consent() + .setConsentId(consentId) + .setPermissions(data.getPermissions()) + .setStatus("AWAITING_AUTHORISATION") + .setCreationDateTime(now) + .setExpirationDateTime(data.getExpirationDateTime()) + .setStatusUpdateDateTime(now) + .setClientId(clientId) + ; + + getStore().put(consentId, consent); + + return consent; + } + + + public synchronized Consent read(String consentId) + { + return getStore().get(consentId); + } + + + public synchronized void update(Consent consent) + { + consent.setStatusUpdateDateTime(ObbUtils.formatNow()); + + getStore().put(consent.getConsentId(), consent); + } + + + public synchronized void delete(String consentId) + { + getStore().remove(consentId); + } + + + public static ConsentDao getInstance() + { + return sInstance; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/database/ConsentStore.java b/src/main/java/com/authlete/jaxrs/server/obb/database/ConsentStore.java new file mode 100644 index 0000000..531a5a8 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/database/ConsentStore.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.database; + + +import java.util.LinkedHashMap; +import java.util.Map.Entry; +import com.authlete.jaxrs.server.obb.model.Consent; + + +/** + * On-memory store for {@link Consent} with the replacement policy of + * LRU (Least Recently Used). Of course, not suitable for production use. + */ +public class ConsentStore extends LinkedHashMap +{ + private static final long serialVersionUID = 1L; + + + @Override + protected boolean removeEldestEntry(Entry eldest) + { + return size() > 100; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/model/AccountData.java b/src/main/java/com/authlete/jaxrs/server/obb/model/AccountData.java new file mode 100644 index 0000000..807352e --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/model/AccountData.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.model; + + +import java.io.Serializable; + + +/** + * AccountData + * + * @see AccountData + */ +public class AccountData implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private String brandName; + private String companyCnpj; + private String type; + private String compeCode; + private String branchCode; + private String number; + private String checkDigit; + private String accountId; + + + public String getBrandName() + { + return brandName; + } + + + public AccountData setBrandName(String brandName) + { + this.brandName = brandName; + + return this; + } + + + public String getCompanyCnpj() + { + return companyCnpj; + } + + + public AccountData setCompanyCnpj(String companyCnpj) + { + this.companyCnpj = companyCnpj; + + return this; + } + + + public String getType() + { + return type; + } + + + public AccountData setType(String type) + { + this.type = type; + + return this; + } + + + public String getCompeCode() + { + return compeCode; + } + + + public AccountData setCompeCode(String compeCode) + { + this.compeCode = compeCode; + + return this; + } + + + public String getBranchCode() + { + return branchCode; + } + + + public AccountData setBranchCode(String branchCode) + { + this.branchCode = branchCode; + + return this; + } + + + public String getNumber() + { + return number; + } + + + public AccountData setNumber(String number) + { + this.number = number; + + return this; + } + + + public String getCheckDigit() + { + return checkDigit; + } + + + public AccountData setCheckDigit(String checkDigit) + { + this.checkDigit = checkDigit; + + return this; + } + + + public String getAccountId() + { + return accountId; + } + + + public AccountData setAccountId(String accountId) + { + this.accountId = accountId; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/model/BusinessEntity.java b/src/main/java/com/authlete/jaxrs/server/obb/model/BusinessEntity.java new file mode 100644 index 0000000..41d8249 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/model/BusinessEntity.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.model; + + +import java.io.Serializable; + + +/** + * BusinessEntity + * + * @see BusinessEntity + */ +public class BusinessEntity implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private Document document; + + + public Document getDocument() + { + return document; + } + + + public BusinessEntity setDocument(Document document) + { + this.document = document; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/model/Consent.java b/src/main/java/com/authlete/jaxrs/server/obb/model/Consent.java new file mode 100644 index 0000000..36bbc7c --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/model/Consent.java @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.model; + + +import java.io.Serializable; + + +public class Consent implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private String consentId; + private String[] permissions; + private String status; + private String creationDateTime; + private String expirationDateTime; + private String statusUpdateDateTime; + private long clientId; + private String refreshToken; + + + public String getConsentId() + { + return consentId; + } + + + public Consent setConsentId(String consentId) + { + this.consentId = consentId; + + return this; + } + + + public String[] getPermissions() + { + return permissions; + } + + + public Consent setPermissions(String[] permissions) + { + this.permissions = permissions; + + return this; + } + + + public String getStatus() + { + return status; + } + + + public Consent setStatus(String status) + { + this.status = status; + + return this; + } + + + public String getCreationDateTime() + { + return creationDateTime; + } + + + public Consent setCreationDateTime(String creationDateTime) + { + this.creationDateTime = creationDateTime; + + return this; + } + + + public String getExpirationDateTime() + { + return expirationDateTime; + } + + + public Consent setExpirationDateTime(String expirationDateTime) + { + this.expirationDateTime = expirationDateTime; + + return this; + } + + + public String getStatusUpdateDateTime() + { + return statusUpdateDateTime; + } + + + public Consent setStatusUpdateDateTime(String statusUpdateDateTime) + { + this.statusUpdateDateTime = statusUpdateDateTime; + + return this; + } + + + public long getClientId() + { + return clientId; + } + + + public Consent setClientId(long clientId) + { + this.clientId = clientId; + + return this; + } + + + public String getRefreshToken() + { + return refreshToken; + } + + + public Consent setRefreshToken(String refreshToken) + { + this.refreshToken = refreshToken; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/model/CreateConsent.java b/src/main/java/com/authlete/jaxrs/server/obb/model/CreateConsent.java new file mode 100644 index 0000000..79a2a84 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/model/CreateConsent.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.model; + + +import java.io.Serializable; + + +/** + * CreateConsent + * + * @see CreateConsent + */ +public class CreateConsent implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private CreateConsentData data; + + + public CreateConsentData getData() + { + return data; + } + + + public CreateConsent setData(CreateConsentData data) + { + this.data = data; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/model/CreateConsentData.java b/src/main/java/com/authlete/jaxrs/server/obb/model/CreateConsentData.java new file mode 100644 index 0000000..2be122e --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/model/CreateConsentData.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.model; + + +import java.io.Serializable; + + +/** + * CreateConsent.data + * + * @see CreateConsent + */ +public class CreateConsentData implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private LoggedUser loggedUser; + private BusinessEntity businessEntity; + private String[] permissions; + private String expirationDateTime; + private String transactionFromDateTime; + private String transactionToDateTime; + + + public LoggedUser getLoggedUser() + { + return loggedUser; + } + + + public CreateConsentData setLoggedUser(LoggedUser loggedUser) + { + this.loggedUser = loggedUser; + + return this; + } + + + public BusinessEntity getBusinessEntity() + { + return businessEntity; + } + + + public CreateConsentData setBusinessEntity(BusinessEntity businessEntity) + { + this.businessEntity = businessEntity; + + return this; + } + + + public String[] getPermissions() + { + return permissions; + } + + + public CreateConsentData setPermissions(String[] permissions) + { + this.permissions = permissions; + + return this; + } + + + public String getExpirationDateTime() + { + return expirationDateTime; + } + + + public CreateConsentData setExpirationDateTime(String datetime) + { + this.expirationDateTime = datetime; + + return this; + } + + + public String getTransactionFromDateTime() + { + return transactionFromDateTime; + } + + + public CreateConsentData setTransactionFromDateTime(String datetime) + { + this.transactionFromDateTime = datetime; + + return this; + } + + + public String getTransactionToDateTime() + { + return transactionToDateTime; + } + + + public CreateConsentData setTransactionToDateTime(String datetime) + { + this.transactionToDateTime = datetime; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/model/Document.java b/src/main/java/com/authlete/jaxrs/server/obb/model/Document.java new file mode 100644 index 0000000..e046e82 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/model/Document.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.model; + + +import java.io.Serializable; + + +public class Document implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private String identification; + private String rel; + + + public String getIdentification() + { + return identification; + } + + + public Document setIdentification(String identification) + { + this.identification = identification; + + return this; + } + + + public String getRel() + { + return rel; + } + + + public Document setRel(String rel) + { + this.rel = rel; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/model/Error.java b/src/main/java/com/authlete/jaxrs/server/obb/model/Error.java new file mode 100644 index 0000000..2c7b70c --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/model/Error.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.model; + + +import java.io.Serializable; + + +public class Error implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private String code; + private String title; + private String detail; + + + public Error() + { + } + + + public Error(String code, String title, String detail) + { + this.code = code; + this.title = title; + this.detail = detail; + } + + + public String getCode() + { + return code; + } + + + public Error setCode(String code) + { + this.code = code; + + return this; + } + + + public String getTitle() + { + return title; + } + + + public Error setTitle(String title) + { + this.title = title; + + return this; + } + + + public String getDetail() + { + return detail; + } + + + public Error setDetail(String detail) + { + this.detail = detail; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/model/Links.java b/src/main/java/com/authlete/jaxrs/server/obb/model/Links.java new file mode 100644 index 0000000..632ead6 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/model/Links.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.model; + + +import java.io.Serializable; + + +/** + * Links + * + * @see Links + */ +public class Links implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private String self; + private String first; + private String prev; + private String next; + private String last; + + + public String getSelf() + { + return self; + } + + + public Links setSelf(String self) + { + this.self = self; + + return this; + } + + + public String getFirst() + { + return first; + } + + + public Links setFirst(String first) + { + this.first = first; + + return this; + } + + + public String getPrev() + { + return prev; + } + + + public Links setPrev(String prev) + { + this.prev = prev; + + return this; + } + + + public String getNext() + { + return next; + } + + + public Links setNext(String next) + { + this.next = next; + + return this; + } + + + public String getLast() + { + return last; + } + + + public Links setLast(String last) + { + this.last = last; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/model/LoggedUser.java b/src/main/java/com/authlete/jaxrs/server/obb/model/LoggedUser.java new file mode 100644 index 0000000..3d82082 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/model/LoggedUser.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.model; + + +import java.io.Serializable; + + +/** + * LoggedUser + * + * @see LoggedUser + */ +public class LoggedUser implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private Document document; + + + public Document getDocument() + { + return document; + } + + + public LoggedUser setDocument(Document document) + { + this.document = document; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/model/Meta.java b/src/main/java/com/authlete/jaxrs/server/obb/model/Meta.java new file mode 100644 index 0000000..541f1bf --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/model/Meta.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.model; + + +import java.io.Serializable; + + +/** + * Meta + * + * @see Meta + */ +public class Meta implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private int totalRecords; + private int totalPages; + private String requestDateTime; + + + public Meta() + { + } + + + public Meta(int totalRecords, int totalPages, String requestDateTime) + { + this.totalRecords = totalRecords; + this.totalPages = totalPages; + this.requestDateTime = requestDateTime; + } + + + public int getTotalRecords() + { + return totalRecords; + } + + + public Meta setTotalRecords(int totalRecords) + { + this.totalRecords = totalRecords; + + return this; + } + + + public int getTotalPages() + { + return totalPages; + } + + + public Meta setTotalPages(int totalPages) + { + this.totalPages = totalPages; + + return this; + } + + + public String getRequestDateTime() + { + return requestDateTime; + } + + + public Meta setRequestDateTime(String datetime) + { + this.requestDateTime = datetime; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/model/Resource.java b/src/main/java/com/authlete/jaxrs/server/obb/model/Resource.java new file mode 100644 index 0000000..8ef9ca5 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/model/Resource.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.model; + + +import java.io.Serializable; + + +public class Resource implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private String resourceId; + private String type; + private String status; + + + public Resource() + { + } + + + public Resource(String resourceId, String type, String status) + { + this.resourceId = resourceId; + this.type = type; + this.status = status; + } + + + public String getResourceId() + { + return resourceId; + } + + + public Resource setResourceId(String resourceId) + { + this.resourceId = resourceId; + + return this; + } + + + public String getType() + { + return type; + } + + + public Resource setType(String type) + { + this.type = type; + + return this; + } + + + public String getStatus() + { + return status; + } + + + public Resource setStatus(String status) + { + this.status = status; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/model/ResponseAccountList.java b/src/main/java/com/authlete/jaxrs/server/obb/model/ResponseAccountList.java new file mode 100644 index 0000000..c4adb86 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/model/ResponseAccountList.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.model; + + +import java.io.Serializable; + + +/** + * ResponseAccountList + * + * @see ResponseAccountList + */ +public class ResponseAccountList implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private AccountData[] data; + private Links links; + private Meta meta; + + + public ResponseAccountList() + { + } + + + public ResponseAccountList(AccountData[] data, Links links, Meta meta) + { + this.data = data; + this.links = links; + this.meta = meta; + } + + + public AccountData[] getData() + { + return data; + } + + + public ResponseAccountList setData(AccountData[] data) + { + this.data = data; + + return this; + } + + + public Links getLinks() + { + return links; + } + + + public ResponseAccountList setLinks(Links links) + { + this.links = links; + + return this; + } + + + public Meta getMeta() + { + return meta; + } + + + public ResponseAccountList setMeta(Meta meta) + { + this.meta = meta; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/model/ResponseConsent.java b/src/main/java/com/authlete/jaxrs/server/obb/model/ResponseConsent.java new file mode 100644 index 0000000..05e83e1 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/model/ResponseConsent.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.model; + + +import java.io.Serializable; +import com.authlete.jaxrs.server.obb.util.ObbUtils; + + +/** + * ResponseConsent. + * + * @see ResponseConsent + */ +public class ResponseConsent implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private ResponseConsentData data; + + + public ResponseConsent() + { + } + + + public ResponseConsent(Consent consent, Links links, Meta meta) + { + data = new ResponseConsentData() + .setConsentId(consent.getConsentId()) + .setCreationDateTime(consent.getCreationDateTime()) + .setStatus(consent.getStatus()) + .setStatusUpdateDateTime(consent.getStatusUpdateDateTime()) + .setPermissions(consent.getPermissions()) + .setExpirationDateTime(consent.getExpirationDateTime()) + .setLinks(links) + .setMeta(meta) + ; + } + + + public ResponseConsentData getData() + { + return data; + } + + + public ResponseConsent setData(ResponseConsentData data) + { + this.data = data; + + return this; + } + + + public static ResponseConsent create(Consent consent) + { + Links links = new Links().setSelf("/"); + Meta meta = new Meta(1, 1, ObbUtils.formatNow()); + + return new ResponseConsent(consent, links, meta); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/model/ResponseConsentData.java b/src/main/java/com/authlete/jaxrs/server/obb/model/ResponseConsentData.java new file mode 100644 index 0000000..c97e7c0 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/model/ResponseConsentData.java @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.model; + + +import java.io.Serializable; + + +/** + * ResponseConsent.data + * + * @see ResponseConsent + */ +public class ResponseConsentData implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private String consentId; + private String creationDateTime; + private String status; + private String statusUpdateDateTime; + private String[] permissions; + private String expirationDateTime; + private String transactionFromDateTime; + private String transactionToDateTime; + private Links links; + private Meta meta; + + + public String getConsentId() + { + return consentId; + } + + + public ResponseConsentData setConsentId(String consentId) + { + this.consentId = consentId; + + return this; + } + + + public String getCreationDateTime() + { + return creationDateTime; + } + + + public ResponseConsentData setCreationDateTime(String datetime) + { + this.creationDateTime = datetime; + + return this; + } + + + public String getStatus() + { + return status; + } + + + public ResponseConsentData setStatus(String status) + { + this.status = status; + + return this; + } + + + public String getStatusUpdateDateTime() + { + return statusUpdateDateTime; + } + + + public ResponseConsentData setStatusUpdateDateTime(String datetime) + { + this.statusUpdateDateTime = datetime; + + return this; + } + + + public String[] getPermissions() + { + return permissions; + } + + + public ResponseConsentData setPermissions(String[] permissions) + { + this.permissions = permissions; + + return this; + } + + + public String getExpirationDateTime() + { + return expirationDateTime; + } + + + public ResponseConsentData setExpirationDateTime(String datetime) + { + this.expirationDateTime = datetime; + + return this; + } + + + public String getTransactionFromDateTime() + { + return transactionFromDateTime; + } + + + public ResponseConsentData setTransactionFromDateTime(String datetime) + { + this.transactionFromDateTime = datetime; + + return this; + } + + + public String getTransactionToDateTime() + { + return transactionToDateTime; + } + + + public ResponseConsentData setTransactionToDateTime(String datetime) + { + this.transactionToDateTime = datetime; + + return this; + } + + + public Links getLinks() + { + return links; + } + + + public ResponseConsentData setLinks(Links links) + { + this.links = links; + + return this; + } + + + public Meta getMeta() + { + return meta; + } + + + public ResponseConsentData setMeta(Meta meta) + { + this.meta = meta; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/model/ResponseError.java b/src/main/java/com/authlete/jaxrs/server/obb/model/ResponseError.java new file mode 100644 index 0000000..04872b6 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/model/ResponseError.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.model; + + +import java.io.Serializable; +import com.authlete.jaxrs.server.obb.util.ObbUtils; + + +/** + * ResponseError + * + * @see ResponseError + */ +public class ResponseError implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private Error[] errors; + private Meta meta; + + + public ResponseError() + { + } + + + public ResponseError(Error[] errors, Meta meta) + { + this.errors = errors; + this.meta = meta; + } + + + public Error[] getErrors() + { + return errors; + } + + + public ResponseError setErrors(Error[] errors) + { + this.errors = errors; + + return this; + } + + + public Meta getMeta() + { + return meta; + } + + + public ResponseError setMeta(Meta meta) + { + this.meta = meta; + + return this; + } + + + public static ResponseError create( + String code, String title, String detail) + { + Error[] errors = new Error[] { new Error(code, title, detail) }; + Meta meta = new Meta(1, 1, ObbUtils.formatNow()); + + return new ResponseError(errors, meta); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/model/ResponseResourceList.java b/src/main/java/com/authlete/jaxrs/server/obb/model/ResponseResourceList.java new file mode 100644 index 0000000..1b0b465 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/model/ResponseResourceList.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.model; + + +import java.io.Serializable; + + +/** + * ResponseResourceList. + * + * @see ResponseResourceList + */ +public class ResponseResourceList implements Serializable +{ + private static final long serialVersionUID = 1L; + + + private Resource[] data; + private Links links; + private Meta meta; + + + public ResponseResourceList() + { + } + + + public ResponseResourceList(Resource[] data, Links links, Meta meta) + { + this.data = data; + this.links = links; + this.meta = meta; + } + + + public Resource[] getData() + { + return data; + } + + + public ResponseResourceList setData(Resource[] data) + { + this.data = data; + + return this; + } + + + public Links getLinks() + { + return links; + } + + + public ResponseResourceList setLinks(Links links) + { + this.links = links; + + return this; + } + + + public Meta getMeta() + { + return meta; + } + + + public ResponseResourceList setMeta(Meta meta) + { + this.meta = meta; + + return this; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/obb/util/ObbUtils.java b/src/main/java/com/authlete/jaxrs/server/obb/util/ObbUtils.java new file mode 100644 index 0000000..d05992a --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/obb/util/ObbUtils.java @@ -0,0 +1,604 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.authlete.jaxrs.server.obb.util; + + +import static com.authlete.common.util.FapiUtils.X_FAPI_INTERACTION_ID; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Map; +import java.util.TimeZone; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.ResponseBuilder; +import jakarta.ws.rs.core.Response.Status; +import com.authlete.common.api.AuthleteApi; +import com.authlete.common.api.AuthleteApiException; +import com.authlete.common.dto.Client; +import com.authlete.common.dto.IntrospectionRequest; +import com.authlete.common.dto.IntrospectionResponse; +import com.authlete.common.dto.IntrospectionResponse.Action; +import com.authlete.common.util.FapiUtils; +import com.authlete.common.util.Utils; +import com.authlete.common.web.BearerToken; +import com.authlete.common.web.DpopToken; +import com.authlete.jaxrs.server.api.OBBCertValidator; +import com.authlete.jaxrs.server.obb.model.ResponseError; +import com.authlete.jakarta.util.CertificateUtils; +import com.nimbusds.jwt.SignedJWT; + + +public class ObbUtils +{ + private static final SimpleDateFormat sDateFormat; + + + static + { + sDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + sDateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + + public static String formatDate(Date date) + { + return sDateFormat.format(date); + } + + + public static String formatNow() + { + return formatDate(new Date()); + } + + + public static String computeOutgoingInteractionId( + String code, String incomingInteractionId) throws WebApplicationException + { + try + { + // Compute the value for the 'x-fapi-interaction-id' HTTP response header. + return FapiUtils.computeOutgoingInteractionId(incomingInteractionId); + } + catch (IllegalArgumentException e) + { + // The format of the incoming interaction ID is wrong. + } + + throw badRequestException(null, code, + "The format of the incoming 'x-fapi-interaction-id' is wrong."); + } + + + public static IntrospectionResponse validateAccessToken( + String outgoingInteractionId, String code, + AuthleteApi authleteApi, HttpServletRequest request, + String... requiredScopes) + { + // Extract the access token from the Authorization header. + String accessToken = extractAccessToken(request); + + // Extract the client certificate. + String clientCertificate = CertificateUtils.extract(request); + + // Extract information required to validate any DPoP proof + String dpop = request.getHeader("DPoP"); + String htm = request.getMethod(); + // This assumes that jetty has the correct incoming url; if running behind a reverse proxy it is important that + // the jetty ForwardedRequestCustomizer is enabled and that the reverse proxy sets the relevants headers so + // that jetty can determine the original url - e.g. in apache "RequestHeader set X-Forwarded-Proto https" is + // required + String htu = request.getRequestURL().toString(); + + IntrospectionResponse response; + + try + { + // Call Authlete's /api/auth/introspection API. + response = callIntrospection( + authleteApi, accessToken, requiredScopes, dpop, htm, htu, clientCertificate); + } + catch (AuthleteApiException e) + { + // Failed to call Authlete's /api/auth/interaction API. + e.printStackTrace(); + + throw internalServerErrorException( + outgoingInteractionId, code, e.getMessage()); + } + + // 'action' in the response denotes the next action which + // this service implementation should take. + Action action = response.getAction(); + + // If the protected resource endpoint conforms to RFC 6750, + // response.getResponseContent() can be used. However, the + // protected resource endpoints of Open Banking Brasil behave + // differently in error cases. + String detail = response.getResultMessage(); + + // Dispatch according to the action. + switch (action) + { + case INTERNAL_SERVER_ERROR: + // 500 Internal Server Error + throw internalServerErrorException( + outgoingInteractionId, code, detail); + + case BAD_REQUEST: + // 400 Bad Request + throw badRequestException( + outgoingInteractionId, code, detail); + + case UNAUTHORIZED: + // 401 Unauthorized + throw unauthorizedException( + outgoingInteractionId, code, detail); + + case FORBIDDEN: + // 403 Forbidden + throw forbiddenException( + outgoingInteractionId, code, detail); + + case OK: + // Return access token information. + return response; + + default: + // Unknown action. This never happens. + throw unknownAction( + outgoingInteractionId, code, action); + } + } + + + private static String extractAccessToken(HttpServletRequest request) + { + // The value of the "Authorization" header. + String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); + + // Extract a DPoP access token from the value of Authorization header. + String accessToken = DpopToken.parse(authorization); + + if (accessToken == null) + { + // if a DPoP token wasn't found, look for a Bearer in the authorization header + accessToken = BearerToken.parse(authorization); + } + + return accessToken; + } + + + private static IntrospectionResponse callIntrospection( + AuthleteApi authleteApi, String accessToken, + String[] requiredScopes, String dpop, String htm, String htu, String clientCertificate) throws AuthleteApiException + { + // Create a request to Authlete's /api/auth/introspection API. + IntrospectionRequest request = new IntrospectionRequest() + .setToken(accessToken) + .setScopes(requiredScopes) + .setDpop(dpop) + .setHtm(htm) + .setHtu(htu) + .setClientCertificate(clientCertificate) + ; + + // Call Authlete's /api/auth/introspection API. + return authleteApi.introspection(request); + } + + + public static Response generateResponse( + Status status, String outgoingInteractionId, Object entity) + { + ResponseBuilder builder = Response.status(status); + + if (outgoingInteractionId != null) + { + builder.header(X_FAPI_INTERACTION_ID, outgoingInteractionId); + } + + if (entity != null) + { + builder.type(MediaType.APPLICATION_JSON_TYPE); + builder.entity(entity instanceof String ? entity : Utils.toJson(entity, true)); + } + + return builder.build(); + } + + + public static Response ok(String outgoingInteractionId, Object entity) + { + return generateResponse(Status.OK, outgoingInteractionId, entity); + } + + + public static Response created(String outgoingInteractionId, Object entity) + { + return generateResponse(Status.CREATED, outgoingInteractionId, entity); + } + + + public static Response noContent(String outgoingInteractionId) + { + return generateResponse(Status.NO_CONTENT, outgoingInteractionId, null); + } + + + public static Response generateErrorResponse( + Status status, String outgoingInteractionId, + String code, String title, String detail) + { + ResponseError entity = ResponseError.create(code, title, detail); + + return generateResponse(status, outgoingInteractionId, entity); + } + + + public static Response badRequest( + String outgoingInteractionId, String code, String detail) + { + return generateErrorResponse( + Status.BAD_REQUEST, outgoingInteractionId, + code, "Bad Request", detail); + } + + + public static WebApplicationException badRequestException( + String outgoingInteractionId, String code, String detail) + { + return new WebApplicationException( + badRequest(outgoingInteractionId, code, detail)); + } + + + public static Response unauthorized( + String outgoingInteractionId, String code, String detail) + { + return generateErrorResponse( + Status.UNAUTHORIZED, outgoingInteractionId, + code, "Unauthorized", detail); + } + + + public static WebApplicationException unauthorizedException( + String outgoingInteractionId, String code, String detail) + { + return new WebApplicationException( + unauthorized(outgoingInteractionId, code, detail)); + } + + + public static Response forbidden( + String outgoingInteractionId, String code, String detail) + { + return generateErrorResponse( + Status.FORBIDDEN, outgoingInteractionId, + code, "Forbidden", detail); + } + + + public static WebApplicationException forbiddenException( + String outgoingInteractionId, String code, String detail) + { + return new WebApplicationException( + forbidden(outgoingInteractionId, code, detail)); + } + + + public static Response notFound( + String outgoingInteractionId, String code, String detail) + { + return generateErrorResponse( + Status.NOT_FOUND, outgoingInteractionId, + code, "Not Found", detail); + } + + + public static WebApplicationException notFoundException( + String outgoingInteractionId, String code, String detail) + { + return new WebApplicationException( + notFound(outgoingInteractionId, code, detail)); + } + + + public static Response internalServerError( + String outgoingInteractionId, String code, String detail) + { + return generateErrorResponse( + Status.INTERNAL_SERVER_ERROR, outgoingInteractionId, + code, "Internal Server Error", detail); + } + + + public static WebApplicationException internalServerErrorException( + String outgoingInteractionId, String code, String detail) + { + return new WebApplicationException( + internalServerError(outgoingInteractionId, code, detail)); + } + + + private static WebApplicationException unknownAction( + String outgoingInteractionId, String code, + IntrospectionResponse.Action action) + { + String detail = String.format( + "Unknow action '%s' from Authlete's introspection API.", action); + + return internalServerErrorException( + outgoingInteractionId, code, detail); + } + + + public static String extractConsentScope(IntrospectionResponse response) + { + if (response == null) + { + return null; + } + + return extractConsentScope(response.getScopes()); + } + + + public static String extractConsentScope(String[] scopes) + { + if (scopes == null) + { + return null; + } + + for (String scope : scopes) + { + if (scope == null) + { + continue; + } + + if (scope.startsWith("consent:")) + { + return scope; + } + } + + return null; + } + + + /** + * Judge whether the given request body represents a Dynamic Client + * Registration request for Open Banking Brasil. + * + * @param requestBody + * The request body of an HTTP request. + * + * @return + * {@code true} if the given request body seems a Dynamic + * Client Registration request for Open Banking Brasil. + * + * @see Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 Implementers Draft 1 + */ + @SuppressWarnings("unchecked") + public static boolean isObbDcr(String requestBody) + { + // If the request does not have a body. + if (requestBody == null) + { + return false; + } + + Map params; + + try + { + // Try to parse the request body as JSON. + params = Utils.fromJson(requestBody, Map.class); + } + catch (Exception e) + { + // Failed to parse the request body as JSON. + return false; + } + + if (params == null) + { + // Failed to create a Map instance from the request body? + return false; + } + + // If the request body does not contain "software_statement". + if (!params.containsKey("software_statement")) + { + // A DCR request of Open Banking Brasil always contains a software statement. + return false; + } + + SignedJWT jwt; + + try + { + // Parse the value of "software_statement" as a signed JWT. + jwt = SignedJWT.parse((String)params.get("software_statement")); + } + catch (Exception e) + { + // Failed to parse the "software_statement" as a signed JWT. + return false; + } + + String softwareJwksUri; + + try + { + // Get the value of the "software_jwks_uri" claim from the software statement. + softwareJwksUri = jwt.getJWTClaimsSet().getStringClaim("software_jwks_uri"); + } + catch (Exception e) + { + // Failed to retrieve the value of "software_jwks_uri". + return false; + } + + // If the software statement does not include the "software_jwks_uri" claim. + if (softwareJwksUri == null) + { + // A software statement issued from the Directory of Open Banking Brasil + // always contains "software_jwks_uri". + return false; + } + + // If the "software_jwks_uri" does not include "openbankingbrasil". + if (softwareJwksUri.indexOf("openbankingbrasil") < 0) + { + // JWK Sets of Open Banking Brasil are hosted on + // "https://keystore[.sandbox].directory.openbankingbrasil.org.br/" + return false; + } + + // The given request body seems to be a Dynamic Client Registration request + // for Open Banking Brasil. + return true; + } + + + /** + * Judge whether the client identified by the client ID is a client that + * has been dynamically registered for Open Banking Brasil. + * + * @param api + * An implementation of {@link AuthleteApi}. + * + * @param clientId + * A client ID. + * + * @return + * {@code true} if the client identified by the client ID is a + * client that has been dynamically registered for Open Banking + * Brasil. + * + * @see Open Banking Brasil Financial-grade API Dynamic Client Registration 1.0 Implementers Draft 1 + */ + @SuppressWarnings("unchecked") + public static boolean isObbDynamicClient(AuthleteApi api, String clientId) + { + Client client; + + try + { + // Get information about the client identified by the client ID + // by calling Authlete's /api/client/get/{clientIdentifier} API. + client = api.getClient(clientId); + } + catch (Exception e) + { + // The API call failed. + return false; + } + + if (client == null) + { + // Client information is not available. + return false; + } + + // Custom metadata of the client. This implementation assumes that + // a client is registered with some client metadata. + String json = client.getCustomMetadata(); + + if (json == null) + { + // The client does not have custom metadata. + return false; + } + + Map metadata; + + try + { + // Parse the string as JSON. + metadata = Utils.fromJson(json, Map.class); + } + catch (Exception e) + { + // Failed to parse the string as JSON. + return false; + } + + // This implementation assumes that the client's custom metadata + // contains "software_roles". This assumption requires that the + // configuration of "Supported Custom Client Metadata" property + // of your Authlete 'Service' has been properly set up. The + // property must include "software_roles" so that it can be + // registered as custom metadata on dynamic client registration. + + // If the custom metadata does not contain "software_roles". + if (!metadata.containsKey("software_roles")) + { + return false; + } + + // The current implementation regards the client as a client + // of Open Banking Brasil if the client's custom metadata + // contains "software_roles". However, if other open banking + // ecosystems start to use "software_roles", further logic + // needs to be added here. For example, checking whether the + // "software_roles" array contains an OBB-specific value such + // as "DADOS" and "PAGTO". But, checking the presence of + // "software_roles" is enough for now. + + return true; + } + + + /** + * Judge whether the root certificate of the certificate chain that + * consists of the presented client certificate and intermediate + * certificates is a certificate issued by the authority of Open + * Banking Brasil. + * + * @param request + * An HTTP request. + * + * @return + * {@code true} if the request contains a client certificate + * for Open Banking Brasil. + */ + public static boolean includesObbCertificate(HttpServletRequest request) + { + try + { + // Validate the certificate chain included in the request. + OBBCertValidator.getInstance().validate(request); + + // The request contains a client certificate issued for OBB. + return true; + } + catch (Exception e) + { + // The request does not contain a client certificate for OBB. + return false; + } + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteBackoff.java b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteBackoff.java new file mode 100644 index 0000000..263b896 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteBackoff.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.util.Random; + + +/** + * Computes the delay before a retry using exponential backoff with random + * jitter, as recommended in Authlete's "Rate Limit Best Practices" guide + * (first retry after ~500 ms, then ~1 s, ~2 s, ...). + * + *

+ * Jitter spreads retries from many clients across time so they do not all + * fire simultaneously and create a renewed traffic spike. When the server + * tells us exactly how long to wait (via a {@code RateLimit-Reset} header on a + * {@code 429}), that value is honoured instead of the computed delay. + *

+ */ +class AuthleteBackoff +{ + private final long baseDelayMillis; + private final long maxDelayMillis; + private final long jitterMillis; + private final Random random; + + + AuthleteBackoff(long baseDelayMillis, long maxDelayMillis, long jitterMillis) + { + this(baseDelayMillis, maxDelayMillis, jitterMillis, new Random()); + } + + + /** + * Package-private constructor that allows an injected {@link Random} for + * deterministic testing. + */ + AuthleteBackoff(long baseDelayMillis, long maxDelayMillis, long jitterMillis, Random random) + { + this.baseDelayMillis = baseDelayMillis; + this.maxDelayMillis = maxDelayMillis; + this.jitterMillis = jitterMillis; + this.random = random; + } + + + /** + * Compute the delay (milliseconds) before the given retry attempt. + * + * @param attempt + * The 1-based retry number (1 = first retry, 2 = second retry, ...). + * + * @param explicitDelayMillis + * An explicit delay requested by the server (e.g. from a + * {@code RateLimit-Reset} header), or {@code null} if none. When + * present, it forms the base delay instead of the exponential value. + * + * @return + * The delay to sleep, clamped to {@code [0, maxDelayMillis]}. + */ + long delayMillis(int attempt, Long explicitDelayMillis) + { + long base; + + if (explicitDelayMillis != null) + { + // Honour the server's instruction. + base = explicitDelayMillis; + } + else + { + // baseDelay * 2^(attempt-1), guarding against overflow. + int shift = Math.max(0, attempt - 1); + + if (shift >= 62) + { + base = maxDelayMillis; + } + else + { + base = baseDelayMillis << shift; + } + } + + long jitter = (jitterMillis > 0) ? (long) (random.nextDouble() * jitterMillis) : 0L; + + long delay = base + jitter; + + if (delay < 0 || delay > maxDelayMillis) + { + delay = maxDelayMillis; + } + + return delay; + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethods.java b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethods.java new file mode 100644 index 0000000..44e1bbc --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethods.java @@ -0,0 +1,271 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.lang.reflect.Method; +import com.authlete.common.dto.CredentialIssuerJwksRequest; +import com.authlete.common.dto.CredentialIssuerMetadataRequest; +import com.authlete.common.dto.IntrospectionRequest; +import com.authlete.common.dto.ServiceConfigurationRequest; +import com.authlete.common.dto.StandardIntrospectionRequest; + + +/** + * Decides which {@link com.authlete.common.api.AuthleteApi AuthleteApi} methods + * are cacheable, and builds a stable cache key and TTL for each call. + * + *

+ * Only the idempotent read endpoints named in Authlete's "Rate Limit Best + * Practices" guide are cached: + *

+ *
    + *
  • {@code getServiceConfiguration} — service discovery document
  • + *
  • {@code getServiceJwks} — service JWK Set
  • + *
  • {@code getClient} — client metadata
  • + *
  • {@code credentialIssuerMetadata} — OID4VCI issuer metadata
  • + *
  • {@code credentialIssuerJwks} — OID4VCI issuer JWK Set
  • + *
  • {@code introspection} / {@code standardIntrospection} — token + * introspection (short TTL; see {@link CachePolicy#capByTokenExpiry})
  • + *
+ * + *

+ * Any other method returns {@code null} from {@link #policyFor(Method, Object[])} + * and is therefore never cached. + *

+ */ +class AuthleteCacheableMethods +{ + /** + * The decision for a single cacheable call: the namespaced cache key and the + * TTL to apply. + */ + static final class CachePolicy + { + final String key; + final long ttlMillis; + + /** + * When true, the effective TTL must additionally be capped so the entry + * never outlives the token's own expiry (introspection only). + */ + final boolean capByTokenExpiry; + + CachePolicy(String key, long ttlMillis, boolean capByTokenExpiry) + { + this.key = key; + this.ttlMillis = ttlMillis; + this.capByTokenExpiry = capByTokenExpiry; + } + } + + + private final ResilienceConfig config; + + + AuthleteCacheableMethods(ResilienceConfig config) + { + this.config = config; + } + + + /** + * Return the caching policy for the given method invocation, or {@code null} + * if the method must not be cached. + */ + CachePolicy policyFor(Method method, Object[] args) + { + String name = method.getName(); + int argc = (args == null) ? 0 : args.length; + + switch (name) + { + case "getServiceConfiguration": + return serviceConfiguration(args, argc); + + case "getServiceJwks": + return key("getServiceJwks", joinArgs(args), + config.getCacheTtlServiceJwks(), false); + + case "getClient": + // getClient(long) and getClient(String); both identify one client. + return key("getClient", String.valueOf(args[0]), + config.getCacheTtlClient(), false); + + case "credentialIssuerMetadata": + if (args[0] instanceof CredentialIssuerMetadataRequest) + { + CredentialIssuerMetadataRequest req = (CredentialIssuerMetadataRequest) args[0]; + return key("credentialIssuerMetadata", String.valueOf(req.isPretty()), + config.getCacheTtlCredentialIssuerMetadata(), false); + } + return null; + + case "credentialIssuerJwks": + if (args[0] instanceof CredentialIssuerJwksRequest) + { + CredentialIssuerJwksRequest req = (CredentialIssuerJwksRequest) args[0]; + return key("credentialIssuerJwks", String.valueOf(req.isPretty()), + config.getCacheTtlCredentialIssuerJwks(), false); + } + return null; + + case "introspection": + if (args[0] instanceof IntrospectionRequest) + { + return introspection((IntrospectionRequest) args[0]); + } + return null; + + case "standardIntrospection": + if (args[0] instanceof StandardIntrospectionRequest) + { + return standardIntrospection((StandardIntrospectionRequest) args[0]); + } + return null; + + default: + return null; + } + } + + + private CachePolicy serviceConfiguration(Object[] args, int argc) + { + long ttl = config.getCacheTtlServiceConfiguration(); + + if (argc == 1 && args[0] instanceof ServiceConfigurationRequest) + { + ServiceConfigurationRequest req = (ServiceConfigurationRequest) args[0]; + String detail = req.isPretty() + "|" + req.getPatch(); + return key("getServiceConfiguration", detail, ttl, false); + } + + // getServiceConfiguration() or getServiceConfiguration(boolean). + return key("getServiceConfiguration", joinArgs(args), ttl, false); + } + + + private CachePolicy introspection(IntrospectionRequest req) + { + // A DPoP proof is unique per request (jti/iat), and HTTP message + // signature inputs vary per request too. A cached result could never + // be legitimately reused for them, and reusing one would skip the + // per-request proof validation, so such calls are never cached. + if (req.getDpop() != null + || req.getMessage() != null + || req.getHeaders() != null + || req.getRequiredComponents() != null) + { + return null; + } + + // The key must capture every remaining input that influences the + // introspection result, so that requests differing in any binding + // never share an entry. Parameters that only apply to DPoP or HTTP + // message signature requests (htm, htu, targetUri, uri, + // dpopNonceRequired, requestBodyContained) are intentionally omitted: + // those requests are excluded above, so such parameters do not affect + // the result of a cacheable request. + StringBuilder detail = new StringBuilder(); + detail.append(req.getToken()); + detail.append('|').append(join(req.getScopes())); + detail.append('|').append(req.getSubject()); + detail.append('|').append(req.getClientCertificate()); + detail.append('|').append(join(req.getResources())); + detail.append('|').append(join(req.getAcrValues())); + detail.append('|').append(req.getMaxAge()); + + return key("introspection", detail.toString(), + config.getCacheTtlIntrospection(), true); + } + + + private CachePolicy standardIntrospection(StandardIntrospectionRequest req) + { + // Besides the token parameters, the response depends on the resource + // server's identity and the requested response format/protection, so + // all of them participate in the key. Otherwise one resource server + // could receive a response cached for another. + StringBuilder detail = new StringBuilder(); + detail.append(req.getParameters()); + detail.append('|').append(req.isWithHiddenProperties()); + detail.append('|').append(req.getRsUri()); + detail.append('|').append(req.getHttpAcceptHeader()); + detail.append('|').append(req.getIntrospectionSignAlg()); + detail.append('|').append(req.getIntrospectionEncryptionAlg()); + detail.append('|').append(req.getIntrospectionEncryptionEnc()); + detail.append('|').append(req.getSharedKeyForSign()); + detail.append('|').append(req.getSharedKeyForEncryption()); + detail.append('|').append(req.getPublicKeyForEncryption()); + + return key("standardIntrospection", detail.toString(), + config.getCacheTtlStandardIntrospection(), true); + } + + + private static CachePolicy key(String namespace, String detail, long ttlMillis, boolean capByTokenExpiry) + { + // Token (and similar) values can be long; namespacing keeps lookups O(1) + // in the shared map without risk of cross-method collisions. + return new CachePolicy(namespace + "::" + detail, ttlMillis, capByTokenExpiry); + } + + + private static String joinArgs(Object[] args) + { + if (args == null || args.length == 0) + { + return ""; + } + + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < args.length; i++) + { + if (i > 0) + { + sb.append('|'); + } + sb.append(String.valueOf(args[i])); + } + + return sb.toString(); + } + + + private static String join(Object[] values) + { + if (values == null || values.length == 0) + { + return ""; + } + + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < values.length; i++) + { + if (i > 0) + { + sb.append(','); + } + sb.append(values[i]); + } + + return sb.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreaker.java b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreaker.java new file mode 100644 index 0000000..fcde803 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreaker.java @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.util.function.LongSupplier; + + +/** + * A classic three-state circuit breaker (Closed / Open / Half-Open) guarding a + * single Authlete API method. + * + *
    + *
  • Closed — normal operation; transient failures are counted + * within a rolling window. Reaching the failure threshold trips the + * breaker Open.
  • + *
  • Open — calls fail fast without touching the backend. After + * the open timeout elapses, the breaker moves to Half-Open.
  • + *
  • Half-Open — a limited number of trial calls are allowed + * through. A success closes the breaker; a failure reopens it.
  • + *
+ * + *

+ * One breaker is kept per API method (see {@link AuthleteCircuitBreakerRegistry}) so + * that failures in one endpoint (e.g. client management) do not block an + * unrelated, higher-priority endpoint (e.g. introspection). + *

+ * + *

+ * All state transitions are guarded by intrinsic locking; the breaker is safe + * for concurrent use. + *

+ */ +class AuthleteCircuitBreaker +{ + enum State + { + CLOSED, + OPEN, + HALF_OPEN + } + + + private final int failureThreshold; + private final long windowMillis; + private final long openMillis; + private final int halfOpenTrials; + private final LongSupplier clock; + + private State state = State.CLOSED; + private int failureCount = 0; + private boolean windowOpen = false; + private long windowStart = 0L; + private long openedAt = 0L; + private int halfOpenInFlight = 0; + + + AuthleteCircuitBreaker(int failureThreshold, long windowMillis, long openMillis, int halfOpenTrials) + { + this(failureThreshold, windowMillis, openMillis, halfOpenTrials, System::currentTimeMillis); + } + + + /** + * Package-private constructor that allows an injected clock for testing. + */ + AuthleteCircuitBreaker(int failureThreshold, long windowMillis, long openMillis, + int halfOpenTrials, LongSupplier clock) + { + this.failureThreshold = failureThreshold; + this.windowMillis = windowMillis; + this.openMillis = openMillis; + this.halfOpenTrials = Math.max(1, halfOpenTrials); + this.clock = clock; + } + + + /** + * Decide whether a request may proceed right now. When this returns + * {@code true} in the half-open state, a trial slot is reserved and must be + * released via {@link #recordSuccess()} or {@link #recordFailure()}. + */ + synchronized boolean allowRequest() + { + long now = clock.getAsLong(); + + switch (state) + { + case CLOSED: + return true; + + case OPEN: + if (now - openedAt >= openMillis) + { + // Time to probe whether the backend has recovered. + state = State.HALF_OPEN; + halfOpenInFlight = 1; + return true; + } + return false; + + case HALF_OPEN: + default: + if (halfOpenInFlight < halfOpenTrials) + { + halfOpenInFlight++; + return true; + } + return false; + } + } + + + /** + * Record a successful call. + */ + synchronized void recordSuccess() + { + // Any success (in either Closed or Half-Open) restores normal operation. + reset(); + } + + + /** + * Release a half-open trial slot without judging backend health. Used when + * a call granted by {@link #allowRequest()} ends in a way that says nothing + * about whether the backend has recovered (e.g. an unexpected local + * error), so the slot becomes available for the next probe. + */ + synchronized void releaseTrial() + { + if (state == State.HALF_OPEN && halfOpenInFlight > 0) + { + halfOpenInFlight--; + } + } + + + /** + * Record a (transient) failure. + */ + synchronized void recordFailure() + { + long now = clock.getAsLong(); + + if (state == State.HALF_OPEN) + { + // The probe failed: reopen immediately. + trip(now); + return; + } + + // CLOSED: count failures within the rolling window. + if (!windowOpen || now - windowStart > windowMillis) + { + // Start a fresh window. + windowOpen = true; + windowStart = now; + failureCount = 0; + } + + failureCount++; + + if (failureCount >= failureThreshold) + { + trip(now); + } + } + + + private void trip(long now) + { + state = State.OPEN; + openedAt = now; + failureCount = 0; + windowOpen = false; + halfOpenInFlight = 0; + } + + + private void reset() + { + state = State.CLOSED; + failureCount = 0; + windowOpen = false; + halfOpenInFlight = 0; + } + + + synchronized State getState() + { + return state; + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerRegistry.java b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerRegistry.java new file mode 100644 index 0000000..1963471 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerRegistry.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.util.concurrent.ConcurrentHashMap; + + +/** + * Lazily creates and holds one {@link AuthleteCircuitBreaker} per Authlete API method. + * + *

+ * Keeping breakers per method isolates failures: a breaker that has tripped for + * one endpoint (e.g. client management) does not affect another, higher-priority + * endpoint (e.g. introspection), as recommended by the "Isolate Resources" + * advice in Authlete's "Rate Limit Best Practices" guide. + *

+ */ +class AuthleteCircuitBreakerRegistry +{ + private final ConcurrentHashMap breakers = + new ConcurrentHashMap(); + + private final int failureThreshold; + private final long windowMillis; + private final long openMillis; + private final int halfOpenTrials; + + + AuthleteCircuitBreakerRegistry(ResilienceConfig config) + { + this.failureThreshold = config.getBreakerFailureThreshold(); + this.windowMillis = config.getBreakerWindowMillis(); + this.openMillis = config.getBreakerOpenMillis(); + this.halfOpenTrials = config.getBreakerHalfOpenTrials(); + } + + + /** + * Get the breaker for the given method name, creating it on first use. + */ + AuthleteCircuitBreaker forMethod(String methodName) + { + AuthleteCircuitBreaker existing = breakers.get(methodName); + + if (existing != null) + { + return existing; + } + + AuthleteCircuitBreaker created = + new AuthleteCircuitBreaker(failureThreshold, windowMillis, openMillis, halfOpenTrials); + + AuthleteCircuitBreaker previous = breakers.putIfAbsent(methodName, created); + + return (previous != null) ? previous : created; + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCache.java b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCache.java new file mode 100644 index 0000000..e25cb47 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCache.java @@ -0,0 +1,233 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.LongSupplier; +import java.util.function.Predicate; + + +/** + * A small, thread-safe, in-memory TTL cache for responses of idempotent + * Authlete API calls. + * + *

+ * Each entry has two lifetimes: + *

+ *
    + *
  • fresh — until its TTL elapses; {@link #getFresh(String)} + * returns it and the value is served directly without calling Authlete.
  • + *
  • stale — for an additional {@code staleMillis} after the TTL; + * {@link #getStale(String)} returns it. Stale values are used only as a + * fast-fail fallback while the circuit breaker is open, so that a degraded + * but functional response can be served during an outage.
  • + *
+ * + *

+ * The cache is intentionally dependency-free (a plain {@link ConcurrentHashMap}) + * so it is easy to read and copy. For cross-instance caching, replace the map + * with a shared store such as Redis. + *

+ * + *

+ * A single instance is shared across all cached methods; cache keys are + * namespaced by method (see {@link AuthleteCacheableMethods}) so there is no risk of + * collision between, say, a client id and a service-configuration request. + *

+ */ +class AuthleteResponseCache +{ + private static final class Entry + { + final Object value; + final long freshUntil; + final long staleUntil; + + Entry(Object value, long freshUntil, long staleUntil) + { + this.value = value; + this.freshUntil = freshUntil; + this.staleUntil = staleUntil; + } + } + + + private final ConcurrentHashMap map = new ConcurrentHashMap(); + private final long staleMillis; + private final int maxEntries; + private final LongSupplier clock; + + + AuthleteResponseCache(long staleMillis, int maxEntries) + { + this(staleMillis, maxEntries, System::currentTimeMillis); + } + + + /** + * Package-private constructor that allows an injected clock for testing. + */ + AuthleteResponseCache(long staleMillis, int maxEntries, LongSupplier clock) + { + this.staleMillis = staleMillis; + this.maxEntries = maxEntries; + this.clock = clock; + } + + + /** + * Return the cached value for the key only if it is still fresh, otherwise + * {@code null}. + */ + Object getFresh(String key) + { + Entry e = map.get(key); + + if (e == null) + { + return null; + } + + long now = clock.getAsLong(); + + if (now < e.freshUntil) + { + return e.value; + } + + // Expired beyond the stale window: drop it eagerly. + if (now >= e.staleUntil) + { + map.remove(key, e); + } + + return null; + } + + + /** + * Return the cached value for the key if it still exists within the stale + * window (whether fresh or only stale), otherwise {@code null}. + */ + Object getStale(String key) + { + Entry e = map.get(key); + + if (e == null) + { + return null; + } + + long now = clock.getAsLong(); + + if (now < e.staleUntil) + { + return e.value; + } + + map.remove(key, e); + + return null; + } + + + /** + * Store a value under the key with the given TTL (milliseconds). The stale + * window is added on top of the TTL. + */ + void put(String key, Object value, long ttlMillis) + { + if (value == null || ttlMillis <= 0) + { + return; + } + + long now = clock.getAsLong(); + + // Bound memory: evict expired entries first, then refuse new keys if + // still over the limit (existing keys are always allowed to refresh). + if (map.size() >= maxEntries && !map.containsKey(key)) + { + purgeExpired(now); + + if (map.size() >= maxEntries) + { + return; + } + } + + map.put(key, new Entry(value, now + ttlMillis, now + ttlMillis + staleMillis)); + } + + + private void purgeExpired(long now) + { + for (Map.Entry e : map.entrySet()) + { + if (now >= e.getValue().staleUntil) + { + map.remove(e.getKey(), e.getValue()); + } + } + } + + + /** + * Remove every entry whose key satisfies the given predicate. + * + *

+ * Used for best-effort eviction of a revoked token's introspection + * entries: introspection cache keys start with the token (see + * {@link AuthleteCacheableMethods}), so all entries for one token can be + * matched without a reverse index. + *

+ * + * @return + * The number of entries removed. + */ + int removeIf(Predicate keyPredicate) + { + int removed = 0; + + for (String key : map.keySet()) + { + if (keyPredicate.test(key) && map.remove(key) != null) + { + removed++; + } + } + + return removed; + } + + + /** + * Remove all entries. Exposed for completeness / testing. + */ + void clear() + { + map.clear(); + } + + + int size() + { + return map.size(); + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicy.java b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicy.java new file mode 100644 index 0000000..9831710 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicy.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.util.List; +import java.util.Map; + + +/** + * Decides whether a failed Authlete API call is transient (worth + * retrying) or permanent (never retried), per Authlete's "Rate Limit + * Best Practices" guide. + * + *

+ * Transient failures are: + *

+ *
    + *
  • {@code 429 Too Many Requests} — retry only after the delay in the + * {@code RateLimit-Reset} response header (see + * {@link #rateLimitResetMillis(Map)}).
  • + *
  • {@code 502 Bad Gateway}, {@code 503 Service Unavailable}, and any other + * {@code 5xx}.
  • + *
  • Connection-level errors with no HTTP response (status {@code 0}).
  • + *
+ * + *

+ * Permanent failures are the remaining {@code 4xx} codes (e.g. {@code 400}, + * {@code 401}, {@code 403}); these can never succeed without changing the + * request, so they are surfaced immediately. + *

+ */ +class AuthleteRetryPolicy +{ + // Common spellings of the rate-limit reset header, matched case-insensitively. + private static final String[] RESET_HEADERS = { + "RateLimit-Reset", "X-RateLimit-Reset", "Retry-After" + }; + + + /** + * Tell whether the given HTTP status code denotes a transient failure that + * may be retried. + * + * @param statusCode + * The HTTP status code from {@code AuthleteApiException.getStatusCode()}. + * A value of {@code 0} means no HTTP response was received (a + * connection-level error), which is treated as transient. + */ + boolean isTransient(int statusCode) + { + // No HTTP response (connection refused, timeout, DNS failure, ...). + if (statusCode == 0) + { + return true; + } + + // Too Many Requests. + if (statusCode == 429) + { + return true; + } + + // Any server-side error. + if (statusCode >= 500 && statusCode < 600) + { + return true; + } + + // Everything else (notably 4xx) is permanent. + return false; + } + + + /** + * Extract the rate-limit reset delay (milliseconds) from the response + * headers, or {@code null} when no usable value is present. + * + *

+ * The header value is interpreted as a number of seconds to wait before the + * next attempt. Non-numeric or non-positive values are ignored. + *

+ */ + Long rateLimitResetMillis(Map> headers) + { + if (headers == null || headers.isEmpty()) + { + return null; + } + + for (String wanted : RESET_HEADERS) + { + String value = findHeader(headers, wanted); + + if (value == null) + { + continue; + } + + try + { + long seconds = Long.parseLong(value.trim()); + + if (seconds > 0) + { + return seconds * 1000L; + } + } + catch (NumberFormatException e) + { + // Try the next candidate header. + } + } + + return null; + } + + + private static String findHeader(Map> headers, String name) + { + for (Map.Entry> e : headers.entrySet()) + { + String key = e.getKey(); + + if (key != null && key.equalsIgnoreCase(name)) + { + List values = e.getValue(); + + if (values != null && !values.isEmpty()) + { + return values.get(0); + } + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceConfig.java b/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceConfig.java new file mode 100644 index 0000000..4209e58 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceConfig.java @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +/** + * Typed, defaulted access to the resilience configuration defined in + * {@code resilience.properties} (overridable via JVM system properties). + * + *

+ * Values are read once at class-load time. The defaults below match the + * recommendations in Authlete's "Rate Limit Best Practices" guide, so the + * resilience layer behaves sensibly even when {@code resilience.properties} + * is absent. + *

+ */ +public final class ResilienceConfig +{ + private static final ResilienceProperties PROPS = new ResilienceProperties(); + + // Master switch. + private final boolean enabled; + + // Cache. + private final boolean cacheEnabled; + private final long cacheTtlServiceConfiguration; + private final long cacheTtlServiceJwks; + private final long cacheTtlClient; + private final long cacheTtlCredentialIssuerMetadata; + private final long cacheTtlCredentialIssuerJwks; + private final long cacheTtlIntrospection; + private final long cacheTtlStandardIntrospection; + private final long cacheStaleMillis; + private final int cacheMaxEntries; + + // Retry / backoff. + private final boolean retryEnabled; + private final int retryMaxAttempts; + private final long retryBaseDelayMillis; + private final long retryMaxTotalMillis; + private final long retryJitterMillis; + + // Circuit breaker. + private final boolean breakerEnabled; + private final int breakerFailureThreshold; + private final long breakerWindowMillis; + private final long breakerOpenMillis; + private final int breakerHalfOpenTrials; + + + /** + * Build a configuration snapshot from {@code resilience.properties} and + * system properties. + */ + public ResilienceConfig() + { + enabled = PROPS.getBoolean("resilience.enabled", true); + + cacheEnabled = PROPS.getBoolean("resilience.cache.enabled", true); + cacheTtlServiceConfiguration = seconds("resilience.cache.ttl.serviceConfiguration", 600); + cacheTtlServiceJwks = seconds("resilience.cache.ttl.serviceJwks", 600); + cacheTtlClient = seconds("resilience.cache.ttl.client", 300); + cacheTtlCredentialIssuerMetadata = seconds("resilience.cache.ttl.credentialIssuerMetadata", 600); + cacheTtlCredentialIssuerJwks = seconds("resilience.cache.ttl.credentialIssuerJwks", 600); + cacheTtlIntrospection = seconds("resilience.cache.ttl.introspection", 30); + cacheTtlStandardIntrospection = seconds("resilience.cache.ttl.standardIntrospection", 30); + cacheStaleMillis = seconds("resilience.cache.staleSeconds", 1800); + cacheMaxEntries = PROPS.getInt("resilience.cache.maxEntries", 10000); + + retryEnabled = PROPS.getBoolean("resilience.retry.enabled", true); + retryMaxAttempts = PROPS.getInt("resilience.retry.maxAttempts", 4); + retryBaseDelayMillis = PROPS.getLong("resilience.retry.baseDelayMillis", 500); + retryMaxTotalMillis = PROPS.getLong("resilience.retry.maxTotalMillis", 60000); + retryJitterMillis = PROPS.getLong("resilience.retry.jitterMillis", 200); + + breakerEnabled = PROPS.getBoolean("resilience.breaker.enabled", true); + breakerFailureThreshold = PROPS.getInt("resilience.breaker.failureThreshold", 5); + breakerWindowMillis = seconds("resilience.breaker.windowSeconds", 30); + breakerOpenMillis = seconds("resilience.breaker.openSeconds", 60); + breakerHalfOpenTrials = PROPS.getInt("resilience.breaker.halfOpenTrials", 1); + } + + + /** + * Read a value expressed in seconds and return it in milliseconds. + */ + private static long seconds(String key, long defaultSeconds) + { + return PROPS.getLong(key, defaultSeconds) * 1000L; + } + + + public boolean isEnabled() + { + return enabled; + } + + + public boolean isCacheEnabled() + { + return cacheEnabled; + } + + + public long getCacheTtlServiceConfiguration() + { + return cacheTtlServiceConfiguration; + } + + + public long getCacheTtlServiceJwks() + { + return cacheTtlServiceJwks; + } + + + public long getCacheTtlClient() + { + return cacheTtlClient; + } + + + public long getCacheTtlCredentialIssuerMetadata() + { + return cacheTtlCredentialIssuerMetadata; + } + + + public long getCacheTtlCredentialIssuerJwks() + { + return cacheTtlCredentialIssuerJwks; + } + + + public long getCacheTtlIntrospection() + { + return cacheTtlIntrospection; + } + + + public long getCacheTtlStandardIntrospection() + { + return cacheTtlStandardIntrospection; + } + + + public long getCacheStaleMillis() + { + return cacheStaleMillis; + } + + + public int getCacheMaxEntries() + { + return cacheMaxEntries; + } + + + public boolean isRetryEnabled() + { + return retryEnabled; + } + + + public int getRetryMaxAttempts() + { + return retryMaxAttempts; + } + + + public long getRetryBaseDelayMillis() + { + return retryBaseDelayMillis; + } + + + public long getRetryMaxTotalMillis() + { + return retryMaxTotalMillis; + } + + + public long getRetryJitterMillis() + { + return retryJitterMillis; + } + + + public boolean isBreakerEnabled() + { + return breakerEnabled; + } + + + public int getBreakerFailureThreshold() + { + return breakerFailureThreshold; + } + + + public long getBreakerWindowMillis() + { + return breakerWindowMillis; + } + + + public long getBreakerOpenMillis() + { + return breakerOpenMillis; + } + + + public int getBreakerHalfOpenTrials() + { + return breakerHalfOpenTrials; + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceProperties.java b/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceProperties.java new file mode 100644 index 0000000..ef07880 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceProperties.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.util.MissingResourceException; +import java.util.ResourceBundle; +import com.authlete.jaxrs.server.util.TypedSystemProperties; + + +/** + * Reads resilience configuration from {@code resilience.properties} (on the + * classpath) with JVM system properties taking precedence. + * + *

+ * This mirrors {@link com.authlete.jaxrs.server.util.ServerProperties + * ServerProperties} but binds to the dedicated {@code resilience} resource + * bundle so that resilience tuning lives in its own file, separate from the + * server's functional configuration. + *

+ * + * @see ResilienceConfig + */ +class ResilienceProperties extends TypedSystemProperties +{ + private static final ResourceBundle RESOURCE_BUNDLE; + + + static + { + ResourceBundle bundle = null; + + try + { + bundle = ResourceBundle.getBundle("resilience"); + } + catch (MissingResourceException mre) + { + // The file is optional; built-in defaults will be used instead. + } + + RESOURCE_BUNDLE = bundle; + } + + + @Override + public String getString(String key, String defaultValue) + { + if (key == null) + { + return defaultValue; + } + + // A JVM system property always wins over the file. + if (super.contains(key)) + { + return super.getString(key, defaultValue); + } + + // The properties file is not available. + if (RESOURCE_BUNDLE == null) + { + return defaultValue; + } + + try + { + return RESOURCE_BUNDLE.getString(key); + } + catch (MissingResourceException e) + { + return defaultValue; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiFactory.java b/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiFactory.java new file mode 100644 index 0000000..fcb3b4c --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiFactory.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.lang.reflect.Proxy; +import com.authlete.common.api.AuthleteApi; +import com.authlete.common.api.AuthleteApiFactory; + + +/** + * Drop-in replacement for {@link AuthleteApiFactory#getDefaultApi()} that + * returns an {@link AuthleteApi} wrapped with the resilience layer (caching, + * conditional retry, exponential backoff with jitter, and a circuit breaker). + * + *

+ * Endpoint classes obtain their API client through this factory instead of + * {@code AuthleteApiFactory} so that every call to Authlete — including + * nested calls made by handler SPIs that receive the same instance — is + * protected, with no change to the endpoint logic itself. + *

+ * + *

+ * The wrapped instance is built once and shared. Tuning lives in + * {@code resilience.properties} (see {@link ResilienceConfig}); when resilience + * is disabled there, the underlying default {@code AuthleteApi} is returned + * unwrapped, restoring the original behaviour. + *

+ */ +public final class ResilientAuthleteApiFactory +{ + private static volatile AuthleteApi cachedApi; + + + private ResilientAuthleteApiFactory() + { + } + + + /** + * Get the resilient default {@link AuthleteApi} instance. + * + * @return + * The default {@code AuthleteApi} wrapped with the resilience layer, + * or the unwrapped default instance when resilience is disabled. + */ + public static AuthleteApi getDefaultApi() + { + AuthleteApi api = cachedApi; + + if (api != null) + { + return api; + } + + return initDefaultApi(); + } + + + private static synchronized AuthleteApi initDefaultApi() + { + if (cachedApi != null) + { + return cachedApi; + } + + AuthleteApi delegate = AuthleteApiFactory.getDefaultApi(); + + cachedApi = wrap(delegate); + + return cachedApi; + } + + + /** + * Wrap the given {@link AuthleteApi} with the resilience layer. Returns the + * delegate unchanged when resilience is disabled in the configuration. + */ + public static AuthleteApi wrap(AuthleteApi delegate) + { + ResilienceConfig config = new ResilienceConfig(); + + if (!config.isEnabled()) + { + return delegate; + } + + return (AuthleteApi) Proxy.newProxyInstance( + AuthleteApi.class.getClassLoader(), + new Class[] { AuthleteApi.class }, + new ResilientAuthleteApiInvocationHandler(delegate, config)); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiInvocationHandler.java b/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiInvocationHandler.java new file mode 100644 index 0000000..dd82f68 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiInvocationHandler.java @@ -0,0 +1,420 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.io.UnsupportedEncodingException; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.URLDecoder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import com.authlete.common.api.AuthleteApi; +import com.authlete.common.api.AuthleteApiException; +import com.authlete.common.dto.IntrospectionResponse; +import com.authlete.common.dto.RevocationRequest; +import com.authlete.jaxrs.server.resilience.AuthleteCacheableMethods.CachePolicy; + + +/** + * The {@link InvocationHandler} behind the resilient {@link AuthleteApi} proxy. + * + *

+ * Every call to the wrapped {@code AuthleteApi} flows through {@link #invoke} + * which applies, in order, the four practices from Authlete's "Rate Limit Best + * Practices" guide: + *

+ *
    + *
  1. Caching — a fresh cached response for an idempotent read is + * returned without calling Authlete.
  2. + *
  3. Circuit breaking — when the per-method breaker is open, the + * call fails fast, serving stale cached data when available.
  4. + *
  5. Conditional retry — only transient failures (429/5xx/no + * response) are retried; permanent 4xx errors propagate immediately.
  6. + *
  7. Exponential backoff with jitter — the wait before each + * retry grows exponentially (honouring {@code RateLimit-Reset} on 429), + * bounded by a total retry budget.
  8. + *
+ */ +class ResilientAuthleteApiInvocationHandler implements InvocationHandler +{ + private static final Logger logger = + LoggerFactory.getLogger(ResilientAuthleteApiInvocationHandler.class); + + private final AuthleteApi delegate; + private final AuthleteCacheableMethods cacheable; + private final AuthleteResponseCache cache; + private final AuthleteRetryPolicy retry; + private final AuthleteBackoff backoff; + private final AuthleteCircuitBreakerRegistry breakers; + + private final boolean cacheEnabled; + private final boolean retryEnabled; + private final boolean breakerEnabled; + private final int maxAttempts; + private final long maxTotalMillis; + + + ResilientAuthleteApiInvocationHandler(AuthleteApi delegate, ResilienceConfig config) + { + this.delegate = delegate; + this.cacheable = new AuthleteCacheableMethods(config); + this.cache = new AuthleteResponseCache(config.getCacheStaleMillis(), config.getCacheMaxEntries()); + this.retry = new AuthleteRetryPolicy(); + this.backoff = new AuthleteBackoff( + config.getRetryBaseDelayMillis(), + config.getRetryMaxTotalMillis(), + config.getRetryJitterMillis()); + this.breakers = new AuthleteCircuitBreakerRegistry(config); + + this.cacheEnabled = config.isCacheEnabled(); + this.retryEnabled = config.isRetryEnabled(); + this.breakerEnabled = config.isBreakerEnabled(); + this.maxAttempts = Math.max(1, config.getRetryMaxAttempts()); + this.maxTotalMillis = config.getRetryMaxTotalMillis(); + } + + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable + { + // Methods inherited from Object (equals/hashCode/toString) are handled + // locally and never forwarded to Authlete. + if (method.getDeclaringClass() == Object.class) + { + return invokeObjectMethod(proxy, method, args); + } + + CachePolicy policy = cacheEnabled ? cacheable.policyFor(method, args) : null; + + // (1) Serve a fresh cached response without touching the network. + if (policy != null) + { + Object fresh = cache.getFresh(policy.key); + + if (fresh != null) + { + return fresh; + } + } + + AuthleteCircuitBreaker breaker = breakerEnabled ? breakers.forMethod(method.getName()) : null; + + long start = System.currentTimeMillis(); + int attempt = 0; + AuthleteApiException lastError = null; + + while (true) + { + attempt++; + + // (2) Circuit breaker gate: fail fast when open. + if (breaker != null && !breaker.allowRequest()) + { + Object stale = serveStale(policy, method, "circuit open"); + + if (stale != null) + { + return stale; + } + + throw (lastError != null) ? lastError : circuitOpenException(method); + } + + try + { + Object result = method.invoke(delegate, args); + + if (breaker != null) + { + breaker.recordSuccess(); + } + + if (policy != null) + { + cache.put(policy.key, result, effectiveTtl(policy, result)); + } + + // Best-effort local eviction: once a revocation goes through, + // this instance must stop serving cached introspection results + // that still report the token as active. + if (cacheEnabled && "revocation".equals(method.getName())) + { + evictIntrospectionEntriesForRevokedToken(args); + } + + return result; + } + catch (InvocationTargetException ite) + { + Throwable cause = ite.getCause(); + + // Only AuthleteApiException participates in retry/breaker logic; + // anything else is an unexpected error and is propagated as-is, + // after releasing any half-open trial slot reserved by + // allowRequest() (the error says nothing about backend health). + if (!(cause instanceof AuthleteApiException)) + { + if (breaker != null) + { + breaker.releaseTrial(); + } + + throw (cause != null) ? cause : ite; + } + + AuthleteApiException ae = (AuthleteApiException) cause; + lastError = ae; + + int status = ae.getStatusCode(); + boolean isTransient = retry.isTransient(status); + + // (3) Only transient failures count toward the breaker. A + // permanent 4xx proves the backend is up and answering, so it + // counts as a success (closing a half-open breaker and + // releasing the trial slot). + if (breaker != null) + { + if (isTransient) + { + breaker.recordFailure(); + } + else + { + breaker.recordSuccess(); + } + } + + // (4) Retry transient failures with exponential backoff, within budget. + if (retryEnabled && isTransient && attempt < maxAttempts) + { + Long reset = (status == 429) + ? retry.rateLimitResetMillis(ae.getResponseHeaders()) : null; + + long delay = backoff.delayMillis(attempt, reset); + long elapsed = System.currentTimeMillis() - start; + + if (elapsed + delay <= maxTotalMillis) + { + logger.debug("Authlete API {} failed (status={}, attempt={}); retrying in {} ms.", + method.getName(), status, attempt, delay); + + if (sleep(delay)) + { + continue; + } + } + } + + // Exhausted retries (or permanent error): try a stale fallback for + // transient failures, otherwise surface the original exception. + if (isTransient) + { + Object stale = serveStale(policy, method, "transient failure, retries exhausted"); + + if (stale != null) + { + return stale; + } + } + + throw ae; + } + } + } + + + /** + * Compute the TTL to store a freshly fetched value under, capping + * introspection results so a cached entry never reports a token as active + * past its own expiry. + */ + private long effectiveTtl(CachePolicy policy, Object result) + { + long ttl = policy.ttlMillis; + + if (policy.capByTokenExpiry && result instanceof IntrospectionResponse) + { + long expiresAt = ((IntrospectionResponse) result).getExpiresAt(); + + if (expiresAt > 0) + { + long untilExpiry = expiresAt - System.currentTimeMillis(); + + // Already expired: do not cache at all. + if (untilExpiry <= 0) + { + return 0; + } + + ttl = Math.min(ttl, untilExpiry); + } + } + + return ttl; + } + + + /** + * Drop every cached introspection entry for the token named in a + * successful revocation request, so this instance stops reporting the + * token as active right away instead of waiting for the TTL. + * + *

+ * Introspection cache keys start with the raw token, so a prefix match + * finds all of them; standard-introspection keys embed the token inside + * the form parameters, so a contains match is used there. This is + * best-effort and local only: other instances still rely on the short + * TTL, and a refresh-token revocation cannot evict the access tokens + * Authlete revokes alongside it. + *

+ */ + private void evictIntrospectionEntriesForRevokedToken(Object[] args) + { + String token = revokedToken(args); + + if (token == null || token.isEmpty()) + { + return; + } + + String prefix = "introspection::" + token + "|"; + + int removed = cache.removeIf(key -> + key.startsWith(prefix) + || (key.startsWith("standardIntrospection::") && key.contains(token))); + + if (removed > 0) + { + logger.debug("Evicted {} cached introspection entries for a revoked token.", removed); + } + } + + + /** + * Extract the {@code token} request parameter from the revocation + * request's form-encoded parameters, or {@code null} if absent. + */ + private static String revokedToken(Object[] args) + { + if (args == null || args.length == 0 || !(args[0] instanceof RevocationRequest)) + { + return null; + } + + String parameters = ((RevocationRequest) args[0]).getParameters(); + + if (parameters == null) + { + return null; + } + + for (String pair : parameters.split("&")) + { + int eq = pair.indexOf('='); + + if (eq < 0 || !"token".equals(pair.substring(0, eq))) + { + continue; + } + + String value = pair.substring(eq + 1); + + try + { + return URLDecoder.decode(value, "UTF-8"); + } + catch (UnsupportedEncodingException e) + { + // UTF-8 is always supported; fall back to the raw value. + return value; + } + } + + return null; + } + + + private Object serveStale(CachePolicy policy, Method method, String reason) + { + if (policy == null) + { + return null; + } + + Object stale = cache.getStale(policy.key); + + if (stale != null) + { + logger.warn("Serving stale cached response for Authlete API {} ({}).", + method.getName(), reason); + } + + return stale; + } + + + /** + * Sleep for the given duration. Returns {@code false} if interrupted, in + * which case the caller should stop retrying. + */ + private boolean sleep(long millis) + { + if (millis <= 0) + { + return true; + } + + try + { + Thread.sleep(millis); + return true; + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + return false; + } + } + + + private static AuthleteApiException circuitOpenException(Method method) + { + return new AuthleteApiException( + "Circuit breaker is open for Authlete API '" + method.getName() + + "'; failing fast to protect the service.", + 503, "Service Unavailable", null); + } + + + private Object invokeObjectMethod(Object proxy, Method method, Object[] args) + { + switch (method.getName()) + { + case "equals": + return proxy == args[0]; + + case "hashCode": + return System.identityHashCode(proxy); + + case "toString": + default: + return "ResilientAuthleteApi[" + delegate + "]"; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/util/CertValidator.java b/src/main/java/com/authlete/jaxrs/server/util/CertValidator.java new file mode 100644 index 0000000..4e59f41 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/util/CertValidator.java @@ -0,0 +1,215 @@ +/* + * Copyright (C) 2021 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.util; + + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.InvalidAlgorithmParameterException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertPath; +import java.security.cert.CertPathValidator; +import java.security.cert.CertPathValidatorException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.PKIXCertPathValidatorResult; +import java.security.cert.PKIXParameters; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import jakarta.servlet.http.HttpServletRequest; +import com.authlete.jakarta.util.CertificateUtils; + + +public class CertValidator +{ + private static final CertificateFactory sCertificateFactory = getCertificateFactoryInstance(); + private static final CertPathValidator sCertPathValidator = getCertPathValidatorInstance(); + private final PKIXParameters mParameters; + + + private static CertificateFactory getCertificateFactoryInstance() + { + try + { + return CertificateFactory.getInstance("X.509"); + } + catch (CertificateException e) + { + // This won't happen. + e.printStackTrace(); + return null; + } + } + + + private static CertPathValidator getCertPathValidatorInstance() + { + try + { + return CertPathValidator.getInstance("PKIX"); + } + catch (NoSuchAlgorithmException e) + { + // This won't happen. + e.printStackTrace(); + return null; + } + } + + + private static CertificateFactory certificateFactory() + { + return sCertificateFactory; + } + + + private static CertPathValidator certPathValidator() + { + return sCertPathValidator; + } + + + private static PKIXParameters createParameters(Path... anchorCertificates) + throws CertificateException, InvalidAlgorithmParameterException, IOException + { + Set anchors = new HashSet<>(); + + for (Path anchorCertificate : anchorCertificates) + { + anchors.add(createTrustAnchor(anchorCertificate)); + } + + PKIXParameters params = new PKIXParameters(anchors); + params.setRevocationEnabled(false); + + return params; + } + + + private static TrustAnchor createTrustAnchor(Path anchorCertificate) + throws CertificateException, IOException + { + return new TrustAnchor(createCertificate(anchorCertificate), null); + } + + + private static X509Certificate createCertificate(Path certificate) + throws CertificateException, IOException + { + try (InputStream in = Files.newInputStream(certificate)) + { + return (X509Certificate)certificateFactory().generateCertificate(in); + } + } + + + public CertValidator(Path...anchorCertificates) + throws CertificateException, InvalidAlgorithmParameterException, + NoSuchAlgorithmException, IOException + { + mParameters = createParameters(anchorCertificates); + } + + + public PKIXCertPathValidatorResult validate(CertPath certPath) + throws CertPathValidatorException, InvalidAlgorithmParameterException + { + return (PKIXCertPathValidatorResult) + certPathValidator().validate(certPath, mParameters); + } + + + public PKIXCertPathValidatorResult validate(List certificates) + throws CertPathValidatorException, InvalidAlgorithmParameterException, CertificateException + { + return validate(certificateFactory().generateCertPath(certificates)); + } + + + public PKIXCertPathValidatorResult validate(String... certificates) + throws CertPathValidatorException, InvalidAlgorithmParameterException, CertificateException + { + List certs = new ArrayList<>(certificates.length); + + for (String certificate : certificates) + { + certs.add(toCertificate(certificate)); + } + + return validate(certs); + } + + + public PKIXCertPathValidatorResult validate(HttpServletRequest request) throws GeneralSecurityException + { + // Extract the chain of the client certificate. + String[] chain = CertificateUtils.extractChain(request); + + // If no certificate chain is included. + if (chain == null || chain.length == 0) + { + throw new GeneralSecurityException( + "The HTTP request does not contain a certificate chain."); + } + + return validate(chain); + } + + + private static Certificate toCertificate(String certificate) throws CertificateException + { + certificate = normalizeCertificate(certificate); + + try (InputStream in = new ByteArrayInputStream(certificate.getBytes(StandardCharsets.UTF_8))) + { + return certificateFactory().generateCertificate(in); + } + catch (IOException e) + { + // This won't happen. + e.printStackTrace(); + return null; + } + } + + + private static String normalizeCertificate(String certificate) + { + String pem = certificate.replaceAll("\\s+(?!CERTIFICATE-----)", "\n").trim(); + + if (pem.startsWith("-----BEGIN CERTIFICATE")) + { + return pem.trim(); + } + + return new StringBuilder() + .append("-----BEGIN CERTIFICATE-----\n") + .append(pem) + .append("\n-----END CERTIFICATE-----") + .toString(); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/util/ExceptionUtil.java b/src/main/java/com/authlete/jaxrs/server/util/ExceptionUtil.java new file mode 100644 index 0000000..b2668c0 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/util/ExceptionUtil.java @@ -0,0 +1,249 @@ +/* + * Copyright (C) 2019-2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.util; + + +import static com.authlete.jaxrs.server.util.ResponseUtil.badRequest; +import static com.authlete.jaxrs.server.util.ResponseUtil.badRequestJson; +import static com.authlete.jaxrs.server.util.ResponseUtil.forbidden; +import static com.authlete.jaxrs.server.util.ResponseUtil.forbiddenJson; +import static com.authlete.jaxrs.server.util.ResponseUtil.internalServerError; +import static com.authlete.jaxrs.server.util.ResponseUtil.internalServerErrorJson; +import static com.authlete.jaxrs.server.util.ResponseUtil.notFound; +import static com.authlete.jaxrs.server.util.ResponseUtil.unauthorized; +import java.util.Map; +import jakarta.ws.rs.WebApplicationException; +import org.glassfish.jersey.server.mvc.Viewable; + + +/** + * Utility class for exceptions. + * + * @author Hideki Ikeda + */ +public class ExceptionUtil +{ + /** + * Create an exception indicating "400 Bad Request". + * + * @param entity + * An entity to contain in the response of the exception. + * + * @return + * An exception indicating "400 Bad Request". + */ + public static WebApplicationException badRequestException(String entity) + { + return new WebApplicationException(entity, badRequest(entity)); + } + + + /** + * Create an exception indicating "400 Bad Request" in application/json format. + * + * @param entity + * An entity to contain in the response of the exception. + * + * @return + * An exception indicating "400 Bad Request". + */ + public static WebApplicationException badRequestExceptionJson(String entity) + { + return badRequestExceptionJson(entity, /* headers */ null); + } + + + public static WebApplicationException badRequestExceptionJson(String entity, Map headers) + { + return new WebApplicationException(entity, badRequestJson(entity, headers)); + } + + + /** + * Create an exception indicating "400 Bad Request". + * + * @param entity + * An entity to contain in the response of the exception. + * + * @return + * An exception indicating "400 Bad Request". + */ + public static WebApplicationException badRequestException(Viewable entity) + { + return new WebApplicationException(badRequest(entity)); + } + + + /** + * Create an exception indicating "401 Unauthorized". + * + * @param entity + * An entity to contain in the response of the exception. + * + * @param challenge + * The value of the "WWW-Authenticate" header of the response of the + * exception. + * + * @return + * An exception indicating "401 Unauthorized". + */ + public static WebApplicationException unauthorizedException(String entity, String challenge) + { + return unauthorizedException(entity, challenge, /* headers */ null); + } + + + public static WebApplicationException unauthorizedException(String entity, String challenge, Map headers) + { + return new WebApplicationException(entity, unauthorized(entity, challenge, headers)); + } + + + /** + * Create an exception indicating "401 Unauthorized". + * + * @param entity + * An entity to contain in the response of the exception. + * + * @param challenge + * The value of the "WWW-Authenticate" header of the response of the + * exception. + * + * @return + * An exception indicating "401 Unauthorized". + */ + public static WebApplicationException unauthorizedException(Viewable entity, String challenge) + { + return new WebApplicationException(unauthorized(entity, challenge)); + } + + /** + * Create an exception indicating "403 Forbidden". + * + * @param entity + * An entity to contain in the response of the exception. + * + * @return + * An exception indicating "403 Forbidden". + */ + public static WebApplicationException forbiddenException(final String entity) + { + return new WebApplicationException(entity, forbidden(entity)); + } + + + /** + * Create an exception indicating "403 Forbidden" in application/json format. + * + * @param entity + * An entity to contain in the response of the exception. + * + * @return + * An exception indicating "403 Forbidden". + */ + public static WebApplicationException forbiddenExceptionJson(final String entity) + { + return forbiddenExceptionJson(entity, /* headers */ null); + } + + + public static WebApplicationException forbiddenExceptionJson(final String entity, Map headers) + { + return new WebApplicationException(entity, forbiddenJson(entity, headers)); + } + + + /** + * Create an exception indicating "404 Not Found". + * + * @param entity + * An entity to contain in the response of the exception. + * + * @return + * An exception indicating "404 Not Found". + */ + public static WebApplicationException notFoundException(String entity) + { + return new WebApplicationException(entity, notFound(entity)); + } + + + /** + * Create an exception indicating "404 Not Found". + * + * @param entity + * An entity to contain in the response of the exception. + * + * @return + * An exception indicating "404 Not Found". + */ + public static WebApplicationException notFoundException(Viewable entity) + { + return new WebApplicationException(notFound(entity)); + } + + + /** + * Create an exception indicating "500 Internal Server Error". + * + * @param entity + * An entity to contain in the response of the exception. + * + * @return + * An exception indicating "500 Internal Server Error". + */ + public static WebApplicationException internalServerErrorException(String entity) + { + return new WebApplicationException(entity, internalServerError(entity)); + } + + + /** + * Create an exception indicating "500 Internal Server Error" in application/json format. + * + * @param entity + * An entity to contain in the response of the exception. + * + * @return + * An exception indicating "500 Internal Server Error". + */ + public static WebApplicationException internalServerErrorExceptionJson(String entity) + { + return internalServerErrorExceptionJson(entity, /* headers */ null); + } + + + public static WebApplicationException internalServerErrorExceptionJson(String entity, Map headers) + { + return new WebApplicationException(entity, internalServerErrorJson(entity, headers)); + } + + + /** + * Create an exception indicating "500 Internal Server Error". + * + * @param entity + * An entity to contain in the response of the exception. + * + * @return + * An exception indicating "500 Internal Server Error". + */ + public static WebApplicationException internalServerErrorException(Viewable entity) + { + return new WebApplicationException(internalServerError(entity)); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/util/ProcessingUtil.java b/src/main/java/com/authlete/jaxrs/server/util/ProcessingUtil.java new file mode 100644 index 0000000..ff1adf9 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/util/ProcessingUtil.java @@ -0,0 +1,80 @@ +package com.authlete.jaxrs.server.util; + + +import static com.authlete.jaxrs.server.util.ExceptionUtil.badRequestException; +import java.util.Date; +import java.util.Map; +import java.util.stream.Collectors; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import jakarta.ws.rs.core.MultivaluedMap; +import com.authlete.common.types.User; +import com.authlete.jaxrs.server.db.UserDao; + + +public class ProcessingUtil +{ + + @SuppressWarnings("unchecked") + public static Map flattenMultivaluedMap(final MultivaluedMap multimap) + { + return multimap.entrySet().stream() + .filter(e -> e.getValue() != null && !e.getValue().isEmpty()) + .map(e -> new Object[]{e.getKey(), e.getValue().get(0)}) + .collect(Collectors.toMap(e -> (K)e[0], e -> (V)e[1])); + } + + + public static boolean fromFormCheckbox(final Map map, final K key) + { + return "on".equals(map.getOrDefault(key, "off")); + } + + + /** + * Get the existing session. + */ + public static HttpSession getSession(HttpServletRequest request) + { + // Get the existing session. + HttpSession session = request.getSession(false); + + // If there exists a session. + if (session != null) + { + // OK. + return session; + } + + // A session does not exist. Make a response of "400 Bad Request". + throw badRequestException("A session does not exist."); + } + + + /** + * Look up an end-user. + */ + public static User getUser(HttpSession session, MultivaluedMap parameters) + { + // Look up the user in the session to see if they're already logged in. + User sessionUser = (User) session.getAttribute("user"); + + if (sessionUser != null) + { + return sessionUser; + } + + // Look up an end-user who has the login credentials. + User loginUser = UserDao.getByCredentials(parameters.getFirst("loginId"), + parameters.getFirst("password")); + + if (loginUser != null) + { + session.setAttribute("user", loginUser); + session.setAttribute("authTime", new Date()); + } + + return loginUser; + } + +} diff --git a/src/main/java/com/authlete/jaxrs/server/util/ResponseUtil.java b/src/main/java/com/authlete/jaxrs/server/util/ResponseUtil.java new file mode 100644 index 0000000..cbb9e01 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/util/ResponseUtil.java @@ -0,0 +1,505 @@ +/* + * Copyright (C) 2019-2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.util; + + +import java.util.Map; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.ResponseBuilder; +import jakarta.ws.rs.core.Response.Status; +import org.glassfish.jersey.server.mvc.Viewable; + + +/** + * Utility class for responses. + * + * @author Hideki Ikeda + */ +public class ResponseUtil +{ + /** + * {@code "text/html;charset=UTF-8"} + */ + private static final MediaType MEDIA_TYPE_HTML = + MediaType.TEXT_HTML_TYPE.withCharset("UTF-8"); + + + /** + * {@code "text/plain;charset=UTF-8"} + */ + private static final MediaType MEDIA_TYPE_PLAIN = + MediaType.TEXT_PLAIN_TYPE.withCharset("UTF-8"); + + /** + * {@code "application/json;charset=UTF-8"} + */ + private static final MediaType MEDIA_TYPE_JSON = + MediaType.APPLICATION_JSON_TYPE.withCharset("UTF-8"); + + /** + * {@code "application/jwt"} + */ + private static final MediaType MEDIA_TYPE_JWT = + new MediaType("application", "jwt"); + + + + /** + * Build a "text/plain" response of "200 OK". + * + * @param entity + * A string entity to contain in the response. + * + * @return + * A "text/plain" response of "200 OK". + */ + public static Response ok(String entity) + { + return ok(entity, /* headers */ null); + } + + + public static Response ok(String entity, Map headers) + { + return builderForTextPlain(Status.OK, entity, headers).build(); + } + + + /** + * Build an "application/json" response of "200 OK". + * + * @param entity + * A string entity to contain in the response. + * + * @return + * An "application/json" response of "200 OK". + */ + public static Response okJson(String entity) + { + return okJson(entity, /* headers */ null); + } + + + public static Response okJson(String entity, Map headers) + { + return builderForJson(Status.OK, entity, headers).build(); + } + + + public static Response okJwt(String entity, Map headers) + { + return builderForJwt(Status.OK, entity, headers).build(); + } + + + /** + * Build a "text/html" response of "200 OK". + * + * @param entity + * A {@link Viewable} entity to contain in the response. + * + * @return + * A "text/html" response of "200 OK". + */ + public static Response ok(Viewable entity) + { + return ok(entity, /* headers */ null); + } + + + public static Response ok(Viewable entity, Map headers) + { + return builderForTextHtml(Status.OK, entity, headers).build(); + } + + + /** + * Build an "application/json" response of "202 ACCEPTED". + * + * @param entity + * A string entity to contain in the response. + * + * @return + * An "application/json" response of "202 ACCEPTED". + */ + public static Response acceptedJson(String entity) + { + return acceptedJson(entity, /* headers */ null); + } + + + public static Response acceptedJson(String entity, Map headers) + { + return builderForJson(Status.ACCEPTED, entity, headers).build(); + } + + + public static Response acceptedJwt(String entity, Map headers) + { + return builderForJwt(Status.ACCEPTED, entity, headers).build(); + } + + + /** + * Build a response of "204 No Content". + * + * @return + * A response of "204 No Content". + */ + public static Response noContent() + { + return Response.noContent().build(); + } + + + /** + * Build a "text/plain" response of "400 Bad Request". + * + * @param entity + * A string entity to contain in the response. + * + * @return + * A "text/plain" response of "400 Bad Request". + */ + public static Response badRequest(String entity) + { + return badRequest(entity, /* headers */ null); + } + + + public static Response badRequest(String entity, Map headers) + { + return builderForTextPlain(Status.BAD_REQUEST, entity, headers).build(); + } + + + /** + * Build an "application/json" response of "400 Bad Request". + * + * @param entity + * A string entity to contain in the response. + * + * @return + * An "application/json" response of "400 Bad Request". + */ + public static Response badRequestJson(String entity) + { + return badRequestJson(entity, /* headers */ null); + } + + + public static Response badRequestJson(String entity, Map headers) + { + return builderForJson(Status.BAD_REQUEST, entity, headers).build(); + } + + + /** + * Build a "text/html" response of "400 Bad Request". + * + * @param entity + * A {@link Viewable} entity to contain in the response. + * + * @return + * A "text/html" response of "400 Bad Request". + */ + public static Response badRequest(Viewable entity) + { + return badRequest(entity, /* headers */ null); + } + + + public static Response badRequest(Viewable entity, Map headers) + { + return builderForTextHtml(Status.BAD_REQUEST, entity, headers).build(); + } + + + /** + * Build a "text/plain" response of "401 Unauthorized". + * + * @param entity + * A string entity to contain in the response. + * + * @param challenge + * The value of the "WWW-Authenticate" header of the response. + * + * @return + * A "text/plain" response of "401 Unauthorized". + */ + public static Response unauthorized(String entity, String challenge) + { + return unauthorized(entity, challenge, /* headers */ null); + } + + + public static Response unauthorized( + String entity, String challenge, Map headers) + { + return builderForTextPlain(Status.UNAUTHORIZED, entity, headers) + .header(HttpHeaders.WWW_AUTHENTICATE, challenge) + .build(); + } + + + /** + * Build a "text/html" response of "401 Unauthorized". + * + * @param entity + * A {@link Viewable} entity to contain in the response. + * + * @param challenge + * The value of the "WWW-Authenticate" header of the response. + * + * @return + * A "text/html" response of "401 Unauthorized". + */ + public static Response unauthorized(Viewable entity, String challenge) + { + return unauthorized(entity, challenge, /* headers */ null); + } + + + public static Response unauthorized( + Viewable entity, String challenge, Map headers) + { + return builderForTextHtml(Status.UNAUTHORIZED, entity, headers) + .header(HttpHeaders.WWW_AUTHENTICATE, challenge) + .build(); + } + + + /** + * Build a "text/plain" response of "403 Forbidden". + * + * @param entity + * A string entity to contain in the response. + * + * @return + * An "text/plain" response of "403 Forbidden". + */ + public static Response forbidden(String entity) + { + return forbidden(entity, /* headers */ null); + } + + + public static Response forbidden(String entity, Map headers) + { + return builderForTextPlain(Status.FORBIDDEN, entity, headers).build(); + } + + + /** + * Build an "application/json" response of "403 Forbidden". + * + * @param entity + * A string entity to contain in the response. + * + * @return + * An "application/json" response of "403 Forbidden". + */ + public static Response forbiddenJson(String entity) + { + return forbiddenJson(entity, /* headers */ null); + } + + + public static Response forbiddenJson(String entity, Map headers) + { + return builderForJson(Status.FORBIDDEN, entity, headers).build(); + } + + + /** + * Build a "text/plain" response of "404 Not Found". + * + * @param entity + * A string entity to contain in the response. + * + * @return + * A "text/plain" response of "404 Not Found". + */ + public static Response notFound(String entity) + { + return notFound(entity, /* headers */ null); + } + + + public static Response notFound(String entity, Map headers) + { + return builderForTextPlain(Status.NOT_FOUND, entity, headers).build(); + } + + + /** + * Build an "application/json" response of "404 Not Found". + * + * @param entity + * A string entity to contain in the response. + * + * @return + * An "application/json" response of "404 Not Found". + */ + public static Response notFoundJson(String entity) + { + return notFoundJson(entity, /* headers */ null); + } + + + public static Response notFoundJson(String entity, Map headers) + { + return builderForJson(Status.NOT_FOUND, entity, headers).build(); + } + + + /** + * Build a "text/html" response of "404 Not Found". + * + * @param entity + * A {@link Viewable} entity to contain in the response. + * + * @return + * A "text/html" response of "404 Not Found". + */ + public static Response notFound(Viewable entity) + { + return notFound(entity, /* headers */ null); + } + + + public static Response notFound(Viewable entity, Map headers) + { + return builderForTextHtml(Status.NOT_FOUND, entity, headers).build(); + } + + + /** + * Build a "text/plain" response of "500 Internal Server Error". + * + * @param entity + * A string entity to contain in the response. + * + * @return + * A "text/plain" response of "500 Internal Server Error". + */ + public static Response internalServerError(String entity) + { + return internalServerError(entity, /* headers */ null); + } + + + public static Response internalServerError(String entity, Map headers) + { + return builderForTextPlain(Status.INTERNAL_SERVER_ERROR, entity, headers).build(); + } + + + /** + * Build a "text/plain" response of "500 Internal Server Error". + * + * @param entity + * A string entity to contain in the response. + * + * @return + * A "text/plain" response of "500 Internal Server Error". + */ + public static Response internalServerErrorJson(String entity) + { + return internalServerErrorJson(entity, /* headers */ null); + } + + + public static Response internalServerErrorJson(String entity, Map headers) + { + return builderForJson(Status.INTERNAL_SERVER_ERROR, entity, headers).build(); + } + + + /** + * Build a "text/html" response of "500 Internal Server Error". + * + * @param entity + * A {@link Viewable} entity to contain in the response. + * + * @return + * A "text/html" response of "500 Internal Server Error". + */ + public static Response internalServerError(Viewable entity) + { + return internalServerError(entity, /* headers */ null); + } + + + public static Response internalServerError(Viewable entity, Map headers) + { + return builderForTextHtml(Status.INTERNAL_SERVER_ERROR, entity, headers).build(); + } + + + private static ResponseBuilder builderForTextPlain( + Status status, String entity, Map headers) + { + return builder(status, entity, MEDIA_TYPE_PLAIN, headers); + } + + + private static ResponseBuilder builderForTextHtml( + Status status, Viewable entity, Map headers) + { + return builder(status, entity, MEDIA_TYPE_HTML, headers); + } + + + private static ResponseBuilder builderForJson( + Status status, String entity, Map headers) + { + return builder(status, entity, MEDIA_TYPE_JSON, headers); + } + + + private static ResponseBuilder builderForJwt( + Status status, String entity, Map headers) + { + return builder(status, entity, MEDIA_TYPE_JWT, headers); + } + + + private static ResponseBuilder builder( + Status status, Object entity, MediaType type, Map headers) + { + ResponseBuilder builder = Response + .status(status) + .entity(entity) + .type(type); + + // If additional headers are given. + if (headers != null) + { + // For each additional header. + for (Map.Entry header : headers.entrySet()) + { + // Add the header. + builder.header(header.getKey(), header.getValue()); + } + } + + return builder; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/util/ServerProperties.java b/src/main/java/com/authlete/jaxrs/server/util/ServerProperties.java new file mode 100644 index 0000000..9530ed3 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/util/ServerProperties.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.util; + + +import java.util.MissingResourceException; +import java.util.ResourceBundle; + + +/** + * A class to read properties from an external file or system properties. + * + * @author Hideki Ikeda + */ +public class ServerProperties extends TypedSystemProperties +{ + private static final ResourceBundle RESOURCE_BUNDLE; + + + static + { + ResourceBundle bundle = null; + + try + { + bundle = ResourceBundle.getBundle("java-oauth-server"); + } + catch (MissingResourceException mre) + { + // ignore + mre.printStackTrace(); + } + + RESOURCE_BUNDLE = bundle; + } + + + @Override + public String getString(String key, String defaultValue) + { + if (key == null) + { + return defaultValue; + } + + // If the parameter identified by the key exists in the system properties. + if (super.contains(key)) + { + // Use the value of the system property. + return super.getString(key, defaultValue); + } + + // If "java-oauth-server.properties" is not available. + if (RESOURCE_BUNDLE == null) + { + // Use the default value. + return defaultValue; + } + + try + { + // Search "java-oauth-server.properties" for the parameter. + return RESOURCE_BUNDLE.getString(key); + } + catch (MissingResourceException e) + { + // Return the default value. + return defaultValue; + } + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/util/TypedSystemProperties.java b/src/main/java/com/authlete/jaxrs/server/util/TypedSystemProperties.java new file mode 100644 index 0000000..d5b0066 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/util/TypedSystemProperties.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.util; + + +import java.util.Properties; +import com.authlete.common.util.StringBasedTypedProperties; + + +/** + * A class for system properties. + * + * @author Hideki Ikeda + */ +public class TypedSystemProperties extends StringBasedTypedProperties +{ + @Override + public boolean contains(String key) + { + Properties properties = System.getProperties(); + + if (properties == null) + { + return false; + } + + return properties.containsKey(key); + } + + + @Override + public String getString(String key, String defaultValue) + { + if (key == null) + { + return defaultValue; + } + + return System.getProperty(key, defaultValue); + } + + + @Override + public void setString(String key, String value) + { + if (key == null) + { + return; + } + + System.setProperty(key, value); + } + + + @Override + public void remove(String key) + { + if (key == null) + { + return; + } + + setString(key, null); + } + + + @Override + public void clear() + { + throw new UnsupportedOperationException("clear() is not supported."); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/vc/AbstractOrderProcessor.java b/src/main/java/com/authlete/jaxrs/server/vc/AbstractOrderProcessor.java new file mode 100644 index 0000000..b2bb41d --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/vc/AbstractOrderProcessor.java @@ -0,0 +1,511 @@ +/* + * Copyright (C) 2023-2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.vc; + + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import com.authlete.common.dto.CredentialIssuanceOrder; +import com.authlete.common.dto.CredentialRequestInfo; +import com.authlete.common.dto.IntrospectionResponse; +import com.authlete.common.types.User; +import com.authlete.jaxrs.server.db.UserDao; +import com.google.gson.Gson; + + +/** + * A base class for {@link OrderProcessor} implementations. + * + *

+ * The OpenID for Verifiable Credential Issuance 1.0 (OID4VCI 1.0) specification + * introduced numerous breaking changes throughout its development process. Even + * the content of the credential request message body—arguably a core part of + * the specification—shifted repeatedly during discussions and went through + * breaking changes. + *

+ * + *

+ * A major flaw in OID4VCI 1.0 Implementer's Draft 1 (ID1) was that, there are + * cases where the credential configuration could not be identified solely from + * the content of a credential request. Since multiple credential configurations + * may share the same {@code format}, specifying only the {@code format} can be + * insufficient to uniquely determine the credential configuration. Nevertheless, + * under OID4VCI 1.0 ID1, there were situations in which the only available + * approach was to infer the credential configuration from the {@code format} + * parameter included in the credential request. This deficiency had been + * repeatedly pointed out during the early stages of the OID4VCI 1.0 + * specification development discussions, yet OID4VCI 1.0 ID1 was released with + * this flaw unresolved. + *

+ * + *

+ * In contrast, in the final version of OID4VCI 1.0, a credential request is + * required to include either a credential configuration ID or a credential + * identifier. In either case, the specified information allows the credential + * configuration to be uniquely determined. + *

+ * + *

+ * Responses from the Authlete API are also affected by the breaking changes + * described above. + *

+ * + *

+ * When a {@code Service} is configured to support OID4VCI 1.0 Final—that is, + * when the {@code oid4vciVersion} property of the {@code Service} is set to + * {@code "1.0"} or {@code "1.0-Final"}—either the + * {@code credentialConfigurationId} property or the {@code credentialIdentifier} + * property of {@link CredentialRequestInfo} will be populated. These properties + * correspond to the {@code credential_configuration_id} parameter and the + * {@code credential_identifier} parameter of the credential request, respectively. + *

+ * + *

+ * On the other hand, when a {@code Service} is configured to support OID4VCI 1.0 + * ID1—that is, when the {@code oid4vciVersion} property of the {@code Service} + * is not set or is set to {@code "1.0-ID1"}—neither the + * {@code credentialConfigurationId} property nor the {@code credentialIdentifier} + * property of {@link CredentialRequestInfo} will be populated. (Note: Although + * OID4VCI 1.0 ID1 defines a {@code credential_identifier} parameter in credential + * requests, Authlete's implementation of OID4VCI 1.0 ID1 does not support this + * parameter. Therefore, the {@code credentialIdentifier} property of + * {@link CredentialRequestInfo} will not be set.) + *

+ * + *

+ * In OID4VCI 1.0 Final, a RAR object with {@code type=openid_credential} is now + * required to include {@code credential_configuration_id}. As a result, it is + * no longer possible to request a credential that is not associated with any + * credential configuration. Consequently, all "Issuable Credentials" associated + * with an access token issued by Authlete are always tied to exactly one + * credential configuration. Therefore, when a {@code Service} supports OID4VCI + * 1.0 Final, the {@code Map} representing an Issuable Credential always contains + * the {@code credential_configuration_id} key. In addition, it always contains + * {@code credential_identifiers} (an array). + *

+ * + *

+ * By contrast, under OID4VCI 1.0 ID1, a RAR object with + * {@code type=openid_credential} may omit {@code credential_configuration_id} + * (in which case it includes {@code format} instead). For such RAR objects, + * the {@code Map} representing the corresponding Issuable Credential does not + * contain the {@code credential_configuration_id} key. + *

+ * + *

+ * If the credential configuration ID is available, the abstract methods of + * {@link AbstractOrderProcessor} can be implemented in a logical manner. + * Otherwise, the implementation inevitably becomes a compromise. Please keep + * this point in mind when reading the implementation of this class. + *

+ */ +public abstract class AbstractOrderProcessor implements OrderProcessor +{ + private static final String KEY_CREDENTIAL_CONFIGURATION_ID = "credential_configuration_id"; + private static final String KEY_CREDENTIAL_IDENTIFIERS = "credential_identifiers"; + + + @SuppressWarnings("unchecked") + @Override + public CredentialIssuanceOrder toOrder( + OrderContext context, + IntrospectionResponse introspection, + CredentialRequestInfo info) throws VerifiableCredentialException + { + // See "Credential Issuance Order" + // + // 3.5.1. Credential Issuance Order + // https://www.authlete.com/developers/oid4vci/#351-credential-issuance-order + // + + // === Step 1 === + // + // Get the subject (= unique identifier) of the user associated + // with the access token from the access token information. + String subject = introspection.getSubject(); + + // === Step 2 === + // + // Retrieve information about the user identified by the subject + // from the user database. + User user = UserDao.getBySubject(subject); + + // === Step 3 === + // + // Get the information about the issuable credentials associated + // with the access token from the access token information. + List> issuableCredentials = + parseJson(introspection.getIssuableCredentials(), List.class); + + // === Step 4 === + // + // Get the credential information included in the credential request + // from the credential request information. + //Map details = parseJson(info.getDetails(), Map.class); + + // === Step 5 === + // + // Confirm that the access token has the necessary permissions for + // the credential request. + checkPermissions(context, issuableCredentials, info); + + // === Step 6 === + // + // Determine the set of user claims to embed in the VC being issued + // based on the credential information, and get the values of the + // user claims from the dataset retrieved from the user database. + Map claims = + collectClaims(context, issuableCredentials, info, user); + + // === Step 7 === + // + // Build a credential issuance order using the collected data. + CredentialIssuanceOrder order = createOrder(info, claims); + + // The credential issuance order. + return order; + } + + + /** + * Create a credential issuance order. + */ + private CredentialIssuanceOrder createOrder( + CredentialRequestInfo info, Map claims) + { + String payload = (claims != null) ? new Gson().toJson(claims) : null; + boolean deferred = (payload == null); + + return new CredentialIssuanceOrder() + .setRequestIdentifier(info.getIdentifier()) + .setCredentialPayload(payload) + .setIssuanceDeferred(deferred) + .setCredentialDuration(computeCredentialDuration()) + ; + } + + + /** + * Check whether the set of issuable credentials covers the credential + * request. + * + * @param context + * The context in which this order processor is executed. + * + * @param issuableCredentials + * The issuable credentials associated with the access token. + * + * @param info + * The credential request information. + * + * @throws InvalidCredentialRequestException + * The issuable credentials do not cover the credential request, + * the content of the credential request is invalid, or some other + * errors. + */ + private void checkPermissions( + OrderContext context, List> issuableCredentials, + CredentialRequestInfo info) throws InvalidCredentialRequestException + { + // If no issuable credential is associated with the access token. + if (issuableCredentials == null) + { + throw new InvalidCredentialRequestException( + "No credential can be issued with the access token."); + } + + // If the Service is configured for OID4VCI 1.0 ID1. + if (is10ID1(info)) + { + checkPermissions10ID1(issuableCredentials, info); + } + else + { + checkPermissions10Final(issuableCredentials, info); + } + } + + + /** + * Check if the credential request information indicates that the Service + * is configured for OID4VCI 1.0 ID1. + */ + private static boolean is10ID1(CredentialRequestInfo info) + { + // If the Service is configured to support OID4VCI 1.0 Final, + // neither credentialConfigurationId or credentialIdentifier + // of the CredentialRequestInfo is set. + // + // NOTE: The credential_identifier request parameter has existed + // since OID4VCI 1.0 ID1, but Authlete's implementation of + // OID4VCI 1.0 ID1 does not support this request parameter. + return info.getCredentialConfigurationId() == null && + info.getCredentialIdentifier() == null; + } + + + /** + * Check whether the set of issuable credentials covers the credential + * request under the configuration of OID4VCI 1.0 ID1. + * + * @param issuableCredentials + * The issuable credentials associated with the access token. + * + * @param info + * The credential request information. + * + * @throws InvalidCredentialRequestException + * The issuable credentials do not cover the credential request, + * the content of the credential request is invalid, or some other + * errors. + */ + protected abstract void checkPermissions10ID1( + List> issuableCredentials, CredentialRequestInfo info) + throws InvalidCredentialRequestException; + + + /** + * Check whether the set of issuable credentials covers the credential + * request under the configuration of OID4VCI 1.0 Final. + * + * @param issuableCredentials + * The issuable credentials associated with the access token. + * + * @param info + * The credential request information. + * + * @throws InvalidCredentialRequestException + * The issuable credentials do not cover the credential request, + * the content of the credential request is invalid, or some other + * errors. + */ + private void checkPermissions10Final( + List> issuableCredentials, CredentialRequestInfo info) + throws InvalidCredentialRequestException + { + // Either of the following is available. + String credentialConfigurationId = info.getCredentialConfigurationId(); + String credentialIdentifier = info.getCredentialIdentifier(); + + // For each issuable credential. + for (Map issuableCredential : issuableCredentials) + { + if (credentialConfigurationId != null && + hasCredentialConfigurationId(issuableCredential, credentialConfigurationId)) + { + // OK. The access token has the permission to obtain a + // credential identified by the credential configuration ID. + return; + } + + if (credentialIdentifier != null && + hasCredentialIdentifier(issuableCredential, credentialIdentifier)) + { + // OK. The access token has the permission to obtain a + // credential identified by the credential identifier. + return; + } + } + + throw new InvalidCredentialRequestException( + "The access token does not have permissions to request the credential."); + } + + + /** + * Collect the requested claims. + * + * @param context + * The context in which this order processor is executed. + * + * @param issuableCredentials + * The issuable credentials associated with the access token. + * + * @param info + * The credential request information. + * + * @param user + * The user associated with the access token. + * + * @return + * The key-value pairs representing the requested claims. + * If null is returned, the credential issuance will be deferred. + */ + private Map collectClaims( + OrderContext context, List> issuableCredentials, + CredentialRequestInfo info, User user) throws VerifiableCredentialException + { + // If the Service is configured to support OID4VCI 1.0 ID1. + if (is10ID1(info)) + { + return collectClaims10ID1(context, issuableCredentials, info, user); + } + else + { + return collectClaims10Final(context, issuableCredentials, info, user); + } + } + + + /** + * Collect the requested claims under the configuration of OID4VCI 1.0 ID1. + * + * @param context + * The context in which this order processor is executed. + * + * @param issuableCredentials + * The issuable credentials associated with the access token. + * + * @param info + * The credential request information. + * + * @param user + * The user associated with the access token. + * + * @return + * The key-value pairs representing the requested claims. + * If null is returned, the credential issuance will be deferred. + */ + protected abstract Map collectClaims10ID1( + OrderContext context, List> issuableCredentials, + CredentialRequestInfo info, User user) throws VerifiableCredentialException; + + + /** + * Collect the requested claims under the configuration of OID4VCI 1.0 Final. + * + * @param context + * The context in which this order processor is executed. + * + * @param issuableCredentials + * The issuable credentials associated with the access token. + * + * @param info + * The credential request information. + * + * @param user + * The user associated with the access token. + * + * @return + * The key-value pairs representing the requested claims. + * If null is returned, the credential issuance will be deferred. + */ + protected abstract Map collectClaims10Final( + OrderContext context, List> issuableCredentials, + CredentialRequestInfo info, User user) throws VerifiableCredentialException; + + + /** + * Compute the credential duration in seconds. + * + *

+ * The default implementation of this method returns 0, which tells + * Authlete to try to generate a VC that does not expire. Subclasses + * may override this method to set duration. + *

+ * + * @return + * The credential duration in seconds. + */ + protected long computeCredentialDuration() + { + return 0; + } + + + /** + * Convert the given JSON to an instance of the specified Java class. + */ + static T parseJson(String json, Class klass) + { + return new Gson().fromJson(json, klass); + } + + + static Map findMatchingIssuableCredential( + List> issuableCredentials, CredentialRequestInfo info) + { + // Either of the following should be available. + String credentialConfigurationId = info.getCredentialConfigurationId(); + String credentialIdentifier = info.getCredentialIdentifier(); + + // For each issuable credential. + for (Map issuableCredential : issuableCredentials) + { + if (credentialConfigurationId != null) + { + if (hasCredentialConfigurationId(issuableCredential, credentialConfigurationId)) + { + return issuableCredential; + } + } + + if (credentialIdentifier != null) + { + if (hasCredentialIdentifier(issuableCredential, credentialIdentifier)) + { + return issuableCredential; + } + } + } + + return null; + } + + + /** + * Check whether the issuable credential contains the specified credential + * configuration ID. + */ + private static boolean hasCredentialConfigurationId( + Map issuableCredential, String credentialConfigurationId) + { + return Objects.equals( + issuableCredential.get(KEY_CREDENTIAL_CONFIGURATION_ID), + credentialConfigurationId); + } + + + /** + * Check whether the issuable credential contains the specified credential + * identifier. + */ + private static boolean hasCredentialIdentifier( + Map issuableCredential, String credentialIdentifier) + { + List identifiers = (List)issuableCredential.get(KEY_CREDENTIAL_IDENTIFIERS); + + if (identifiers == null) + { + // This should not happen. When the Service is configured for + // OID4VCI 1.0 Final, maps representing Issuable Credentials + // should always contain the "credential_identifiers" key. + return false; + } + + for (Object identifier : identifiers) + { + if (Objects.equals(identifier, credentialIdentifier)) + { + return true; + } + } + + return false; + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/vc/InvalidCredentialRequestException.java b/src/main/java/com/authlete/jaxrs/server/vc/InvalidCredentialRequestException.java new file mode 100644 index 0000000..fc00be4 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/vc/InvalidCredentialRequestException.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.vc; + + +public class InvalidCredentialRequestException extends VerifiableCredentialException +{ + private static final long serialVersionUID = 1L; + + + public InvalidCredentialRequestException() + { + } + + + public InvalidCredentialRequestException(String message) + { + super(message); + } + + + public InvalidCredentialRequestException(Throwable cause) + { + super(cause); + } + + + public InvalidCredentialRequestException(String message, Throwable cause) + { + super(message, cause); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/vc/MdocOrderProcessor.java b/src/main/java/com/authlete/jaxrs/server/vc/MdocOrderProcessor.java new file mode 100644 index 0000000..e42f281 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/vc/MdocOrderProcessor.java @@ -0,0 +1,664 @@ +/* + * Copyright (C) 2023-2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.vc; + + +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import com.authlete.common.dto.CredentialRequestInfo; +import com.authlete.common.types.User; +import com.authlete.mdoc.constants.MDLClaimNames; +import com.authlete.mdoc.constants.MDLConstants; + + +/** + * An implementation of {@link OrderProcessor} for mdoc. + */ +class MdocOrderProcessor extends AbstractOrderProcessor +{ + private static final String KEY_CLAIMS = "claims"; + private static final String KEY_CREDENTIAL_METADATA = "credential_metadata"; + private static final String KEY_DOC_TYPE = "doctype"; + private static final String KEY_FORMAT = "format"; + private static final String KEY_PATH = "path"; + + + @Override + @SuppressWarnings("unchecked") + protected void checkPermissions10ID1( + List> issuableCredentials, CredentialRequestInfo info) + throws InvalidCredentialRequestException + { + // The other properties in the credential request rather than the + // common ones such as credential_configuration_id. + Map requestedCredential = parseJson(info.getDetails(), Map.class); + + // For each issuable credential. + for (Map issuableCredential : issuableCredentials) + { + // If the format of the issuable credential does not match + // the target format. + if (!matchFormat(info.getFormat(), issuableCredential)) + { + continue; + } + + // If the document type of the issuable credential does not + // match that of the requested credential. + if (!matchDocType(issuableCredential, requestedCredential)) + { + continue; + } + + // If the claims of the issuable credentials includes the + // claims of the requested credential. + if (includeClaims(issuableCredential, requestedCredential)) + { + // OK. The credential request is permitted. + return; + } + } + + throw new InvalidCredentialRequestException( + "The access token does not have permissions to request the credential."); + } + + + private static boolean matchFormat( + String format, Map issuableCredential) + { + // The "format" in the issuable credential. + String issuableCredentialFormat = (String)issuableCredential.get(KEY_FORMAT); + + return format.equals(issuableCredentialFormat); + } + + + private static boolean matchDocType( + Map issuableCredential, + Map requestedCredential) + { + // The document type of the issuable credential. + Object issuableCredentialDocType = issuableCredential.get(KEY_DOC_TYPE); + + // The document type of the requested credential. + Object requestedCredentialDocType = requestedCredential.get(KEY_DOC_TYPE); + + // If either or both are not strings. + if (!(issuableCredentialDocType instanceof String) || + !(requestedCredentialDocType instanceof String)) + { + return false; + } + + return issuableCredentialDocType.equals(requestedCredentialDocType); + } + + + @SuppressWarnings("unchecked") + private static boolean includeClaims( + Map issuableCredential, + Map requestedCredential) throws InvalidCredentialRequestException + { + // The claims in the issuable credential. + Object issuableCredentialClaims = issuableCredential.get(KEY_CLAIMS); + + // The claims in the requested credential. + Object requestedCredentialClaims = requestedCredential.get(KEY_CLAIMS); + + // If the credential request does not include the "claims" property. + if (requestedCredentialClaims == null) + { + // Conceptually, any issuable credential includes an empty claim set. + // But note that the issued verifiable credential will include no claim. + return true; + } + + // If the credential request includes the "claims" property but its + // value is not a JSON object. + if (!(requestedCredentialClaims instanceof Map)) + { + throw new InvalidCredentialRequestException( + "The value of the 'claims' property in the credential request is not a JSON object."); + } + + // If the content of the "claims" property in the credential request is empty. + if (((Map)requestedCredentialClaims).isEmpty()) + { + // Conceptually, any issuable credential includes an empty claim set. + // But note that the issued verifiable credential will include no claim. + return true; + } + + // If the code flow reaches here, requestedCredentialClaims contains + // at least one claim. + + // If the issuable credential does include any claims. + if (!(issuableCredentialClaims instanceof Map)) + { + // No claim can be requested. + return false; + } + + // The expected structure of "claims" is as follows. + // + // "namespace1": { + // "claimName1": "claimValue1" + // }, + // "namespace": { + // "claimName2": "claimValue2" + // } + // + // This implementation checks only the first-level and the second-level + // property names. In other words, this implementation checks only the + // name spaces and their top-level claim names. Property names nested + // deeper are not checked. + return includeMap( + (Map)issuableCredentialClaims, + (Map)requestedCredentialClaims, + /* deepest */ 2, /* depth */ 1); + } + + + @SuppressWarnings("unchecked") + private static boolean includeMap( + Map mapA, Map mapB, + int deepest, int depth) + { + for (Map.Entry entryB : mapB.entrySet()) + { + String keyB = entryB.getKey(); + Object valB = entryB.getValue(); + + if (!mapA.containsKey(keyB)) + { + // The mapB contains a key that the mapA does not. + // The mapA does not include the mapB. + return false; + } + + // If it is not necessary to check nested properties. + if (deepest == depth) + { + continue; + } + + // If the entryB does not have nested properties. + if (!(valB instanceof Map)) + { + continue; + } + + Object valA = mapA.get(keyB); + + // If the value in the mapA does not have a nested structure. + if (!(valA instanceof Map)) + { + // The mapB's entry has a nested structure, but the mapA's + // entry does not. The mapA does not include the mapB. + return false; + } + + // Check if the mapA's nested map contains the mapB's nested map. + boolean included = includeMap( + (Map)valA, (Map)valB, + deepest, (depth + 1)); + + if (!included) + { + // The mapA's entry does not include the mapB's entry. + return false; + } + } + + // The mapA contains all the top-level and nested properties + // (up to the deepest level) of the mapB. + return true; + } + + + @Override + @SuppressWarnings("unchecked") + protected Map collectClaims10ID1( + OrderContext context, List> issuableCredentials, + CredentialRequestInfo info, User user) throws VerifiableCredentialException + { + Map requestedCredential = parseJson(info.getDetails(), Map.class); + + // The document type of the requested credential. + String docType = (String)requestedCredential.get(KEY_DOC_TYPE); + + // The requested claims. + Map requestedClaims = + (Map)requestedCredential.get(KEY_CLAIMS); + + // The user's claims for the document type. + Map userClaims = + (Map)user.getAttribute(docType); + if (userClaims == null) + { + userClaims = Collections.emptyMap(); + } + + // Build claims + Map claims = buildClaims(userClaims, requestedClaims); + + // In the case of mdoc, CredentialIssuanceOrder.credentialPayload + // is required to have the following structure. + // + // { + // "doctype": "{doctype}", + // "claims": { + // ... + // } + // } + // + Map payload = new LinkedHashMap<>(); + payload.put(KEY_DOC_TYPE, docType); + payload.put(KEY_CLAIMS, claims); + + return payload; + } + + + @SuppressWarnings("unchecked") + private static Map buildClaims( + Map userClaims, Map requestedClaims) + { + // The structure of userClaims and requestedClaims: + // + // { + // "namespace1": { + // "claimName1": "claimValue1", + // ... + // }, + // "namespace2": { + // "claimName2": "claimValue2", + // ... + // }, + // ... + // } + // + + Map claims = new LinkedHashMap<>(); + + // If the credential request does not include the "claims" property. + if (requestedClaims == null) + { + // The verifiable credential will include no claim. + return claims; + } + + for (Map.Entry requestedNameSpace : requestedClaims.entrySet()) + { + // The name space + String nameSpace = requestedNameSpace.getKey(); + + // If userClaims does not have the name space. + if (!userClaims.containsKey(nameSpace)) + { + continue; + } + + // User claims under the name space. + Object userSubclaims = userClaims.get(nameSpace); + + // Requested claims under the name space. + Object requestedSubclaims = requestedNameSpace.getValue(); + + if (!(userSubclaims instanceof Map) || + !(requestedSubclaims instanceof Map)) + { + continue; + } + + // Extract user subclaims that are requested. + Map subclaims = buildSubclaims( + nameSpace, + (Map)userSubclaims, + (Map)requestedSubclaims); + + claims.put(nameSpace, subclaims); + } + + return claims; + } + + + private static Map buildSubclaims( + String nameSpace, + Map userSubclaims, + Map requestedSubclaims) + { + Map subclaims = new LinkedHashMap<>(); + + // If the name space is "org.iso.18013.5.1". + if (nameSpace.equals(MDLConstants.NAME_SPACE_MDL)) + { + // Add special claims. + addMDLClaims(subclaims, requestedSubclaims); + } + + // Add requested claims to subclaims. Shallow copy. + for (String claimName : requestedSubclaims.keySet()) + { + if (userSubclaims.containsKey(claimName)) + { + subclaims.put(claimName, userSubclaims.get(claimName)); + } + } + + return subclaims; + } + + + private static void addMDLClaims( + Map subclaims, Map requestedSubclaims) + { + // The current time. + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); + + // If the "issue_date" claim is requested. + if (requestedSubclaims.containsKey(MDLClaimNames.ISSUE_DATE)) + { + // "issue_date": 1004("YYYY-MM-DD") + subclaims.put(MDLClaimNames.ISSUE_DATE, toFullDate(now)); + } + + // If the "expiry_date" claim is requested. + if (requestedSubclaims.containsKey(MDLClaimNames.EXPIRY_DATE)) + { + // The expiry date. There is no deep reason for "1 year" here. + // This is just an example. + ZonedDateTime exp = now.plusYears(1); + + // "expiry_date": "YYYY-MM-DD" + subclaims.put(MDLClaimNames.EXPIRY_DATE, toFullDate(exp)); + } + } + + + private static String toFullDate(ZonedDateTime dt) + { + return String.format("cbor:1004(\"%s\")", + dt.format(DateTimeFormatter.ISO_LOCAL_DATE)); + } + + + @Override + protected long computeCredentialDuration() + { + // The current time. + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC).withNano(0); + + // The expiration datetime. There is no deep reason for "1 year" here. + // This is just an example. + ZonedDateTime exp = now.plusYears(1); + + // The seconds between the current time and the expiration datetime. + return ChronoUnit.SECONDS.between(now, exp); + } + + + @Override + @SuppressWarnings("unchecked") + protected Map collectClaims10Final( + OrderContext context, List> issuableCredentials, + CredentialRequestInfo info, User user) throws VerifiableCredentialException + { + // Issuable credential corresponding to the credential request. + Map issuableCredential = + findMatchingIssuableCredential(issuableCredentials, info); + + // The doctype property associated with the issuable credential. + String docType = (String)issuableCredential.get(KEY_DOC_TYPE); + + // The claims supported by the issuable credential. + Map supportedClaims = + extractSupportedClaims(issuableCredential); + + // The user's claims for the document type. + Map userClaims = + (Map)user.getAttribute(docType); + if (userClaims == null) + { + userClaims = Collections.emptyMap(); + } + + // Build claims + Map claims = buildClaims(userClaims, supportedClaims); + + // In the case of mdoc, CredentialIssuanceOrder.credentialPayload + // is required to have the following structure. + // + // { + // "doctype": "{doctype}", + // "claims": { + // ... + // } + // } + // + Map payload = new LinkedHashMap<>(); + payload.put(KEY_DOC_TYPE, docType); + payload.put(KEY_CLAIMS, claims); + + return payload; + } + + + @SuppressWarnings("unchecked") + private static Map extractSupportedClaims(Map issuableCredential) + { + // The expected structure of the supported claims in the + // issuable credential is as follows: + // + // { + // "credential_metadata": { + // ..., + // "claims": [ + // { + // "path": ["namespace1", "claimName1"], + // ... + // }, + // { + // "path": ["namespace1", "claimName2"], + // ... + // }, + // ... + // ] + // } + // } + // + // Example: + // + // { + // "credential_metadata": { + // "claims": [ + // { "path": ["org.iso.18013.5.1", "given_name"] }, + // { "path": ["org.iso.18013.5.1", "family_name"] } + // ] + // } + // } + + // The code below builds the following from the above structure: + // + // { + // "namespace1": { + // "claimName1": {}, + // "claimName2": {}, + // ... + // }, + // ... + // } + // + // Example: + // + // { + // "org.iso.18013.5.1": { + // "given_name": {}, + // "family_name": {} + // } + // } + + // Extract credential_metadata.claims as a JSON array from + // the issuable credential. + List claims = extractCredentialMetadataClaims(issuableCredential); + + if (claims == null) + { + // No supported claims. + return null; + } + + Map supportedClaims = new LinkedHashMap<>(); + + // For each element in the "claims" array. + for (Object claimObject : claims) + { + // If the element is not a JSON object. + if (!(claimObject instanceof Map)) + { + // Unexpected format. + continue; + } + + processClaimObject(supportedClaims, (Map)claimObject); + } + + return supportedClaims; + } + + + @SuppressWarnings("unchecked") + private static List extractCredentialMetadataClaims(Map issuableCredential) + { + // The value of "credential_metadata". + Object metadataObject = issuableCredential.get(KEY_CREDENTIAL_METADATA); + + // If the issuable credential does not contain "credential_metadata", + // or if the value of "credential_metadata" is not a JSON object. + if (!(metadataObject instanceof Map)) + { + // No supported claims. + return null; + } + + // "credential_metadata" as a JSON object. + Map metadata = (Map)metadataObject; + + // The value of "claims" under the "credential_metadata" object. + Object claimsObject = metadata.get(KEY_CLAIMS); + + // If the metadata does not contain "claims", or if the value of + // "claims" is not a JSON array. + if (!(claimsObject instanceof List)) + { + // No supported claims. + return null; + } + + // "claims" as a JSON array. + List claims = (List)claimsObject; + + return claims; + } + + + private static void processClaimObject( + Map supportedClaims, Map claimObject) + { + // Extract "path" as a JSON array from the claim object. + List path = extractPath(claimObject); + + // If the claim object does not contain a valid "path", or + // if the number of elements in the path array is less than 2. + if (path == null || path.size() < 2) + { + // Invalid path. + return; + } + + // The first element in the path array represents a namespace. + String namespace = path.get(0); + + // The second element in the path array represents a top-level claim name. + String claimName = path.get(1); + + processNamespaceClaimName(supportedClaims, namespace, claimName); + } + + + private static List extractPath(Map claimObject) + { + // The value of the "path" in the claim object. + Object pathObject = claimObject.get(KEY_PATH); + + // If the claim object does not contain "path", or if the value of + // "path" is not a JSON array. + if (!(pathObject instanceof List)) + { + // Invalid claim path. + return null; + } + + // For each element in the "path" array. + for (Object element : (List)pathObject) + { + // If the element is not a string. + if (!(element instanceof String)) + { + // Invalid claim path. + return null; + } + } + + // Convert the path object into a string list. + return ((List)pathObject).stream() + .map(String.class::cast) + .collect(Collectors.toList()); + } + + + @SuppressWarnings("unchecked") + private static void processNamespaceClaimName( + Map supportedClaims, String namespace, String claimName) + { + // Obtain the map for the namespace. + Map namespaceObject = + (Map)supportedClaims.get(namespace); + + // If a map for the namespace has not been created yet. + if (namespaceObject == null) + { + // Create a map for the namespace. + namespaceObject = new LinkedHashMap<>(); + supportedClaims.put(namespace, namespaceObject); + } + + // "namespace": { + // "claimName": {} + // } + namespaceObject.put(claimName, Collections.emptyMap()); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/vc/OrderContext.java b/src/main/java/com/authlete/jaxrs/server/vc/OrderContext.java new file mode 100644 index 0000000..085b9b1 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/vc/OrderContext.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.vc; + + +public enum OrderContext +{ + SINGLE, + BATCH, + DEFERRED, + ; +} diff --git a/src/main/java/com/authlete/jaxrs/server/vc/OrderFormat.java b/src/main/java/com/authlete/jaxrs/server/vc/OrderFormat.java new file mode 100644 index 0000000..1d646a8 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/vc/OrderFormat.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2023-2025 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.vc; + + +import java.util.Arrays; + + +/** + * Order formats. + * + *

+ * NOTE: The media type of SD-JWT VC has been changed from {@code vc+sd-jwt} to + * {@code dc+sd-jwt} by OAuth-SD-JWT-VC PR 268: change media type from vc+sd-jwt to dc+sd-jwt. + *

+ * + * @see OAuth-SD-JWT VC PR 268: change media type from vc+sd-jwt to dc+sd-jwt + * + * @see IETF 121 Dublin, SD-JWT/SD-JWT VC, Page 51 + */ +public enum OrderFormat +{ + DC_SD_JWT("dc+sd-jwt", new SdJwtOrderProcessor()), + VC_SD_JWT("vc+sd-jwt", new SdJwtOrderProcessor()), + MDOC("mso_mdoc", new MdocOrderProcessor()), + ; + + + private final String id; + private final OrderProcessor processor; + + + private OrderFormat(String id, OrderProcessor processor) + { + this.id = id; + this.processor = processor; + } + + + public String getId() + { + return id; + } + + + public OrderProcessor getProcessor() + { + return processor; + } + + + public static OrderFormat byId(final String id) + { + return Arrays.stream(OrderFormat.values()) + .filter(format -> format.getId().equals(id)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/vc/OrderProcessor.java b/src/main/java/com/authlete/jaxrs/server/vc/OrderProcessor.java new file mode 100644 index 0000000..6c01d03 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/vc/OrderProcessor.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.vc; + + +import com.authlete.common.dto.CredentialIssuanceOrder; +import com.authlete.common.dto.CredentialRequestInfo; +import com.authlete.common.dto.IntrospectionResponse; + + +public interface OrderProcessor +{ + CredentialIssuanceOrder toOrder( + OrderContext context, + IntrospectionResponse introspection, + CredentialRequestInfo info) throws VerifiableCredentialException; +} diff --git a/src/main/java/com/authlete/jaxrs/server/vc/SdJwtOrderProcessor.java b/src/main/java/com/authlete/jaxrs/server/vc/SdJwtOrderProcessor.java new file mode 100644 index 0000000..a7ff4bd --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/vc/SdJwtOrderProcessor.java @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2023-2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.vc; + + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.authlete.common.dto.CredentialRequestInfo; +import com.authlete.common.types.User; + + +/** + * An implementation of {@link OrderProcessor} for SD-JWT VC. + */ +public class SdJwtOrderProcessor extends AbstractOrderProcessor +{ + private static final String KEY_FORMAT = "format"; + private static final String KEY_SUB = "sub"; + private static final String KEY_VCT = "vct"; + + + @Override + @SuppressWarnings("unchecked") + protected void checkPermissions10ID1( + List> issuableCredentials, CredentialRequestInfo info) + throws InvalidCredentialRequestException + { + // As explained in https://www.authlete.com/developers/oid4vci/, + // it is challenging to implement this step in a manner consistent + // across all implementations due to the flaws of the OID4VCI spec. + + // The implementation here follows "SD-JWT-based Verifiable Credentials" + // as much as possible. + // + // https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/ + + // The credential format requested by the credential request. + String format = info.getFormat(); + + // The other properties in the credential request rather than the + // common ones such as credential_configuration_id. + Map requestedCredential = + parseJson(info.getDetails(), Map.class); + + // The requested credential must contain "vct". + String vct = extractVct(requestedCredential); + + // For each issuable credential. + for (Map issuableCredential : issuableCredentials) + { + // The format of the issuable credential. + String issuableCredentialFormat = (String)issuableCredential.get(KEY_FORMAT); + + // If the format of the requested credential is different from + // the format of the issuable credential + if (!format.equals(issuableCredentialFormat)) + { + continue; + } + + // The "vct" in the issuable credential. + Object value = issuableCredential.get(KEY_VCT); + + // If the "type" property is not available as a string. + if (!(value instanceof String)) + { + continue; + } + + // This implementation of the checkPermissions method is simple. + // If "vct" of the requested credential matches "vct" of any of + // the issuable credentials, it is regarded that the credential + // request is permitted. + if (vct.equals(value)) + { + // The credential request is permitted. + return; + } + } + + throw new InvalidCredentialRequestException( + "The access token does not have permissions to request the credential."); + } + + + private String extractVct( + Map requestedCredential) throws InvalidCredentialRequestException + { + // If the requested credential does not contain "vct". + if (!requestedCredential.containsKey(KEY_VCT)) + { + throw new InvalidCredentialRequestException( + "The credential request does not contain 'vct'."); + } + + // The value of the "vct" property. + Object value = requestedCredential.get(KEY_VCT); + + // If the value of the "vct" property is not a string. + if (!(value instanceof String)) + { + throw new InvalidCredentialRequestException( + "The value of the 'vct' property in the credential request is not a string."); + } + + return (String)value; + } + + + @Override + @SuppressWarnings("unchecked") + protected Map collectClaims10ID1( + OrderContext context, List> issuableCredentials, + CredentialRequestInfo info, User user) throws VerifiableCredentialException + { + Map requestedCredential = parseJson(info.getDetails(), Map.class); + + // The "vct" in the requested credential. + String vctId = (String)requestedCredential.get(KEY_VCT); + + // Find a VerifiableCredentialType corresponding to the vct. + VerifiableCredentialType vct = VerifiableCredentialType.byId(vctId); + + if (vct == null) + { + // The credential type is not supported. + throw new UnsupportedCredentialTypeException(String.format( + "The credential type '%s' is not supported.", vctId)); + } + + // For testing purposes, the credential issuance for a certain user + // (subject = "1003", loginId = "max") is intentionally deferred. + if (context != OrderContext.DEFERRED && user.getSubject().equals("1003")) + { + // Returning null from the collectClaims() method will result in + // issuing a transaction ID instead of a verifiable credential. + return null; + } + + return buildClaims(user, vct); + } + + + @Override + protected long computeCredentialDuration() + { + // 30 days in seconds. + return 30 * 24 * 60 * 60; + } + + + private static Map buildClaims(User user, VerifiableCredentialType vct) + { + // Claims. + Map claims = new LinkedHashMap<>(); + + // "vct" + claims.put(KEY_VCT, vct.getId()); + + // "sub" + claims.put(KEY_SUB, user.getSubject()); + + // The VerifiableCredentialType has a set of claims. + // For each claim in the set. + for (String claimName : vct.getClaims()) + { + // The value of the claim. + Object claimValue = user.getClaim(claimName, null); + + // If the value of the claim is available. + if (claimValue != null) + { + claims.put(claimName, claimValue); + } + } + + return claims; + } + + + @Override + protected Map collectClaims10Final( + OrderContext context, List> issuableCredentials, + CredentialRequestInfo info, User user) throws VerifiableCredentialException + { + // Issuable credential corresponding to the credential request. + Map issuableCredential = + findMatchingIssuableCredential(issuableCredentials, info); + + // The vct property associated with the issuable credential. + String vctId = (String)issuableCredential.get(KEY_VCT); + + // Find a VerifiableCredentialType corresponding to the vct. + VerifiableCredentialType vct = VerifiableCredentialType.byId(vctId); + + if (vct == null) + { + // The vct is not supported. + throw new InvalidCredentialRequestException(String.format( + "The vct '%s' is not supported.", vctId)); + } + + return buildClaims(user, vct); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/vc/UnsupportedCredentialFormatException.java b/src/main/java/com/authlete/jaxrs/server/vc/UnsupportedCredentialFormatException.java new file mode 100644 index 0000000..3c599b0 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/vc/UnsupportedCredentialFormatException.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.vc; + + +public class UnsupportedCredentialFormatException extends VerifiableCredentialException +{ + private static final long serialVersionUID = 1L; + + + public UnsupportedCredentialFormatException() + { + } + + + public UnsupportedCredentialFormatException(String message) + { + super(message); + } + + + public UnsupportedCredentialFormatException(Throwable cause) + { + super(cause); + } + + + public UnsupportedCredentialFormatException(String message, Throwable cause) + { + super(message, cause); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/vc/UnsupportedCredentialTypeException.java b/src/main/java/com/authlete/jaxrs/server/vc/UnsupportedCredentialTypeException.java new file mode 100644 index 0000000..ede80fe --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/vc/UnsupportedCredentialTypeException.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.vc; + + +public class UnsupportedCredentialTypeException extends VerifiableCredentialException +{ + private static final long serialVersionUID = 1L; + + + public UnsupportedCredentialTypeException() + { + } + + + public UnsupportedCredentialTypeException(String message) + { + super(message); + } + + + public UnsupportedCredentialTypeException(Throwable cause) + { + super(cause); + } + + + public UnsupportedCredentialTypeException(String message, Throwable cause) + { + super(message, cause); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/vc/VerifiableCredentialException.java b/src/main/java/com/authlete/jaxrs/server/vc/VerifiableCredentialException.java new file mode 100644 index 0000000..fd0c2fa --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/vc/VerifiableCredentialException.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2023 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.vc; + + +public class VerifiableCredentialException extends Exception +{ + private static final long serialVersionUID = 1L; + + + public VerifiableCredentialException() + { + } + + + public VerifiableCredentialException(String message) + { + super(message); + } + + + public VerifiableCredentialException(Throwable cause) + { + super(cause); + } + + + public VerifiableCredentialException(String message, Throwable cause) + { + super(message, cause); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/vc/VerifiableCredentialType.java b/src/main/java/com/authlete/jaxrs/server/vc/VerifiableCredentialType.java new file mode 100644 index 0000000..c91c278 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/vc/VerifiableCredentialType.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2023-2024 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.vc; + + +import java.util.Arrays; +import com.authlete.common.types.StandardClaims; + + +/** + * Verifiable Credential Type identified by the "{@code vct}" claim + * in an SD-JWT VC. + */ +public enum VerifiableCredentialType +{ + IDENTITY_CREDENTIAL( + "https://credentials.example.com/identity_credential", + new String[] { + StandardClaims.GIVEN_NAME, + StandardClaims.FAMILY_NAME, + StandardClaims.BIRTHDATE + } + ), + + DIGITAL_CREDENTIAL( + "https://credentials.example.com/digital_credential", + new String[] { + StandardClaims.GIVEN_NAME, + StandardClaims.FAMILY_NAME, + StandardClaims.BIRTHDATE + } + ), + + /** + * The vct used in the POTENTIAL Interop Event Track 2. + * + *
+ * + * + * + * + * + * + * + * + * + *
vctclaims
+ * urn:eu.europa.ec.eudi:pid:1 + * + *
    + *
  • family_name + *
  • given_name + *
  • birthdate + *
  • age_equal_or_over/18 + *
  • place_of_birth/locality + *
  • address/formatted + *
  • issuing_authority + *
  • issuing_country + *
+ *
+ *
+ * + * @see POTENTIAL Interop Event Track 2 / description + */ + EUDI_PID_1( + "urn:eu.europa.ec.eudi:pid:1", + new String[] { + StandardClaims.FAMILY_NAME, + StandardClaims.GIVEN_NAME, + StandardClaims.BIRTHDATE, + "age_equal_or_over", + "place_of_birth", + StandardClaims.ADDRESS, + "issuing_authority", + "issuing_country", + } + ), + ; + + + private final String id; + private final String[] claims; + + + private VerifiableCredentialType(String id, String[] claims) + { + this.id = id; + this.claims = claims; + } + + + public String getId() + { + return id; + } + + + public String[] getClaims() + { + return claims; + } + + + public static VerifiableCredentialType byId(final String id) + { + return Arrays.stream(VerifiableCredentialType.values()) + .filter(format -> format.getId().equals(id)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/resources/ekyc-ida/examples/response/document_800_63A.json b/src/main/resources/ekyc-ida/examples/response/document_800_63A.json new file mode 100644 index 0000000..e62359a --- /dev/null +++ b/src/main/resources/ekyc-ida/examples/response/document_800_63A.json @@ -0,0 +1,76 @@ +{ + "verified_claims": { + "verification": { + "trust_framework": "nist_800_63A", + "assurance_level": "ial2", + "assurance_process": { + "assurance_details": [ + { + "assurance_type": "evidence_validation", + "assurance_classification": "strong", + "evidence_ref": [ + { + "txn": "DL1-93h506th2f45hf" + } + ] + }, + { + "assurance_type": "verification", + "assurance_classification": "strong", + "evidence_ref": [ + { + "txn": "v-93jfk284ugjfj2093" + } + ] + } + ] + }, + "time": "2021-06-06T05:32Z", + "verification_process": "7675D80F-57E0-AB14-9543-26B41FC22", + "evidence": [ + { + "type": "document", + "check_details": [ + { + "check_method": "vpiruv", + "organization": "doc_checker", + "txn": "DL1-93h506th2f45hf" + }, + { + "check_method": "pvp", + "organization": "face_checker", + "txn": "v-93jfk284ugjfj2093" + } + ], + "time": "2021-06-06T05:33Z", + "document_details": { + "type": "driving_permit", + "document_number": "I1234568", + "date_of_issuance": "2019-09-05", + "date_of_expiry": "2024-08-01", + "issuer": { + "name": "CA DMV", + "country": "US", + "country_code": "USA", + "jurisdiction": "CA" + } + } + } + ] + }, + "claims": { + "given_name": "Inga", + "family_name": "Silverstone", + "birthdate": "1991-11-06", + "place_of_birth": { + "country": "USA" + }, + "address": { + "locality": "Shoshone", + "postal_code": "CA 92384", + "country": "USA", + "street_address": "114 Old State Hwy 127" + } + } + } +} diff --git a/src/main/resources/ekyc-ida/examples/response/document_UKTDIF.json b/src/main/resources/ekyc-ida/examples/response/document_UKTDIF.json new file mode 100644 index 0000000..620a76a --- /dev/null +++ b/src/main/resources/ekyc-ida/examples/response/document_UKTDIF.json @@ -0,0 +1,97 @@ +{ + "verified_claims": { + "verification": { + "trust_framework": "uk_tfida", + "assurance_level": "medium", + "assurance_process": { + "policy": "gpg45", + "procedure": "m1c", + "assurance_details": [ + { + "assurance_type": "evidence_validation", + "assurance_classification": "score_3", + "evidence_ref": [ + { + "txn": "DL1-93h506th2f45hf", + "evidence_metadata": { + "evidence_classification": "score_3_strength" + } + } + ] + }, + { + "assurance_type": "verification", + "assurance_classification": "score_3", + "evidence_ref": [ + { + "txn": "v-93jfk284ugjfj2093" + } + ] + } + ] + }, + "time": "2021-06-06T05:32Z", + "verification_process": "7675D80F-57E0-AB14-9543-26B41FC22", + "evidence": [ + { + "type": "document", + "check_details": [ + { + "check_method": "vpiruv", + "organization": "doc_checker", + "txn": "DL1-93h506th2f45hf", + "time": "2021-06-08T11:41Z" + }, + { + "check_method": "pvp", + "organization": "face_checker", + "txn": "v-93jfk284ugjfj2093", + "time": "2021-06-08T11:42Z" + } + ], + "time": "2021-06-06T05:33Z", + "document_details": { + "type": "driving_permit", + "document_number": "I1234568", + "date_of_issuance": "2019-09-05", + "date_of_expiry": "2024-08-01", + "issuer": { + "name": "CA DMV", + "country": "US", + "country_code": "USA", + "jurisdiction": "CA" + } + }, + "attachments": [ + { + "desc": "scan of driving_permit", + "content_type": "image/jpeg", + "txn": "DL1-93h506th2f45hf", + "content": "d16d2552e35582810e5a40e523716504525b6016ae96844ddc533163059b3067==" + }, + { + "desc": "captured face", + "content_type": "image/jpeg", + "txn": "v-93jfk284ugjfj2093", + "content": "6954697405687029456098270457602984756098274509687204576==" + } + ] + } + ] + }, + "claims": { + "given_name": "Inga", + "family_name": "Silverstone", + "birthdate": "1991-11-06", + "place_of_birth": { + "country": "USA" + }, + "address": { + "locality": "Shoshone", + "postal_code": "CA 92384", + "country": "USA", + "street_address": "114 Old State Hwy 127" + } + } + } +} diff --git a/src/main/resources/java-oauth-server.properties b/src/main/resources/java-oauth-server.properties new file mode 100644 index 0000000..c039b72 --- /dev/null +++ b/src/main/resources/java-oauth-server.properties @@ -0,0 +1,4 @@ +# +# Set server configurations here if necessary. The configuration properties you +# can set here are defined in "com.authlete.jaxrs.server.ServerConfig". +# \ No newline at end of file diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..479545e --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,11 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + diff --git a/src/main/resources/resilience.properties b/src/main/resources/resilience.properties new file mode 100644 index 0000000..7d80851 --- /dev/null +++ b/src/main/resources/resilience.properties @@ -0,0 +1,79 @@ +# +# Resilience configuration for calls made to Authlete's API endpoints. +# +# This file tunes the resilience layer that wraps the Authlete API client +# (see com.authlete.jaxrs.server.resilience). The layer implements the +# practices described in Authlete's "Rate Limit Best Practices" guide: +# intelligent caching of idempotent reads, conditional retries on transient +# failures, exponential backoff with jitter, and a circuit breaker. +# +# Every key below can also be overridden with a JVM system property of the +# same name (e.g. -Dresilience.cache.enabled=false), which always wins over +# the value defined here. All durations are expressed in the unit noted on +# each key. Removing a key simply falls back to the built-in default shown +# in ResilienceConfig. +# + +# --------------------------------------------------------------------------- +# Master switch. When false, the Authlete API client is used directly with no +# caching, retries or circuit breaking (i.e. the original behaviour). +# --------------------------------------------------------------------------- +resilience.enabled = true + +# --------------------------------------------------------------------------- +# Caching of idempotent (safe, repeatable) read endpoints. TTLs are in seconds. +# Only the endpoints listed in the best-practices guide are cached; all other +# API calls are never cached. A revocation processed by this instance evicts +# its own cached introspection entries for that token immediately (best-effort, +# local only). Keep the introspection TTLs short anyway: other instances, and +# tokens revoked indirectly (e.g. access tokens invalidated by revoking their +# refresh token), still rely on the TTL to stop reporting a token as active. +# --------------------------------------------------------------------------- +resilience.cache.enabled = true +resilience.cache.ttl.serviceConfiguration = 600 +resilience.cache.ttl.serviceJwks = 600 +resilience.cache.ttl.client = 300 +resilience.cache.ttl.credentialIssuerMetadata = 600 +resilience.cache.ttl.credentialIssuerJwks = 600 +resilience.cache.ttl.introspection = 30 +resilience.cache.ttl.standardIntrospection = 30 + +# How long an expired entry is retained as "stale" (seconds). Stale entries are +# never served during normal operation; they are only used as a fast-fail +# fallback while the circuit breaker for that endpoint is open. +resilience.cache.staleSeconds = 1800 + +# Safety cap on the number of cached entries (per cached method) to bound memory. +resilience.cache.maxEntries = 10000 + +# --------------------------------------------------------------------------- +# Conditional retry with exponential backoff and jitter. Retries are attempted +# only for transient failures: HTTP 429, 502, 503, any other 5xx, and +# connection-level errors (no HTTP response). Permanent 4xx errors +# (400/401/403/...) are never retried. +# --------------------------------------------------------------------------- +resilience.retry.enabled = true +# Total number of attempts, including the first one (so 4 means 1 try + 3 retries). +resilience.retry.maxAttempts = 4 +# Base delay for the first retry (ms). Subsequent retries double it: 500, 1000, 2000, ... +resilience.retry.baseDelayMillis = 500 +# Hard cap on the total time spent retrying a single call (ms). +resilience.retry.maxTotalMillis = 60000 +# Upper bound of the random jitter added to each backoff delay (ms). +resilience.retry.jitterMillis = 200 + +# --------------------------------------------------------------------------- +# Circuit breaker. One independent breaker is kept per Authlete API method, so +# a storm of failures on (say) client management does not trip the breaker for +# introspection. While a breaker is open, calls fail fast (and serve stale +# cached data when available) instead of hammering an unhealthy backend. +# --------------------------------------------------------------------------- +resilience.breaker.enabled = true +# Number of transient failures within the window that trips the breaker open. +resilience.breaker.failureThreshold = 5 +# Rolling window (seconds) over which failures are counted. +resilience.breaker.windowSeconds = 30 +# How long the breaker stays open before allowing trial requests (seconds). +resilience.breaker.openSeconds = 60 +# Number of trial requests allowed while half-open before deciding to close/reopen. +resilience.breaker.halfOpenTrials = 1 \ No newline at end of file diff --git a/src/main/resources/resource_servers.json b/src/main/resources/resource_servers.json new file mode 100644 index 0000000..e88ec3c --- /dev/null +++ b/src/main/resources/resource_servers.json @@ -0,0 +1,11 @@ +[ + { + "id": "rs0", + "secret": "rs0-secret", + "uri": "https//rs0.example.com", + "introspectionSignAlg": "ES256", + "introspectionEncryptionAlg": "RSA_OAEP_256", + "introspectionEncryptionEnc": "A128CBC_HS256", + "publicKeyForIntrospectionResponseEncryption": "{\"kty\":\"RSA\", \"e\": \"AQAB\",\"use\": \"enc\",\"kid\": \"22BGA3qKjBG7a5Y5lmftcOYkeUCql_G12qPbjBn08rA\",\"alg\": \"RSA-OAEP-256\",\"n\": \"0bBna89O_reo8ttH1ITZ9sBc601OAOTHIdMQ3vwUYrrb-x2Zgp8BvueYKAeMy5kvv05zAGHqnF76v_z-XjT3Dr85xdY9ruNHA-Sg9hupa5NTUFbTOareh7MldjQNer9sejVeNmy7Wtk3CP7Y7p581VLSqj8r5DGsVh6Ha2mw5EiqtHLCPAMXMdb6pUMZ7TdKioHd-NMLwcL-p-OKGfF0znf-Fho-5KdoX855Digt2ud8LARe-qMA1DbSoHI1zowQeezRmcj_cbdv9RUaRmxg3Wqr_87WOninWA71qZFeLNEFitjQldf6FZhJ143lWnnMdzTBVvBBav0KHnsVcr982Q\"}" + } +] diff --git a/src/main/webapp/WEB-INF/template/authorization.jsp b/src/main/webapp/WEB-INF/template/authorization.jsp index 7acedaf..bcfbf14 100644 --- a/src/main/webapp/WEB-INF/template/authorization.jsp +++ b/src/main/webapp/WEB-INF/template/authorization.jsp @@ -1,7 +1,7 @@ @@ -75,31 +75,142 @@ + +

Claims for ID Token

+
+
    + +
  • ${claim} + +
+
+
+ + +

Claims for UserInfo

+
+
    + +
  • ${claim} + +
+
+
+ + +

Identity Assurance

+
+ +
Purpose
+
+

${model.purpose}

+
+
+ +
Verified claims requested for ID token
+
+ + All + + + + + + + + + + + + + + + + + +
claimpurpose
${pair.key}${pair.value}
+
+
+
+ +
Verified claims requested for userinfo
+
+ + All + + + + + + + + + + + + + + + + + +
claimpurpose
${pair.key}${pair.value}
+
+
+
+
+
+ + +

Authorization Details

+
+
+${model.authorizationDetails}
+
+
+
+

Authorization

Do you grant authorization to the application?

- +
-
Input Login ID and password.
+
Input Login ID and Password.
+ autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" + class="font-default" value="${model.loginId}" ${model.loginIdReadOnly}> + class="font-default"> +
+ +
+
ID federation using an external OpenID Provider
+ +
${model.federationMessage}
+
+ +
+
+
+ +
+ Logged in as . + If re-authentication is needed, append &prompt=login + to the authorization request.
-
- -
Logged in as
-
+
- - diff --git a/src/main/webapp/WEB-INF/template/credential-offer.jsp b/src/main/webapp/WEB-INF/template/credential-offer.jsp new file mode 100644 index 0000000..c721477 --- /dev/null +++ b/src/main/webapp/WEB-INF/template/credential-offer.jsp @@ -0,0 +1,267 @@ + + + + + + + + Credential Offer + + + + + + + + + +
Credential Offer
+ +
+
+ +
+

Login

+
+
+
Input Login ID and Password.
+ + +
+
+
+
+ + + +
+

Offer parameters

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Credentials
Credential Configuration IDs + +
Grants
Authorization Code Grant + checked > + +
Issue State + checked > + +
Pre-Authorized Code Grant + checked > + +
Transaction Code
Value + +
Input Mode + +
Description + +
Transmission
Credential Offer Endpoint + +
+ +
+ +
+ +
+
+ +
+

Offer

+ + +

Transaction Code: ${model.info.txCode}

+
+ + +
Credential offer
+ + + +
+
+
${model.credentialOfferContent}
+
+
+
+ + +
Credential offer URI
+ + + +
+
+ + + +
+
+
+
+
+
+
+ + diff --git a/src/main/webapp/WEB-INF/template/device/authorization.jsp b/src/main/webapp/WEB-INF/template/device/authorization.jsp new file mode 100644 index 0000000..9e8c872 --- /dev/null +++ b/src/main/webapp/WEB-INF/template/device/authorization.jsp @@ -0,0 +1,70 @@ + + + + + + + + Device Flow | Authorization + + + + + + +
Device Flow Authorization
+ +
+

${model.clientName}

+ + +

Permissions

+
+

The application is requesting the following permissions.

+ +
+ +
${scope.name}
+
${scope.description}
+
+
+
+
+ +

Authorization

+
+

Do you grant authorization to the application?

+ +
+
+ + +
+
+
+
+ + + diff --git a/src/main/webapp/WEB-INF/template/device/verification.jsp b/src/main/webapp/WEB-INF/template/device/verification.jsp new file mode 100644 index 0000000..3a17c8f --- /dev/null +++ b/src/main/webapp/WEB-INF/template/device/verification.jsp @@ -0,0 +1,76 @@ + + + + + + + + Verification + + + + + + +
Device Flow Verification
+ +
+

Verification

+
+ +

${model.notification}

+
+

Enter required information below.

+ +
+ +
+
Input Login ID and password.
+ + +
+
+ + +
Logged in as
+
+ +
+
Input User Code.
+ +
+ +
+ +
+
+
+
+ + + diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 168c49f..cef23f6 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -1,12 +1,19 @@ + version="6.0"> + + + com.authlete.jaxrs.server.core.AppContextListener + + + + com.authlete.jaxrs.server.core.SessionTracker + API @@ -26,14 +33,44 @@ jersey.config.server.provider.classnames + com.authlete.jaxrs.server.api.AppleAppSiteAssociation, com.authlete.jaxrs.server.api.AuthorizationDecisionEndpoint, com.authlete.jaxrs.server.api.AuthorizationEndpoint, + com.authlete.jaxrs.server.api.vci.BatchCredentialEndpoint, + com.authlete.jaxrs.server.api.vci.CredentialEndpoint, + com.authlete.jaxrs.server.api.vci.CredentialMetadataEndpoint, + com.authlete.jaxrs.server.api.vci.CredentialNonceEndpoint, + com.authlete.jaxrs.server.api.vci.CredentialJwtIssuerEndpoint, + com.authlete.jaxrs.server.api.vci.DeferredCredentialEndpoint, + com.authlete.jaxrs.server.api.vci.CredentialOfferEndpoint, + com.authlete.jaxrs.server.api.vci.CredentialOfferIssueEndpoint, + com.authlete.jaxrs.server.api.vci.CredentialJWKSetEndpoint, + com.authlete.jaxrs.server.api.ClientRegistrationEndpoint, com.authlete.jaxrs.server.api.ConfigurationEndpoint, + com.authlete.jaxrs.server.api.FederationConfigurationEndpoint, + com.authlete.jaxrs.server.api.FederationEndpoint, + com.authlete.jaxrs.server.api.FederationRegistrationEndpoint, + com.authlete.jaxrs.server.api.GrantManagementEndpoint, + com.authlete.jaxrs.server.api.IntrospectionEndpoint, com.authlete.jaxrs.server.api.JwksEndpoint, + com.authlete.jaxrs.server.api.PushedAuthReqEndpoint, com.authlete.jaxrs.server.api.RevocationEndpoint, + com.authlete.jaxrs.server.api.TestEndpoint, com.authlete.jaxrs.server.api.TokenEndpoint, + com.authlete.jaxrs.server.api.UserInfoEndpoint, + com.authlete.jaxrs.server.api.backchannel.BackchannelAuthenticationCallbackEndpoint, + com.authlete.jaxrs.server.api.backchannel.BackchannelAuthenticationEndpoint, + com.authlete.jaxrs.server.api.device.DeviceAuthorizationEndpoint, + com.authlete.jaxrs.server.api.device.DeviceCompleteEndpoint, + com.authlete.jaxrs.server.api.device.DeviceVerificationEndpoint, + com.authlete.jaxrs.server.api.obb.AccountsEndpoint, + com.authlete.jaxrs.server.api.obb.FAPI2BaseAccountsEndpoint, + com.authlete.jaxrs.server.api.obb.ConsentsEndpoint, + com.authlete.jaxrs.server.api.obb.ResourcesEndpoint, org.glassfish.jersey.moxy.json.MoxyJsonFeature, - org.glassfish.jersey.server.mvc.jsp.JspMvcFeature + org.glassfish.jersey.server.mvc.jsp.JspMvcFeature, + com.authlete.jaxrs.server.decorator.FapiInteractionIdResponseFilter, + com.authlete.jaxrs.server.api.attestation.AttestationChallengeEndpoint, @@ -56,7 +93,13 @@ API /api/* + /.well-known/oauth-authorization-server /.well-known/openid-configuration + /.well-known/openid-credential-issuer + /.well-known/openid-federation + /.well-known/jwt-issuer + /.well-known/jwt-vc-issuer + /.well-known/apple-app-site-association diff --git a/src/main/webapp/css/authorization.css b/src/main/webapp/css/authorization.css index 6cf9122..977e6e8 100644 --- a/src/main/webapp/css/authorization.css +++ b/src/main/webapp/css/authorization.css @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 Authlete, Inc. + * Copyright (C) 2016-2022 Authlete, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,8 +19,6 @@ { font-family: 'Source Sans Pro', 'Helvetica Neue', 'Segoe UI', 'Arial', sans-serif; -webkit-font-smoothing: antialiased; - font-weight: 200; - font-size: 18px; color: #666; } @@ -138,6 +136,21 @@ input { width: 300px; } +#login-user { + font-style: italic; +} + +#federations-prompt { + font-size: 85%; + margin-bottom: 5px; +} + +#federation-message { + font-size: 85%; + margin-bottom: 5px; + color: darkred; +} + #authorization-form-buttons { margin: 20px auto; } @@ -180,3 +193,18 @@ input { #deny-button:active { background-color: red; } + +pre { + background: #f4f4f4; + border: 1px solid #ddd; + border-left: 3px solid #33b0f3; + color: #666; + page-break-inside: avoid; + font-family: monospace; + margin-bottom: 1.6em; + max-width: 60%; + overflow: auto; + padding: 1em 1.5em; + display: block; + word-wrap: break-word; +} \ No newline at end of file diff --git a/src/main/webapp/css/device/authorization.css b/src/main/webapp/css/device/authorization.css new file mode 100644 index 0000000..12fac0b --- /dev/null +++ b/src/main/webapp/css/device/authorization.css @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ + +.font-default +{ + font-family: 'Source Sans Pro', 'Helvetica Neue', 'Segoe UI', 'Arial', sans-serif; + -webkit-font-smoothing: antialiased; + color: #666; +} + +body { + margin: 0; + text-shadow: none; +} + +p { + margin-top: 0; +} + +h3, h4 { + color: steelblue; +} + +.indent { + margin-left: 15px; +} + +#page_title { + background: #F5F5F5; + color: steelblue; + padding: 0.5em; + margin: 0; +} + +#content { + padding: 0 20px 20px; +} + +#scope-list { + margin-left: 20px; +} + +#scope-list dt { + font-weight: bold; +} + +#scope-list dd { + margin-bottom: 10px; +} + +input { + color: black; +} + +#authorization-form-buttons { + margin: 20px auto; +} + +#authorize-button, #deny-button { + display: inline-block; + width: 150px; + padding: 12px 0; + margin: 13px; + min-height: 26px; + text-align: center; + text-decoration: none; + outline: 0; + -webkit-transition: none; + transition: none; +} + +#authorize-button { + background-color: #4285f4; + color: white; +} + +#authorize-button:hover { + background-color: #1255f4; +} + +#authorize-button:active { + background-color: blue; +} + +#deny-button { + background-color: #f08080; + color: white; +} + +#deny-button:hover { + background-color: #f05050; +} + +#deny-button:active { + background-color: red; +} diff --git a/src/main/webapp/css/device/verification.css b/src/main/webapp/css/device/verification.css new file mode 100644 index 0000000..1b5278e --- /dev/null +++ b/src/main/webapp/css/device/verification.css @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2019 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ + +.font-default +{ + font-family: 'Source Sans Pro', 'Helvetica Neue', 'Segoe UI', 'Arial', sans-serif; + -webkit-font-smoothing: antialiased; + color: #666; +} + +body { + margin: 0; + text-shadow: none; +} + +p { + margin-top: 0; +} + +h3, h4 { + color: steelblue; +} + +.indent { + margin-left: 15px; +} + +#page_title { + background: #F5F5F5; + color: steelblue; + padding: 0.5em; + margin: 0; +} + +#content { + padding: 0 20px 20px; +} + +#notification { + color: red; +} + +input { + color: black; +} + +#login-fields, #usercode-field { + margin-bottom: 20px; +} + +#usercode-field { + margin-top: 20px; +} + +#login-prompt, #usercode-prompt { + font-size: 85%; + margin-bottom: 5px; +} + +#loginId { + display: block; + border: 1px solid #666; + border-bottom: none; + padding: 0.3em 0.5em; + width: 300px; +} + +#password { + display: block; + border: 1px solid #666; + padding: 0.3em 0.5em; + width: 300px; +} + +#verification-form-button { + margin: 20px auto; +} + +#send-button { + display: inline-block; + width: 150px; + padding: 12px 0; + margin: 13px; + min-height: 26px; + text-align: center; + text-decoration: none; + outline: 0; + -webkit-transition: none; + transition: none; + background-color: #4285f4; + color: white; +} + +#send-button:hover { + background-color: #1255f4; +} + +#send-button:active { + background-color: blue; +} diff --git a/src/main/webapp/css/index.css b/src/main/webapp/css/index.css index 6524f33..1c03b15 100644 --- a/src/main/webapp/css/index.css +++ b/src/main/webapp/css/index.css @@ -1,49 +1,47 @@ -body { - margin: 0; - text-shadow: none; -} - -#page_title { - background: #333; - color: white; - padding: 0.5em; - margin: 0; - font-size: 200%; -} - -#content { - padding: 20px; -} - -table { - border-collapse: collapse; -} - -td { - padding: 10px; -} - -tr.label, td.label { - background-color: #E0E0E0; -} - -a { - text-decoration: none; - color: blue; -} - -a:visited { - color: blue; -} - -a:hover { - text-decoration: underline; -} - -.font-default -{ - font-family: 'Source Sans Pro', 'Helvetica Neue', 'Segoe UI', 'Arial', sans-serif; - -webkit-font-smoothing: antialiased; - font-weight: 200; - font-size: 18px; -} +body { + margin: 0; + text-shadow: none; +} + +#page_title { + background: #333; + color: white; + padding: 0.5em; + margin: 0; + font-size: 200%; +} + +#content { + padding: 20px; +} + +table { + border-collapse: collapse; +} + +td { + padding: 10px; +} + +tr.label, td.label { + background-color: #E0E0E0; +} + +a { + text-decoration: none; + color: blue; +} + +a:visited { + color: blue; +} + +a:hover { + text-decoration: underline; +} + +.font-default +{ + font-family: 'Source Sans Pro', 'Helvetica Neue', 'Segoe UI', 'Arial', sans-serif; + -webkit-font-smoothing: antialiased; +} diff --git a/src/main/webapp/index.html b/src/main/webapp/index.html index 8c58afa..495e65a 100644 --- a/src/main/webapp/index.html +++ b/src/main/webapp/index.html @@ -36,13 +36,53 @@ /api/jwks - Configuration Endpoint + Discovery Endpoint /.well-known/openid-configuration Revocation Endpoint /api/revocation + + Introspection Endpoint + /api/introspection + + + Registration Endpoint + /api/register + + + Pushed Authorization Request Endpoint + /api/par + + + Grant Management Endpoint + /api/gm/{grantId} + + + Federation Configuration Endpoint + /.well-known/openid-federation + + + Federation Registration Endpoint + /api/federation/register + + + Credential Issuer Metadata Endpoint + /.well-known/openid-credential-issuer + + + JWT Issuer Metadata Endpoint + /.well-known/jwt-issuer + + + JWT VC Issuer Metadata Endpoint + /.well-known/jwt-vc-issuer + + + Credential Issuer Nonce Endpoint + /api/nonce + @@ -70,7 +110,7 @@
  • Authlete is an OAuth 2.0 & OpenID Connect implementation on cloud - (overview). + (overview).
  • This authorization server is written using Authlete's open source libraries.
  • @@ -78,10 +118,10 @@
  • You can manage settings of authorization servers by Service Owner Console - (document). + (document).
  • You can manage settings of client applications by Developer Console - (document). + (document). diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteBackoffTest.java b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteBackoffTest.java new file mode 100644 index 0000000..c776ff7 --- /dev/null +++ b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteBackoffTest.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import java.util.Random; +import org.junit.Test; + + +public class AuthleteBackoffTest +{ + @Test + public void exponentialScheduleWithoutJitter() + { + // base=500, max=60000, jitter=0 + AuthleteBackoff backoff = new AuthleteBackoff(500, 60000, 0, new Random(0)); + + assertEquals(500, backoff.delayMillis(1, null)); // 500 * 2^0 + assertEquals(1000, backoff.delayMillis(2, null)); // 500 * 2^1 + assertEquals(2000, backoff.delayMillis(3, null)); // 500 * 2^2 + assertEquals(4000, backoff.delayMillis(4, null)); // 500 * 2^3 + } + + + @Test + public void delayIsCappedAtMax() + { + AuthleteBackoff backoff = new AuthleteBackoff(500, 3000, 0, new Random(0)); + + // 500 * 2^3 = 4000 would exceed the 3000 cap. + assertEquals(3000, backoff.delayMillis(4, null)); + assertEquals(3000, backoff.delayMillis(20, null)); + } + + + @Test + public void explicitDelayIsHonoured() + { + AuthleteBackoff backoff = new AuthleteBackoff(500, 60000, 0, new Random(0)); + + // A RateLimit-Reset of 5s overrides the exponential value. + assertEquals(5000, backoff.delayMillis(1, 5000L)); + } + + + @Test + public void jitterStaysWithinBounds() + { + long base = 500; + long jitter = 200; + AuthleteBackoff backoff = new AuthleteBackoff(base, 60000, jitter, new Random(42)); + + for (int i = 0; i < 100; i++) + { + long delay = backoff.delayMillis(1, null); + assertTrue("delay >= base", delay >= base); + assertTrue("delay < base + jitter", delay < base + jitter); + } + } +} diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethodsTest.java b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethodsTest.java new file mode 100644 index 0000000..81b60c7 --- /dev/null +++ b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethodsTest.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import java.lang.reflect.Method; +import java.net.URI; +import org.junit.Test; +import com.authlete.common.api.AuthleteApi; +import com.authlete.common.dto.IntrospectionRequest; +import com.authlete.common.dto.StandardIntrospectionRequest; +import com.authlete.jaxrs.server.resilience.AuthleteCacheableMethods.CachePolicy; + + +public class AuthleteCacheableMethodsTest +{ + private final AuthleteCacheableMethods cacheable = + new AuthleteCacheableMethods(new ResilienceConfig()); + + + private CachePolicy introspectionPolicy(IntrospectionRequest req) throws Exception + { + Method method = AuthleteApi.class.getMethod( + "introspection", IntrospectionRequest.class); + + return cacheable.policyFor(method, new Object[] { req }); + } + + + private CachePolicy standardIntrospectionPolicy(StandardIntrospectionRequest req) throws Exception + { + Method method = AuthleteApi.class.getMethod( + "standardIntrospection", StandardIntrospectionRequest.class); + + return cacheable.policyFor(method, new Object[] { req }); + } + + + @Test + public void dpopIntrospectionIsNeverCached() throws Exception + { + IntrospectionRequest req = new IntrospectionRequest() + .setToken("token") + .setDpop("dpop-proof") + .setHtm("GET") + .setHtu("https://rs.example.com/resource"); + + assertNull("DPoP-bound requests must not be cached", + introspectionPolicy(req)); + } + + + @Test + public void messageSignatureIntrospectionIsNeverCached() throws Exception + { + IntrospectionRequest req = new IntrospectionRequest() + .setToken("token") + .setMessage("signed-message"); + + assertNull("message-signature requests must not be cached", + introspectionPolicy(req)); + } + + + @Test + public void clientCertificateParticipatesInIntrospectionKey() throws Exception + { + IntrospectionRequest base = new IntrospectionRequest().setToken("token"); + + CachePolicy noCert = introspectionPolicy(base); + CachePolicy withCert = introspectionPolicy( + new IntrospectionRequest().setToken("token").setClientCertificate("CERT-A")); + CachePolicy otherCert = introspectionPolicy( + new IntrospectionRequest().setToken("token").setClientCertificate("CERT-B")); + + assertNotNull(noCert); + assertNotNull(withCert); + assertNotNull(otherCert); + assertNotEquals("same token, different certs must not share an entry", + withCert.key, otherCert.key); + assertNotEquals(noCert.key, withCert.key); + } + + + @Test + public void resourcesParticipateInIntrospectionKey() throws Exception + { + CachePolicy a = introspectionPolicy(new IntrospectionRequest() + .setToken("token") + .setResources(new URI[] { URI.create("https://rs-a.example.com") })); + CachePolicy b = introspectionPolicy(new IntrospectionRequest() + .setToken("token") + .setResources(new URI[] { URI.create("https://rs-b.example.com") })); + + assertNotEquals(a.key, b.key); + } + + + @Test + public void rsUriParticipatesInStandardIntrospectionKey() throws Exception + { + CachePolicy a = standardIntrospectionPolicy(new StandardIntrospectionRequest() + .setParameters("token=abc") + .setRsUri(URI.create("https://rs-a.example.com"))); + CachePolicy b = standardIntrospectionPolicy(new StandardIntrospectionRequest() + .setParameters("token=abc") + .setRsUri(URI.create("https://rs-b.example.com"))); + + assertNotEquals("different resource servers must not share an entry", + a.key, b.key); + } + + + @Test + public void acceptHeaderParticipatesInStandardIntrospectionKey() throws Exception + { + CachePolicy json = standardIntrospectionPolicy(new StandardIntrospectionRequest() + .setParameters("token=abc") + .setHttpAcceptHeader("application/json")); + CachePolicy jwt = standardIntrospectionPolicy(new StandardIntrospectionRequest() + .setParameters("token=abc") + .setHttpAcceptHeader("application/token-introspection+jwt")); + + assertNotEquals(json.key, jwt.key); + } +} diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerTest.java b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerTest.java new file mode 100644 index 0000000..dc61329 --- /dev/null +++ b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerTest.java @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import java.util.function.LongSupplier; +import org.junit.Test; +import com.authlete.jaxrs.server.resilience.AuthleteCircuitBreaker.State; + + +public class AuthleteCircuitBreakerTest +{ + /** A clock whose value the test advances manually. */ + private final long[] now = { 0L }; + private final LongSupplier clock = () -> now[0]; + + + private AuthleteCircuitBreaker newBreaker() + { + // threshold=3 failures within a 30s window, open for 60s, 1 half-open trial. + return new AuthleteCircuitBreaker(3, 30_000, 60_000, 1, clock); + } + + + @Test + public void opensAfterThresholdFailures() + { + AuthleteCircuitBreaker cb = newBreaker(); + + assertTrue(cb.allowRequest()); + cb.recordFailure(); + cb.recordFailure(); + assertEquals(State.CLOSED, cb.getState()); + + cb.recordFailure(); // 3rd failure trips it open + assertEquals(State.OPEN, cb.getState()); + assertFalse("open breaker fails fast", cb.allowRequest()); + } + + + @Test + public void successResetsFailureCount() + { + AuthleteCircuitBreaker cb = newBreaker(); + + cb.recordFailure(); + cb.recordFailure(); + cb.recordSuccess(); // resets the count + cb.recordFailure(); + cb.recordFailure(); + + assertEquals("still closed after reset", State.CLOSED, cb.getState()); + } + + + @Test + public void failuresOutsideWindowDoNotAccumulate() + { + AuthleteCircuitBreaker cb = newBreaker(); + + cb.recordFailure(); + cb.recordFailure(); + + // Advance beyond the 30s rolling window: the count restarts. + now[0] += 31_000; + cb.recordFailure(); + cb.recordFailure(); + + assertEquals(State.CLOSED, cb.getState()); + } + + + @Test + public void halfOpenSuccessClosesBreaker() + { + AuthleteCircuitBreaker cb = newBreaker(); + trip(cb); + + // Before the open timeout, requests are rejected. + now[0] += 30_000; + assertFalse(cb.allowRequest()); + + // After the open timeout, a single trial is allowed (half-open). + now[0] += 31_000; + assertTrue("half-open trial allowed", cb.allowRequest()); + assertEquals(State.HALF_OPEN, cb.getState()); + assertFalse("only one trial permitted", cb.allowRequest()); + + cb.recordSuccess(); + assertEquals("success closes the breaker", State.CLOSED, cb.getState()); + } + + + @Test + public void halfOpenFailureReopensBreaker() + { + AuthleteCircuitBreaker cb = newBreaker(); + trip(cb); + + now[0] += 61_000; + assertTrue(cb.allowRequest()); // half-open trial + cb.recordFailure(); + + assertEquals("failed trial reopens", State.OPEN, cb.getState()); + assertFalse(cb.allowRequest()); + } + + + @Test + public void releaseTrialFreesHalfOpenSlot() + { + AuthleteCircuitBreaker cb = newBreaker(); + trip(cb); + + now[0] += 61_000; + assertTrue(cb.allowRequest()); // half-open trial reserved + assertFalse("slot taken", cb.allowRequest()); + + // The trial ended without a verdict on backend health (e.g. an + // unexpected local error): the slot must become available again. + cb.releaseTrial(); + assertEquals("still half-open", State.HALF_OPEN, cb.getState()); + assertTrue("slot available again", cb.allowRequest()); + } + + + private void trip(AuthleteCircuitBreaker cb) + { + cb.recordFailure(); + cb.recordFailure(); + cb.recordFailure(); + assertEquals(State.OPEN, cb.getState()); + } +} diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCacheTest.java b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCacheTest.java new file mode 100644 index 0000000..9d85879 --- /dev/null +++ b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCacheTest.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import java.util.function.LongSupplier; +import org.junit.Test; + + +public class AuthleteResponseCacheTest +{ + private final long[] now = { 0L }; + private final LongSupplier clock = () -> now[0]; + + + @Test + public void freshHitWithinTtl() + { + AuthleteResponseCache cache = new AuthleteResponseCache(1000, 100, clock); + cache.put("k", "v", 500); + + now[0] = 499; + assertEquals("v", cache.getFresh("k")); + } + + + @Test + public void expiresAfterTtlButStaleRemains() + { + AuthleteResponseCache cache = new AuthleteResponseCache(1000, 100, clock); + cache.put("k", "v", 500); // fresh until 500, stale until 1500 + + now[0] = 600; + assertNull("no longer fresh", cache.getFresh("k")); + assertEquals("but available as stale", "v", cache.getStale("k")); + } + + + @Test + public void staleEntryDroppedAfterStaleWindow() + { + AuthleteResponseCache cache = new AuthleteResponseCache(1000, 100, clock); + cache.put("k", "v", 500); // stale until 1500 + + now[0] = 1600; + assertNull(cache.getStale("k")); + assertNull(cache.getFresh("k")); + } + + + @Test + public void nullValueAndNonPositiveTtlAreNotCached() + { + AuthleteResponseCache cache = new AuthleteResponseCache(1000, 100, clock); + cache.put("a", null, 500); + cache.put("b", "v", 0); + + assertNull(cache.getFresh("a")); + assertNull(cache.getFresh("b")); + assertEquals(0, cache.size()); + } + + + @Test + public void maxEntriesIsBounded() + { + AuthleteResponseCache cache = new AuthleteResponseCache(1000, 2, clock); + cache.put("a", "1", 500); + cache.put("b", "2", 500); + cache.put("c", "3", 500); // exceeds the limit; rejected while others are live + + assertEquals(2, cache.size()); + assertNull("new key rejected at capacity", cache.getFresh("c")); + + // Updating an existing key is still allowed at capacity. + cache.put("a", "1b", 500); + assertEquals("1b", cache.getFresh("a")); + } + + + @Test + public void removeIfDropsOnlyMatchingKeys() + { + AuthleteResponseCache cache = new AuthleteResponseCache(1000, 100, clock); + cache.put("introspection::tokenA|scope1", "a1", 500); + cache.put("introspection::tokenA|scope2", "a2", 500); + cache.put("introspection::tokenB|scope1", "b1", 500); + + int removed = cache.removeIf(key -> key.startsWith("introspection::tokenA|")); + + assertEquals(2, removed); + assertNull(cache.getFresh("introspection::tokenA|scope1")); + assertNull(cache.getFresh("introspection::tokenA|scope2")); + assertEquals("other tokens untouched", "b1", + cache.getFresh("introspection::tokenB|scope1")); + } +} diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicyTest.java b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicyTest.java new file mode 100644 index 0000000..8d2fdc3 --- /dev/null +++ b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicyTest.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + + +public class AuthleteRetryPolicyTest +{ + private final AuthleteRetryPolicy policy = new AuthleteRetryPolicy(); + + + @Test + public void transientStatuses() + { + assertTrue("no response is transient", policy.isTransient(0)); + assertTrue("429 is transient", policy.isTransient(429)); + assertTrue("500 is transient", policy.isTransient(500)); + assertTrue("502 is transient", policy.isTransient(502)); + assertTrue("503 is transient", policy.isTransient(503)); + assertTrue("599 is transient", policy.isTransient(599)); + } + + + @Test + public void permanentStatuses() + { + assertFalse("400 is permanent", policy.isTransient(400)); + assertFalse("401 is permanent", policy.isTransient(401)); + assertFalse("403 is permanent", policy.isTransient(403)); + assertFalse("404 is permanent", policy.isTransient(404)); + assertFalse("200 is not retried", policy.isTransient(200)); + } + + + @Test + public void rateLimitResetParsedFromSeconds() + { + Map> headers = new HashMap<>(); + headers.put("RateLimit-Reset", Collections.singletonList("3")); + + assertEquals(Long.valueOf(3000L), policy.rateLimitResetMillis(headers)); + } + + + @Test + public void rateLimitResetHeaderIsCaseInsensitive() + { + Map> headers = new HashMap<>(); + headers.put("ratelimit-reset", Arrays.asList("2")); + + assertEquals(Long.valueOf(2000L), policy.rateLimitResetMillis(headers)); + } + + + @Test + public void rateLimitResetAbsentOrInvalid() + { + assertNull(policy.rateLimitResetMillis(null)); + assertNull(policy.rateLimitResetMillis(new HashMap<>())); + + Map> bad = new HashMap<>(); + bad.put("RateLimit-Reset", Collections.singletonList("not-a-number")); + assertNull(policy.rateLimitResetMillis(bad)); + + Map> zero = new HashMap<>(); + zero.put("RateLimit-Reset", Collections.singletonList("0")); + assertNull("non-positive reset is ignored", policy.rateLimitResetMillis(zero)); + } +} diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiE2ETest.java b/src/test/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiE2ETest.java new file mode 100644 index 0000000..117cf19 --- /dev/null +++ b/src/test/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiE2ETest.java @@ -0,0 +1,317 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.fail; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import com.authlete.common.api.AuthleteApi; +import com.authlete.common.api.AuthleteApiException; +import com.authlete.common.dto.IntrospectionRequest; +import com.authlete.common.dto.IntrospectionResponse; +import com.authlete.common.dto.RevocationRequest; + + +/** + * End-to-end tests that exercise the whole resilience layer through the real + * dynamic proxy produced by {@link ResilientAuthleteApiFactory#wrap}, driving a + * programmable fake {@link AuthleteApi} backend so caching, retry, permanent + * error handling, circuit breaking and stale fallback can all be asserted + * without a network or a real Authlete server. + */ +public class ResilientAuthleteApiE2ETest +{ + /** Every resilience knob this test touches, reset between cases. */ + private static final String[] KEYS = { + "resilience.enabled", + "resilience.retry.enabled", + "resilience.retry.maxAttempts", + "resilience.retry.baseDelayMillis", + "resilience.retry.jitterMillis", + "resilience.retry.maxTotalMillis", + "resilience.breaker.enabled", + "resilience.breaker.failureThreshold", + "resilience.breaker.windowSeconds", + "resilience.breaker.openSeconds", + "resilience.cache.enabled", + "resilience.cache.ttl.introspection", + "resilience.cache.staleSeconds", + }; + + + /** + * Programmable {@link AuthleteApi} backend. Only {@code introspection} is + * meaningful; every other method returns {@code null}. The next queued + * status (or {@link #always}, when set) decides whether a call throws an + * {@link AuthleteApiException} or returns {@link #response}. + */ + private static final class Backend implements InvocationHandler + { + final AtomicInteger calls = new AtomicInteger(); + final ConcurrentLinkedQueue statuses = new ConcurrentLinkedQueue<>(); + volatile Integer always = null; + final IntrospectionResponse response; + + Backend(IntrospectionResponse response) + { + this.response = response; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) + { + if (method.getDeclaringClass() == Object.class) + { + switch (method.getName()) + { + case "equals": return proxy == args[0]; + case "hashCode": return System.identityHashCode(proxy); + default: return "Backend"; + } + } + + if (!"introspection".equals(method.getName())) + { + return null; + } + + calls.incrementAndGet(); + + Integer status = (always != null) ? always : statuses.poll(); + + if (status != null && status.intValue() != 0) + { + throw new AuthleteApiException( + "simulated " + status, status.intValue(), "error", null); + } + + return response; + } + } + + + private final IntrospectionResponse canned = new IntrospectionResponse(); + private Backend backend; + private AuthleteApi api; + + + @Before + public void setUp() + { + // A baseline that is friendly to the cache/retry cases; individual + // tests override the few knobs they care about before building the API. + set("resilience.enabled", "true"); + set("resilience.retry.enabled", "true"); + set("resilience.retry.maxAttempts", "4"); + set("resilience.retry.baseDelayMillis", "5"); + set("resilience.retry.jitterMillis", "0"); + set("resilience.retry.maxTotalMillis", "60000"); + set("resilience.breaker.enabled", "true"); + set("resilience.breaker.failureThreshold", "100"); + set("resilience.breaker.windowSeconds", "30"); + set("resilience.breaker.openSeconds", "60"); + set("resilience.cache.enabled", "true"); + set("resilience.cache.ttl.introspection", "30"); + set("resilience.cache.staleSeconds", "1800"); + + backend = new Backend(canned); + } + + + @After + public void tearDown() + { + for (String key : KEYS) + { + System.clearProperty(key); + } + } + + + /** Build the resilient proxy from the current system-property snapshot. */ + private AuthleteApi buildApi() + { + AuthleteApi delegate = (AuthleteApi) Proxy.newProxyInstance( + AuthleteApi.class.getClassLoader(), + new Class[] { AuthleteApi.class }, + backend); + + return ResilientAuthleteApiFactory.wrap(delegate); + } + + + private static IntrospectionRequest request() + { + return new IntrospectionRequest().setToken("token-123"); + } + + + @Test + public void freshHitIsServedFromCacheWithoutCallingBackend() throws Exception + { + api = buildApi(); + + IntrospectionResponse first = api.introspection(request()); + IntrospectionResponse second = api.introspection(request()); + + assertSame("same cached instance returned", first, second); + assertSame(canned, first); + assertEquals("backend invoked only once", 1, backend.calls.get()); + } + + + @Test + public void transientFailuresAreRetriedThenSucceed() throws Exception + { + backend.statuses.add(503); + backend.statuses.add(503); + + api = buildApi(); + + IntrospectionResponse result = api.introspection(request()); + + assertSame("succeeds after the backend recovers", canned, result); + assertEquals("two failures + one success", 3, backend.calls.get()); + } + + + @Test + public void permanentErrorIsNotRetried() throws Exception + { + backend.statuses.add(400); + + api = buildApi(); + + try + { + api.introspection(request()); + fail("expected the 400 to propagate"); + } + catch (AuthleteApiException e) + { + assertEquals(400, e.getStatusCode()); + } + + assertEquals("no retry on a permanent error", 1, backend.calls.get()); + } + + + @Test + public void breakerOpensAndFailsFastWithoutCallingBackend() throws Exception + { + set("resilience.retry.enabled", "false"); + set("resilience.breaker.failureThreshold", "3"); + backend.always = 503; + + api = buildApi(); + + for (int i = 0; i < 6; i++) + { + try + { + api.introspection(request()); + fail("every call should fail while the backend is down"); + } + catch (AuthleteApiException expected) + { + // expected + } + } + + // Only the first three calls reach the backend; after the threshold the + // breaker is open and subsequent calls fail fast. + assertEquals("backend shielded once the breaker opens", 3, backend.calls.get()); + } + + + @Test + public void revocationEvictsCachedIntrospectionForTheToken() throws Exception + { + api = buildApi(); + + // Prime the cache; the second call is served from it. + assertSame(canned, api.introspection(request())); + api.introspection(request()); + assertEquals("backend hit once while cached", 1, backend.calls.get()); + + // Revoke the same token through the proxy (URL-encoded form parameters). + api.revocation(new RevocationRequest() + .setParameters("token=token-123&token_type_hint=access_token")); + + // The cached entry must be gone: the next introspection reaches the + // backend again instead of reporting the revoked token as active. + api.introspection(request()); + assertEquals("cache evicted on revocation", 2, backend.calls.get()); + } + + + @Test + public void revocationOfAnotherTokenKeepsUnrelatedCacheEntries() throws Exception + { + api = buildApi(); + + assertSame(canned, api.introspection(request())); + assertEquals(1, backend.calls.get()); + + // Revoking a different token must not evict this token's entry. + api.revocation(new RevocationRequest() + .setParameters("token=other-token&token_type_hint=access_token")); + + api.introspection(request()); + assertEquals("unrelated entry still served from cache", 1, backend.calls.get()); + } + + + @Test + public void staleEntryIsServedAsFallbackOnFailure() throws Exception + { + set("resilience.retry.enabled", "false"); + set("resilience.cache.ttl.introspection", "1"); // 1s fresh, then stale + + api = buildApi(); + + // Prime the cache with a successful response. + assertSame(canned, api.introspection(request())); + assertEquals(1, backend.calls.get()); + + // Let the fresh TTL lapse so the entry is only "stale". + Thread.sleep(1200); + + // Backend now fails; the layer should serve the stale value instead. + backend.always = 503; + IntrospectionResponse result = api.introspection(request()); + + assertSame("stale cached value served as fallback", canned, result); + assertEquals("backend was attempted once more before falling back", + 2, backend.calls.get()); + } + + + private static void set(String key, String value) + { + System.setProperty(key, value); + } +}