Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@
<version>${jackson.version}</version>
</dependency>


</dependencies>

</dependencyManagement>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,11 @@ public interface AppContextProvider {
* @since 1.0.0
*/
String urlToSamplePage(String projectId, String experimentId);

/**
* Returns the base URL of the application including its context path.
* @return the base URL and the context path
* @since 1.8.0
*/
String baseUrl();
}
134 changes: 134 additions & 0 deletions user-interface/auth-flow-zenodo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Integration of external services

Data Manager integrates services like ORCID (as identity provider) or Zenodo (as resource provider).

This document describes the technical view on how it has been integrated in the application and its
important components
for developers.

## OAuth 2.0

Data Manager uses the OAuth 2.0 protocol to acquire access resources on behalf of the resource
owner (e.g. the user in Data Manager).

The Data Manager application acts as the client and for allowing it to perform actions against an
extern resource such as Zenodo, it needs to get the resource owner's (user's) allowance first.

### Zenodo

We assume, that the user has already logged-in their Data Manager account and want to interact with
Zenodo, in order to create a draft on Zenodo with some metadata available
in their Data Manager project.

The current user Alice represents the resource owner and has an account on Zenodo and Data Manager.

First, let us see on a very top level view what happens:

```mermaid

sequenceDiagram
actor Alice
participant Data Manager
participant DM Session
participant Zenodo
Alice ->> Data Manager: provides credentials
activate Data Manager
Data Manager ->> DM Session: creates session for Alice
Data Manager ->> Alice: diplays projects
deactivate Data Manager
note right of Alice: Alice is logged in
Alice ->> Data Manager: create result record on Zenodo
activate Data Manager
Data Manager ->> Zenodo: inits authorization challenge
activate Zenodo
Zenodo ->> Alice: asks to log into her account
Alice ->> Zenodo: provides credentials
Zenodo ->> Alice: asks to give Data Manager access
Alice ->> Zenodo: gives Data Manager access
Zenodo ->> Data Manager: provides authorization
Data Manager ->> Zenodo: requests access token
Zenodo ->> Data Manager: gives access token for account
deactivate Zenodo
Data Manager ->> DM Session: stores Zenodo access token
Data Manager ->> Alice: informs Alice to continue
deactivate Data Manager
note right of Alice: Alice is now able to create a Zenodo record<br/> from within her data manager session


```

In order to make it work, we needed to tweak the default OAuth2.0 implementation flow of Spring in
order to make it
work for the Data Manager use case.

We have some extra components

- **Custom OAuth 2.0 callback controller**: intercepts the authorization challenge with Zenodo after
the authorization has been granted by the resource owner
- **Custom OAuth 2.0 access token response client**: turns out Zenodo does not like `client_id` and
`client_secret` being transferred in the HTTP header. So we need to put it in the HTTP message
body as form data.
- **Additional custom security context**: We don't want to interfere with the Spring security
context and the security implementation context of the logged-in principal. So we use an
additional one for the remote resource access only.

#### Custom OAuth2.0 callback controller

To the time of writing in the ``ZenodoOAuth2Controller.java`` class. As a Spring servlet controller,
the route `/zenodo/callback` will be registered by Spring and we can interecept all incoming http
requests. Exactly what we want when Zenodo grants Data Manager access on behalf of the user.

_What is wrong with the default Spring OAuth 2.0 handling?_

Nothing.

However:

1. Zenodo does not accept the access token challenge as it would be done Springs default
implementation. ``client_id`` and ``client_secret`` are usually passed Base64-encoded as Basic
authentication in the http message header. If you do that against the Zenodo API, you will receive a status code ``404`` (not found).

2. We need to enrich the user session with the acquired remote service access token. The default OAuth 2.0 flow wil otherwise replace the current user's existing security context.

This magic happens in our custom controller implementation and only in the case of handling third party access tokens. This does not interfere with the OAuth 2.0 flow that we use for log in via ORCID.

#### Additional custom security context

Next to the primary Spring security context, we enrich the session with additional principals, that are secondary and only for accessing remote resource server (e.g. Zenodo).

Although the access tokens have a longer life span (in case of Zenodo at the time of writing **2 months**), we decided against storing them
persistently on our side. It adds complexity to the Data Manager's implementation since access tokens are very sensitive material.

So access tokens currently only live as long as the user's current Data Manager session.
After that, users need to authorize Data Manager again, which is fast and easy, especially if they use ORCID as identity provide in both cases.

After successful access token retrieval, it is added to the Data Manager Security Context:

```java
import life.qbic.datamanager.security.context.DMOAuth2BearerToken;
import life.qbic.datamanager.security.context.DMSecurityContext;

DMOAuth2BearerToken token = new DMOAuth2BearerToken(accessToken, refreshToken, "zenodo");
var contextBuilder = new DMSecurityContext.Builder();
DMSecurityContext context = contextBuilder.addPrincipal(token).build();

// append context to the user session
session.addAttribute("DATA_MANAGER_SECURITY_CONTEXT", context);
```

So in any authenticated context in the Data Manager, we can now look for existing access tokens if the
user already granted access and do actions on behalf of them from the app.














4 changes: 4 additions & 0 deletions user-interface/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- docx4j dependencies !-->
<!-- https://mvnrepository.com/artifact/org.docx4j/docx4j-core -->
<dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,9 @@ public String urlToSamplePage(String projectId, String experimentId) {
throw new ApplicationException("Data Manager context creation failed.", e);
}
}

@Override
public String baseUrl() {
return baseUrlApplication.toExternalForm();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,20 @@
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.page.Page.ExtendedClientDetailsReceiver;
import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.server.RequestHandler;
import com.vaadin.flow.server.ServiceDestroyEvent;
import com.vaadin.flow.server.ServiceInitEvent;
import com.vaadin.flow.server.SessionDestroyEvent;
import com.vaadin.flow.server.SessionInitEvent;
import com.vaadin.flow.server.UIInitEvent;
import com.vaadin.flow.server.VaadinRequest;
import com.vaadin.flow.server.VaadinResponse;
import com.vaadin.flow.server.VaadinServiceInitListener;
import com.vaadin.flow.server.VaadinSession;
import com.vaadin.flow.server.WrappedSession;
import com.vaadin.flow.shared.ui.Transport;
import com.vaadin.flow.spring.annotation.SpringComponent;
import java.io.IOException;
import life.qbic.datamanager.exceptionhandling.UiExceptionHandler;
import life.qbic.datamanager.security.LogoutService;
import life.qbic.datamanager.views.AppRoutes;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package life.qbic.datamanager.security;

import static org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames.TOKEN_TYPE;
import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.ACCESS_TOKEN;
import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.EXPIRES_IN;

import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

/**
* <b><class short description - 1 Line!></b>
*
* <p><More detailed description - When to use, what it solves, etc.></p>
*
* @since <version tag>
*/
@Component
public class DMOAuth2AccessTokenResponseClient implements
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {

private static Map<String, Object> filterOptional(Map<String, Object> body) {
return body.entrySet().stream().filter(entry -> isOptionalParameter(entry.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}

private static boolean isOptionalParameter(String value) {
return switch (value) {
case "access_token" -> false;
case "token_type" -> false;
case "expires_in" -> false;
default -> true;
};
}

private static Optional<TokenType> fromString(String tokenType) {
switch (tokenType.toLowerCase()) {
case "bearer":
return Optional.of(TokenType.BEARER);
default:
return Optional.empty();
}
}

@Override
public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest request) {
// Send the token request
String tokenUri = request.getClientRegistration().getProviderDetails().getTokenUri();
ResponseEntity<Map<String, Object>> response = new RestTemplate().exchange(
tokenUri, HttpMethod.POST, createRequestEntity(request),
new ParameterizedTypeReference<>() {
}
);

// Parse the response manually
Map<String, Object> body = response.getBody();
String accessToken = (String) body.get(ACCESS_TOKEN);
var tokenTypeString = (String) body.get(TOKEN_TYPE);
TokenType tokenType = fromString((String) body.get(TOKEN_TYPE)).orElseThrow(() ->
new RuntimeException("Unknown token type: '%s'".formatted(tokenTypeString)));
long expiresIn = ((Number) body.get(EXPIRES_IN)).longValue();

return OAuth2AccessTokenResponse.withToken(accessToken)
.tokenType(tokenType)
.expiresIn(expiresIn)
.scopes(request.getClientRegistration().getScopes())
.additionalParameters(filterOptional(body))
.build();
}

private HttpEntity<?> createRequestEntity(OAuth2AuthorizationCodeGrantRequest request) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("code", request.getAuthorizationExchange().getAuthorizationResponse().getCode());
body.add("redirect_uri",
request.getAuthorizationExchange().getAuthorizationRequest().getRedirectUri());
body.add("client_id", request.getClientRegistration().getClientId());
body.add("client_secret", request.getClientRegistration().getClientSecret());

return new HttpEntity<>(body, headers);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package life.qbic.datamanager.security;

/**
* <b><class short description - 1 Line!></b>
*
* <p><More detailed description - When to use, what it solves, etc.></p>
*
* @since <version tag>
*/
import org.springframework.context.annotation.Bean;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.stereotype.Component;

@Component
public class FilterChainDebugger {

private final FilterChainProxy filterChainProxy;

public FilterChainDebugger(FilterChainProxy filterChainProxy) {
this.filterChainProxy = filterChainProxy;
}

@Bean
public void printFilterChains() {
filterChainProxy.getFilterChains().forEach(chain -> {
System.out.println("Filter Chain for: " + chain.getFilters());
chain.getFilters().forEach(filter -> System.out.println(" " + filter.getClass().getName()));
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package life.qbic.datamanager.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;

/**
* <b><class short description - 1 Line!></b>
*
* <p><More detailed description - When to use, what it solves, etc.></p>
*
* @since <version tag>
*/

@Configuration
public class OAuth2Config {

@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository) {
return new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientRepository);
}
}
Loading