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:
+ *
+ *
+ *
+ * - Entity ID + {@code /.well-known/openid-federation}
+ *
- 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.
+ *
+ *
+ *
+ * - {@code application/entity-statement+jwt}
+ *
- {@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