diff --git a/pom.xml b/pom.xml
index 1634a092..bd119487 100644
--- a/pom.xml
+++ b/pom.xml
@@ -324,6 +324,13 @@
s3
+
+
+ com.github.mizosoft.methanol
+ methanol
+ 1.7.0
+
+
diff --git a/src/ext/java/no/entur/uttu/ext/fintraffic/security/FintrafficSecurityConfiguration.java b/src/ext/java/no/entur/uttu/ext/fintraffic/security/FintrafficSecurityConfiguration.java
new file mode 100644
index 00000000..28180011
--- /dev/null
+++ b/src/ext/java/no/entur/uttu/ext/fintraffic/security/FintrafficSecurityConfiguration.java
@@ -0,0 +1,31 @@
+package no.entur.uttu.ext.fintraffic.security;
+
+import no.entur.uttu.security.spi.UserContextService;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+
+@Configuration
+@Profile("fintraffic")
+public class FintrafficSecurityConfiguration {
+
+ @Bean
+ UserContextService userContextService(
+ @Value("${uttu.ext.fintraffic.security.tenant-id}") String tenantId,
+ @Value("${uttu.ext.fintraffic.security.client-id}") String clientId,
+ @Value("${uttu.ext.fintraffic.security.client-secret}") String clientSecret,
+ @Value("${uttu.ext.fintraffic.security.scope}") String scope,
+ @Value("${uttu.ext.fintraffic.security.admin-role-id}") String adminRoleId,
+ @Value("${uttu.ext.fintraffic.security.vaco-api}") String vacoApi
+ ) {
+ return new FintrafficUserContextService(
+ tenantId,
+ clientId,
+ clientSecret,
+ scope,
+ adminRoleId,
+ vacoApi
+ );
+ }
+}
diff --git a/src/ext/java/no/entur/uttu/ext/fintraffic/security/FintrafficUserContextService.java b/src/ext/java/no/entur/uttu/ext/fintraffic/security/FintrafficUserContextService.java
new file mode 100644
index 00000000..57f26540
--- /dev/null
+++ b/src/ext/java/no/entur/uttu/ext/fintraffic/security/FintrafficUserContextService.java
@@ -0,0 +1,313 @@
+package no.entur.uttu.ext.fintraffic.security;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.mizosoft.methanol.FormBodyPublisher;
+import com.github.mizosoft.methanol.Methanol;
+import com.github.mizosoft.methanol.MutableRequest;
+import java.io.IOException;
+import java.net.http.HttpClient;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import no.entur.uttu.ext.fintraffic.security.model.EntraTokenResponse;
+import no.entur.uttu.ext.fintraffic.security.model.Me;
+import no.entur.uttu.ext.fintraffic.security.model.VacoApiResponse;
+import no.entur.uttu.security.spi.UserContextService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+
+/**
+ * Fintraffic uses a combination of Entra, Microsoft Graph
+ * and in-house TIS VACO service specific data to determine user's access to various resources.
+ */
+public class FintrafficUserContextService implements UserContextService {
+
+ private static final Logger logger = LoggerFactory.getLogger(
+ FintrafficUserContextService.class
+ );
+
+ /**
+ * Microsoft Graph authentication scope requires this as its hardcoded value.
+ */
+ private static final String MICROSOFT_GRAPH_SCOPE =
+ "https://graph.microsoft.com/.default";
+ /**
+ * Microsoft Graph API root URI.
+ *
+ * @see Microsoft Graph REST API v1.0 endpoint reference
+ */
+ private static final String MICROSOFT_GRAPH_API_ROOT =
+ "https://graph.microsoft.com/v1.0";
+
+ /**
+ * Generic Microsoft Entra login URI, provided as template. Call "...".format(tenantId)
to acquire
+ * working login URI.
+ *
+ * @see Microsoft Entra
+ */
+ private static final String MICROSOFT_ENTRA_LOGIN_URI =
+ "https://login.microsoftonline.com/%s/oauth2/v2.0/token";
+
+ /**
+ * Entra tenant id for accessing any Entra or Graph protected resource within the represented tenant.
+ *
+ * @see #MICROSOFT_ENTRA_LOGIN_URI
+ */
+ private final String tenantId;
+
+ /**
+ * Entra client id for accessing Fintraffic TIS VACO API.
+ */
+ private final String clientId;
+
+ /**
+ * Entra client secret for accessing Fintraffic TIS VACO API.
+ */
+ private final String clientSecret;
+
+ /**
+ * Authentication scope for accessing Fintraffic TIS VACO API.
+ */
+ private final String scope;
+
+ /**
+ * Microsoft Entra application role's id for admin identifying users.
+ */
+ private final String adminRoleId;
+
+ /**
+ * Fintraffic TIS VACO service's API root URI.
+ */
+ private final String vacoApi;
+
+ /**
+ * Stores authentication scope to {@link EntraTokenResponse} for reusing authentication tokens.
+ */
+ private final ConcurrentMap> authenticationTokens =
+ new ConcurrentHashMap<>();
+
+ private final Methanol httpClient;
+
+ /**
+ * Internal {@link ObjectMapper} used for VACO API interactions, isolated from Spring's singleton on purpose to avoid
+ * accidental misconfigurations.
+ */
+ private final ObjectMapper objectMapper;
+
+ public FintrafficUserContextService(
+ String tenantId,
+ String clientId,
+ String clientSecret,
+ String scope,
+ String adminRoleId,
+ String vacoApi
+ ) {
+ this.tenantId = tenantId;
+ this.clientId = clientId;
+ this.clientSecret = clientSecret;
+ this.scope = scope;
+ this.adminRoleId = adminRoleId;
+ this.vacoApi = vacoApi;
+ this.objectMapper = initializeObjectMapper();
+ this.httpClient = initializeHttpClient();
+ }
+
+ private static ObjectMapper initializeObjectMapper() {
+ ObjectMapper om = new ObjectMapper();
+ om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ return om;
+ }
+
+ private static Methanol initializeHttpClient() {
+ return Methanol
+ .newBuilder()
+ .connectTimeout(Duration.ofSeconds(5))
+ .requestTimeout(Duration.ofSeconds(5))
+ .headersTimeout(Duration.ofSeconds(5))
+ .readTimeout(Duration.ofSeconds(5))
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .userAgent("Entur Uttu/" + LocalDate.now().format(DateTimeFormatter.ISO_DATE))
+ .build();
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return User's preferred name defined by the preferred_username
claim provided by Entra.
+ */
+ @Override
+ public String getPreferredName() {
+ return getToken()
+ .map(t -> t.getClaimAsString("preferred_username"))
+ .orElse("unknown");
+ }
+
+ private static Optional getToken() {
+ if (
+ SecurityContextHolder
+ .getContext()
+ .getAuthentication() instanceof JwtAuthenticationToken token &&
+ (token.getPrincipal() instanceof Jwt jwt)
+ ) {
+ return Optional.of(jwt);
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public boolean isAdmin() {
+ return login(MICROSOFT_GRAPH_SCOPE)
+ .flatMap(this::msGraphAppRoleAssignments)
+ .map(tree -> {
+ JsonNode assignedAppRoles = tree.path("value");
+ if (assignedAppRoles.isArray()) {
+ for (JsonNode assignedAppRole : assignedAppRoles) {
+ if (
+ assignedAppRole.has("appRoleId") &&
+ assignedAppRole.get("appRoleId").asText().equals(adminRoleId)
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ })
+ .orElse(false);
+ }
+
+ /**
+ * Hand-rolled implementation of acquiring app role assignments from MS Graph to avoid unnecessarily high amount of
+ * dependencies.
+ *
+ * @param serviceToken Token authorized to access {@link #MICROSOFT_GRAPH_SCOPE}
+ * @return Jackson {@link JsonNode} representing successful response from MS Graph or {@link Optional#empty()} if call failed.
+ * @see #login(String)
+ */
+ private Optional msGraphAppRoleAssignments(EntraTokenResponse serviceToken) {
+ return getToken()
+ .flatMap(jwt -> {
+ MutableRequest request = MutableRequest
+ .GET(
+ MICROSOFT_GRAPH_API_ROOT +
+ "/users/" +
+ jwt.getClaimAsString("oid") +
+ "/appRoleAssignments"
+ )
+ .header("Authorization", "Bearer " + serviceToken.getAccessToken())
+ .header("Content-Type", "application/json");
+ try {
+ HttpResponse response = httpClient.send(
+ request,
+ BodyHandlers.ofString()
+ );
+ JsonNode tree = objectMapper.readTree(response.body());
+ return Optional.of(tree);
+ } catch (IOException e) {
+ logger.warn("I/O error during API request", e);
+ } catch (InterruptedException e) {
+ logger.warn(
+ "Underlying thread interrupted during HTTP client action, interrupting current thread",
+ e
+ );
+ Thread.currentThread().interrupt();
+ }
+ return Optional.empty();
+ });
+ }
+
+ @Override
+ public boolean hasAccessToProvider(String providerCode) {
+ return login(scope)
+ .flatMap(this::me)
+ .map(me -> {
+ Set things = me
+ .data()
+ .companies()
+ .stream()
+ .flatMap(vc -> vc.codespaces().stream())
+ .collect(Collectors.toSet());
+ return things.contains(providerCode);
+ })
+ .orElse(false);
+ }
+
+ private Optional login(String tokenScope) {
+ AtomicReference tokenContainer =
+ authenticationTokens.computeIfAbsent(tokenScope, t -> new AtomicReference<>(null));
+ EntraTokenResponse currentToken = tokenContainer.get();
+ if (
+ currentToken == null ||
+ currentToken.getValidUntil().isBefore(LocalDateTime.now().minusSeconds(10))
+ ) {
+ try {
+ FormBodyPublisher formBody = FormBodyPublisher
+ .newBuilder()
+ .query("client_id", clientId)
+ .query("grant_type", "client_credentials")
+ .query("scope", tokenScope)
+ .query("client_secret", clientSecret)
+ .build();
+ MutableRequest request = MutableRequest.POST(
+ MICROSOFT_ENTRA_LOGIN_URI.formatted(tenantId),
+ formBody
+ );
+ HttpResponse response = httpClient.send(request, BodyHandlers.ofString());
+
+ EntraTokenResponse newToken = objectMapper.readValue(
+ response.body(),
+ EntraTokenResponse.class
+ );
+ tokenContainer.compareAndSet(currentToken, newToken);
+ } catch (IOException e) {
+ logger.warn("I/O error during API request", e);
+ } catch (InterruptedException e) {
+ logger.warn(
+ "Underlying thread interrupted during HTTP client action, interrupting current thread",
+ e
+ );
+ Thread.currentThread().interrupt();
+ }
+ }
+ return Optional.ofNullable(tokenContainer.get());
+ }
+
+ private Optional> me(EntraTokenResponse token) {
+ MutableRequest request = MutableRequest
+ .GET(vacoApi + "/me")
+ .header("Authorization", "Bearer " + token.getAccessToken())
+ .header("Content-Type", "application/json");
+ try {
+ HttpResponse response = httpClient.send(request, BodyHandlers.ofString());
+ return Optional.of(
+ objectMapper.readValue(
+ response.body(),
+ new TypeReference>() {}
+ )
+ );
+ } catch (IOException e) {
+ logger.warn("I/O error during API request", e);
+ } catch (InterruptedException e) {
+ logger.warn(
+ "Underlying thread interrupted during HTTP client action, interrupting current thread",
+ e
+ );
+ Thread.currentThread().interrupt();
+ }
+ return Optional.empty();
+ }
+}
diff --git a/src/ext/java/no/entur/uttu/ext/fintraffic/security/HttpClientException.java b/src/ext/java/no/entur/uttu/ext/fintraffic/security/HttpClientException.java
new file mode 100644
index 00000000..fea34f44
--- /dev/null
+++ b/src/ext/java/no/entur/uttu/ext/fintraffic/security/HttpClientException.java
@@ -0,0 +1,8 @@
+package no.entur.uttu.ext.fintraffic.security;
+
+public class HttpClientException extends RuntimeException {
+
+ public HttpClientException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/EntraTokenResponse.java b/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/EntraTokenResponse.java
new file mode 100644
index 00000000..6126ce5f
--- /dev/null
+++ b/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/EntraTokenResponse.java
@@ -0,0 +1,37 @@
+package no.entur.uttu.ext.fintraffic.security.model;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.LocalDateTime;
+
+public class EntraTokenResponse {
+
+ private final String tokenType;
+
+ private final LocalDateTime validUntil;
+
+ private final String accessToken;
+
+ @JsonCreator
+ public EntraTokenResponse(
+ @JsonProperty("token_type") String tokenType,
+ @JsonProperty("expires_in") long expiresIn,
+ @JsonProperty("access_token") String accessToken
+ ) {
+ this.tokenType = tokenType;
+ this.validUntil = LocalDateTime.now().plusSeconds(expiresIn);
+ this.accessToken = accessToken;
+ }
+
+ public String getTokenType() {
+ return tokenType;
+ }
+
+ public LocalDateTime getValidUntil() {
+ return validUntil;
+ }
+
+ public String getAccessToken() {
+ return accessToken;
+ }
+}
diff --git a/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/Me.java b/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/Me.java
new file mode 100644
index 00000000..b7a495e3
--- /dev/null
+++ b/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/Me.java
@@ -0,0 +1,5 @@
+package no.entur.uttu.ext.fintraffic.security.model;
+
+import java.util.List;
+
+public record Me(List companies) {}
diff --git a/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/VacoApiResponse.java b/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/VacoApiResponse.java
new file mode 100644
index 00000000..0d356470
--- /dev/null
+++ b/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/VacoApiResponse.java
@@ -0,0 +1,3 @@
+package no.entur.uttu.ext.fintraffic.security.model;
+
+public record VacoApiResponse(D data) {}
diff --git a/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/VacoCompany.java b/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/VacoCompany.java
new file mode 100644
index 00000000..a7c8b1a4
--- /dev/null
+++ b/src/ext/java/no/entur/uttu/ext/fintraffic/security/model/VacoCompany.java
@@ -0,0 +1,10 @@
+package no.entur.uttu.ext.fintraffic.security.model;
+
+import java.util.List;
+
+public record VacoCompany(
+ String businessId,
+ String name,
+ String language,
+ List codespaces
+) {}