Skip to content
Merged
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
22 changes: 20 additions & 2 deletions docs/access-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,28 @@ with the authorisation code grant, and with the redirect URI
provider must issue tokens to it without a client secret and without PKCE.

### Token validation
TESK API validates the token by hitting Elixir AAI `user_info` endpoint on each request and extracting information returned from that endpoint. If user info is returned without error, token is considered valid. Invalid token or lack of thereof in `authorisation: Bearer` header will result in `HTTP 401 Unauthorized` error. Tokens of allow-listed services are checked locally instead, if that is on (see [Tokens of services](#tokens-of-services)).
TESK API validates the token by hitting Elixir AAI `user_info` endpoint on each request and extracting information returned from that endpoint, using the resource server of Spring Security. A token is valid if the endpoint answers it (`200 OK`) with a JSON object that has a `sub` (not blank) and no `error`. TESK API waits at most 10 seconds to connect to the endpoint and 30 seconds for each read of its answer (these limits don't cover the name lookup of its host, nor the answer as a whole); if the endpoint can't be reached or doesn't answer in time, the token is refused like an invalid one. An invalid token, or lack of thereof in the `Authorization: Bearer` header, will result in `HTTP 401 Unauthorized` error. Tokens of allow-listed services are checked locally instead, if that is on (see [Tokens of services](#tokens-of-services)).

TESK API takes the token from the `Authorization` header only (`Bearer <token>`, as in [RFC 6750, section 2.1](https://datatracker.ietf.org/doc/html/rfc6750#section-2.1)), not from an `access_token` parameter in the URL or in a form. A header whose token is empty or has characters that bearer tokens can't have (anything but letters, digits and `-._~+/`, with `=` only at the end; e.g. spaces) is refused without a call to the user info endpoint.

The answer `401 Unauthorized` has a `WWW-Authenticate` header and a JSON body with the OAuth 2.0 error code and description:

* without a token: `WWW-Authenticate: Bearer realm="oauth", error="unauthorized", error_description="Full authentication is required to access this resource"`;
* with an invalid token: `error="invalid_token"`, with the description `Invalid access token` (`Bearer token is malformed` for a header as above; `Invalid access token of a service client` or `User ID of a service client` for [tokens of services](#tokens-of-services)). The token itself is never part of the answer.

```json
{
"timestamp" : "2026-11-14T10:00:00.000+00:00",
"status" : 401,
"error" : "invalid_token",
"message" : "Invalid access token",
"path" : "/ga4gh/tes/v1/tasks",
"error_description" : "Invalid access token"
}
```

### Integrating with a different OIDC provider
We have not tested TESK API integration with a different OIDC provider than Elixir AAI, but since standard Spring OAuth library is used, it should be possible (just point `security.oauth2.resource.user-info-uri`, or `SECURITY_OAUTH2_RESOURCE_USER_INFO_URI`, to a proper URL), provided that the other Authorization Server provides information about the user in the same format that Elixir does. Otherwise changes to [GroupNamesSecurityExtractor](https://github.com/elixir-cloud-aai/tesk-api/blob/dev/src/main/java/uk/ac/ebi/tsc/tesk/config/security/GroupNamesSecurityExtractor.java) and [ElixirPrincipalExtractor](https://github.com/elixir-cloud-aai/tesk-api/blob/dev/src/main/java/uk/ac/ebi/tsc/tesk/config/security/ElixirPrincipalExtractor.java) will be necessary.
We have not tested TESK API integration with a different OIDC provider than Elixir AAI, but since tokens are validated at the standard user info endpoint of OpenID Connect, it should be possible (just point `security.oauth2.resource.user-info-uri`, or `SECURITY_OAUTH2_RESOURCE_USER_INFO_URI`, to a proper URL), provided that the other Authorization Server provides information about the user in the same format that Elixir does. Otherwise changes to [GroupNamesSecurityExtractor](https://github.com/elixir-cloud-aai/tesk-api/blob/dev/src/main/java/uk/ac/ebi/tsc/tesk/config/security/GroupNamesSecurityExtractor.java) and [ElixirPrincipalExtractor](https://github.com/elixir-cloud-aai/tesk-api/blob/dev/src/main/java/uk/ac/ebi/tsc/tesk/config/security/ElixirPrincipalExtractor.java) will be necessary.

## Authorisation
TESK API uses Elixir AAI group membership to authorise users (and optionally a claim, see [Authorisation without groups](#authorisation-without-groups)). Elixir recognises group managers and members, but only includes information about the latter in `user_info` endpoint response. Therefore a nested structure of groups is used by us to reflect different roles of users.
Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ Status code | Meaning
--- | ---
200 | OK: successful request
400 | Bad request: validation error, or cancellation of a task that doesn't run
401 | Unauthorized: no access token in the request, or an invalid token
401 | Unauthorized: no access token in the request, or an invalid token (see [Token validation](access-control.md#token-validation) for the `WWW-Authenticate` header and the body)
403 | Forbidden: the user may not do this (usually, not in the required group, or without the required claim)
404 | Not found: no task with this ID
500 | Something else went wrong (possibly the configuration)
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Environment variable | Meaning
`TESK_API_TASKMASTER_EXECUTOR_SECRET_MOUNT_PATH` | Where that secret is mounted. Default: `/secret`.
`TESK_API_SERVICE_INFO_LOCATION` | The YAML file of [service info](#service-info). Default: the built-in `classpath:service-info.yaml`.
`TESK_API_SERVICE_INFO_PUBLIC` | With the `auth` profile: `true` (default) lets `GET` and `HEAD /service-info` answer without a token; `false` makes them need a valid token (no group membership). No effect with `noauth`, where all endpoints are open. Since 1.2.0.
`SECURITY_OAUTH2_RESOURCE_USER_INFO_URI` | User info endpoint of the OpenID Connect provider, which validates tokens; see [Access control](access-control.md#token-validation). Default: ELIXIR AAI.
`SECURITY_OAUTH2_RESOURCE_USER_INFO_URI` | User info endpoint of the OpenID Connect provider, which validates tokens; see [Access control](access-control.md#token-validation). Required with the `auth` profile: TESK API doesn't start unless it is an absolute `http` or `https` URL. The name comes from Spring Security OAuth, which TESK API no longer uses; it is the only one of its `SECURITY_OAUTH2_*` settings that TESK API reads. Default: ELIXIR AAI.
`TESK_API_AUTHORISATION_*` | Authorisation by group membership: `TESK_API_AUTHORISATION_GROUPS_CLAIM` (default `eduperson_entitlement`), `…_GROUP_PREFIX`, `…_GROUP_SUFFIX`, `…_DELIMITER`, `…_PARENT_GROUP`, `…_ENV_SUBGROUP`, `…_BASE_GROUP` and `…_ADMIN_SUBGROUP`; see [Access control](access-control.md#configuration) for their meanings and defaults.
`TESK_API_AUTHORISATION_LEGACY_USER_LABELS` | `true`: tasks created by earlier versions, which have only the old creator label, belong to their creators again; `false` (default): only team admins and super admins see them. See [Access control](access-control.md#task-creators). In the Helm chart of TESK: `auth.legacyUserLabels` (from chart 0.3.0).
`TESK_API_AUTHORISATION_CLAIM_*` | Authorisation without groups, by a claim of the user info; off by default. Turning it on lets everyone with one of the configured values run tasks; see [Access control](access-control.md#authorisation-without-groups) for the settings and their security implications.
Expand Down
28 changes: 28 additions & 0 deletions docs/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,34 @@ notice changed answers:
are more (see [Listing tasks](api.md#listing-tasks)). Before, it returned
all tasks at once. Clients that don't pass `page_size` must follow
`next_page_token`, or pass a `page_size` of up to 2048.
- **Token validation.** TESK API validates access tokens with the resource
server of Spring Security instead of Spring Security OAuth, which is end
of life. Deployments need no change if
`SECURITY_OAUTH2_RESOURCE_USER_INFO_URI` is an absolute `http` or
`https` URL: it keeps its name and meaning. TESK API reads none of the other `security.oauth2.*` settings of
Spring Security OAuth (e.g. `security.oauth2.resource.token-info-uri`),
which it never documented. Clients may notice (see [Token
validation](access-control.md#token-validation)):
- The token is taken from the `Authorization: Bearer` header only; an
`access_token` parameter in the URL or in a form is ignored, so such
requests get `401 Unauthorized`. Send the token in the header.
- For an invalid token, `error_description` and `message` of the
`401` say `Invalid access token` instead of repeating the token.
- A header with an empty token, or with characters that tokens can't
have (e.g. spaces), is refused with `401` (`invalid_token`,
`Bearer token is malformed`) without a call to the user info
endpoint. Before, it was passed on to the endpoint.
- A token whose user info has no `sub` (or a blank one) is refused with `401` instead
of failing with `500 Internal Server Error`.
- Without a valid token, a request that accepts no JSON (e.g.
`Accept: text/plain`) gets `401` with the `WWW-Authenticate`
header and no body.
- **User info endpoint.** TESK API waits at most 10 seconds to connect to
the user info endpoint and 30 seconds for each read of its answer, and then refuses
the token (before, it waited without limit). It doesn't start if
`SECURITY_OAUTH2_RESOURCE_USER_INFO_URI` isn't an absolute `http` or
`https` URL with a host; before, it started and refused every
token.
- **`ignore_error`.** `executors[].ignore_error: true` is accepted rather
than refused with `400 Bad Request` (see [TES 1.1
fields](api.md#tes-11-fields)). It needs a taskmaster that goes on after
Expand Down
11 changes: 3 additions & 8 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,6 @@
<dependencyManagement>
<dependencies>
<!-- Transitive dependencies with known vulnerabilities in the versions they ask for -->
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.5.2.RELEASE</version>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
Expand Down Expand Up @@ -113,10 +108,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Bearer tokens, validated at the user info endpoint (OpaqueTokenIntrospector) -->
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.6.8</version>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<!-- Checks the JWT access tokens of service clients (issue #153); also used by the
Kubernetes client, version managed above -->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package uk.ac.ebi.tsc.tesk;

import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.head;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static uk.ac.ebi.tsc.tesk.UrlConstants.TASK_URL;

import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.junit.WireMockRule;
import io.kubernetes.client.openapi.ApiClient;
import io.kubernetes.client.util.Config;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;

/**
* Integration testing of the answers to requests without a valid access token (auth profile): the
* status, the headers ({@code WWW-Authenticate}) and the JSON body. The user info endpoint is
* WireMocked.
*/
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(
locations = {"classpath:application.properties"},
properties = {
"security.oauth2.resource.user-info-uri = http://localhost:8090",
"spring.profiles.active=auth"
})
public class AuthErrorResponsesIT {

private static final String TOKEN = "TOKEN-THAT-MUST-NOT-BE-ECHOED";

private static final String NO_TOKEN_MESSAGE =
"Full authentication is required to access this resource";

private static final String INVALID_TOKEN_MESSAGE = "Invalid access token";

@Autowired private MockMvc mvc;

@Rule public WireMockRule mockUserInfo = new WireMockRule(8090);

@TestConfiguration
static class KubernetesClientMock {
@Bean
@Primary
public ApiClient kubernetesApiClient() {
return Config.fromUrl("http://localhost:9000", false);
}
}

/** The answer to a request without a usable token: as before the resource server moved */
private static void expectUnauthorized(ResultActions result, String error, String description)
throws Exception {
result.andExpect(status().isUnauthorized())
.andExpect(
header().string(
"WWW-Authenticate",
"Bearer realm=\"oauth\", error=\""
+ error
+ "\", error_description=\""
+ description
+ "\""))
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(header().string("Pragma", "no-cache"))
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(header().string("WWW-Authenticate", not(containsString(TOKEN))))
.andExpect(content().string(not(containsString(TOKEN))));
}

private static void expectBody(ResultActions result, String error, String description)
throws Exception {
result.andExpect(jsonPath("$.status").value(401))
.andExpect(jsonPath("$.error").value(error))
.andExpect(jsonPath("$.message").value(description))
.andExpect(jsonPath("$.error_description").value(description))
.andExpect(jsonPath("$.path").value(TASK_URL))
.andExpect(jsonPath("$.timestamp").exists());
}

@Test
public void withoutToken_isUnauthorized() throws Exception {
ResultActions result = this.mvc.perform(get(TASK_URL));
expectUnauthorized(result, "unauthorized", NO_TOKEN_MESSAGE);
expectBody(result, "unauthorized", NO_TOKEN_MESSAGE);
expectUnauthorized(this.mvc.perform(head(TASK_URL)), "unauthorized", NO_TOKEN_MESSAGE);
mockUserInfo.verify(0, anyRequestedFor(anyUrl()));
}

@Test
public void otherScheme_isUnauthorized() throws Exception {
ResultActions result =
this.mvc.perform(get(TASK_URL).header("Authorization", "Basic Zm9vOmJhcg=="));
expectUnauthorized(result, "unauthorized", NO_TOKEN_MESSAGE);
expectBody(result, "unauthorized", NO_TOKEN_MESSAGE);
mockUserInfo.verify(0, anyRequestedFor(anyUrl()));
}

/** The user info endpoint gets the token as a bearer token, and is asked for JSON */
@Test
public void validToken_isSentToUserInfo() throws Exception {
mockUserInfo.givenThat(
WireMock.get("/")
.willReturn(okJson("{\"sub\":\"123\",\"eduperson_entitlement\":[]}")));
this.mvc
.perform(get(TASK_URL).header("Authorization", "Bearer " + TOKEN))
.andExpect(status().isForbidden());
mockUserInfo.verify(
1,
getRequestedFor(urlEqualTo("/"))
.withHeader("Authorization", equalTo("Bearer " + TOKEN))
.withHeader("Accept", containing("application/json")));
}

private void expectInvalidToken() throws Exception {
ResultActions result =
this.mvc.perform(get(TASK_URL).header("Authorization", "Bearer " + TOKEN));
expectUnauthorized(result, "invalid_token", INVALID_TOKEN_MESSAGE);
expectBody(result, "invalid_token", INVALID_TOKEN_MESSAGE);
}

@Test
public void tokenRefusedByUserInfo_isUnauthorized() throws Exception {
mockUserInfo.givenThat(WireMock.get("/").willReturn(unauthorized()));
expectInvalidToken();
}

@Test
public void userInfoFails_isUnauthorized() throws Exception {
mockUserInfo.givenThat(WireMock.get("/").willReturn(serverError()));
expectInvalidToken();
}

@Test
public void userInfoWithError_isUnauthorized() throws Exception {
mockUserInfo.givenThat(
WireMock.get("/").willReturn(okJson("{\"error\":\"invalid_token\"}")));
expectInvalidToken();
}

@Test
public void userInfoWithoutSub_isUnauthorized() throws Exception {
mockUserInfo.givenThat(WireMock.get("/").willReturn(okJson("{\"name\":\"x\"}")));
expectInvalidToken();
}

@Test
public void userInfoNotJson_isUnauthorized() throws Exception {
mockUserInfo.givenThat(
WireMock.get("/")
.willReturn(
aResponse()
.withHeader("Content-Type", "application/json")
.withBody("[\"sub\"]")));
expectInvalidToken();
}

/** Malformed bearer tokens are refused without asking the user info endpoint */
@Test
public void malformedToken_isUnauthorized() throws Exception {
mockUserInfo.givenThat(
WireMock.get("/")
.willReturn(okJson("{\"sub\":\"123\",\"eduperson_entitlement\":[]}")));
for (String value : new String[] {"Bearer ", "Bearer a b", "Bearer a\"b"}) {
this.mvc
.perform(get(TASK_URL).header("Authorization", value))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("invalid_token"));
}
mockUserInfo.verify(0, anyRequestedFor(anyUrl()));
}

/** Tokens are only taken from the Authorization header, not from the URL */
@Test
public void tokenInQuery_isIgnored() throws Exception {
mockUserInfo.givenThat(
WireMock.get("/")
.willReturn(okJson("{\"sub\":\"123\",\"eduperson_entitlement\":[]}")));
ResultActions result = this.mvc.perform(get(TASK_URL).param("access_token", TOKEN));
expectUnauthorized(result, "unauthorized", NO_TOKEN_MESSAGE);
mockUserInfo.verify(0, anyRequestedFor(anyUrl()));
}
}
Loading
Loading