Skip to content

Commit

Permalink
feat: implement Delegated Authentication Service (#4270)
Browse files Browse the repository at this point in the history
* add jwks resolver and auth service

* improved unit tests by splitting

* added extension test

* added fallback header, key caching [skip ci]

* updated log line

* license header

* license header

* DEPENDENCIES

* DEPENDENCIES

* only register tokenbased if no other is registered

* DEPENDENCIES

* pr remarks

* use factory method for JwksPublicKeyResolverImpl
  • Loading branch information
paullatzelsperger authored Jun 17, 2024
1 parent bacdf5a commit 4441956
Show file tree
Hide file tree
Showing 19 changed files with 967 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public Result<ClaimToken> validate(TokenRepresentation tokenRepresentation, Publ
var publicKeyResolutionResult = publicKeyResolver.resolveKey(publicKeyId);

if (publicKeyResolutionResult.failed()) {
return publicKeyResolutionResult.mapTo();
return publicKeyResolutionResult.mapFailure();
}

var verifierCreationResult = CryptoConverter.createVerifierFor(publicKeyResolutionResult.getContent());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,32 @@ public class ExpirationIssuedAtValidationRule implements TokenValidationRule {

private final Clock clock;
private final int issuedAtLeeway;
private final boolean allowNull;

/**
* Instantiates the rule
*
* @deprecated Please use {@link ExpirationIssuedAtValidationRule#ExpirationIssuedAtValidationRule(Clock, int, boolean)} instead
*/
@Deprecated(since = "0.7.0")
public ExpirationIssuedAtValidationRule(Clock clock, int issuedAtLeeway) {
this(clock, issuedAtLeeway, false);
}

public ExpirationIssuedAtValidationRule(Clock clock, int issuedAtLeeway, boolean allowNull) {
this.clock = clock;
this.issuedAtLeeway = issuedAtLeeway;
this.allowNull = allowNull;
}

@Override
public Result<Void> checkRule(@NotNull ClaimToken toVerify, @Nullable Map<String, Object> additional) {
var now = clock.instant();
var expires = toVerify.getInstantClaim(EXPIRATION_TIME);
if (expires == null) {
return Result.failure("Required expiration time (exp) claim is missing in token");
if (!allowNull) {
return Result.failure("Required expiration time (exp) claim is missing in token");
}
} else if (now.isAfter(expires)) {
return Result.failure("Token has expired (exp)");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,22 @@
public class NotBeforeValidationRule implements TokenValidationRule {
private final Clock clock;
private final int notBeforeLeeway;
private final boolean allowNull;

/**
* Instantiates the rule
*
* @deprecated Please use {@link NotBeforeValidationRule#NotBeforeValidationRule(Clock, int, boolean)} instead.
*/
@Deprecated(since = "0.7.0")
public NotBeforeValidationRule(Clock clock, int notBeforeLeeway) {
this(clock, notBeforeLeeway, false);
}

public NotBeforeValidationRule(Clock clock, int notBeforeLeeway, boolean allowNull) {
this.clock = clock;
this.notBeforeLeeway = notBeforeLeeway;
this.allowNull = allowNull;
}

@Override
Expand All @@ -45,7 +57,9 @@ public Result<Void> checkRule(@NotNull ClaimToken toVerify, @Nullable Map<String
var notBefore = toVerify.getInstantClaim(NOT_BEFORE);

if (notBefore == null) {
return Result.failure("Required not before (nbf) claim is missing in token");
if (!allowNull) {
return Result.failure("Required not before (nbf) claim is missing in token");
}
} else if (leewayNow.isBefore(notBefore)) {
return Result.failure("Current date/time with leeway before the not before (nbf) claim in token");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,21 @@ void validationKoBecauseExpirationTimeNotRespected() {
}

@Test
void validationKoBecauseExpirationTimeNotProvided() {
void validationKoBecauseExpirationTimeNotProvided_allowsNull() {
var r = new ExpirationIssuedAtValidationRule(clock, 0, true);
var token = ClaimToken.Builder.newInstance().build();

var result = rule.checkRule(token, emptyMap());
var result = r.checkRule(token, emptyMap());

assertThat(result.succeeded()).isTrue();
}

@Test
void validationKoBecauseExpirationTimeNotProvided_doesNotAllowNull() {
var r = new ExpirationIssuedAtValidationRule(clock, 0, false);
var token = ClaimToken.Builder.newInstance().build();

var result = r.checkRule(token, emptyMap());

assertThat(result.succeeded()).isFalse();
assertThat(result.getFailureMessages()).hasSize(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,25 @@ void validationKoBecauseNotBeforeTimeNotRespected() {
}

@Test
void validationKoBecauseNotBeforeTimeNotProvided() {
void validationKoBecauseNotBeforeTimeNotProvided_doesNotAllowNull() {
var r = new NotBeforeValidationRule(clock, notBeforeLeeway, false);
var token = ClaimToken.Builder.newInstance().build();

var result = rule.checkRule(token, emptyMap());
var result = r.checkRule(token, emptyMap());

assertThat(result.succeeded()).isFalse();
assertThat(result.getFailureMessages()).hasSize(1)
.contains("Required not before (nbf) claim is missing in token");
}

@Test
void validationKoBecauseNotBeforeTimeNotProvided_allowsNull() {
var r = new NotBeforeValidationRule(clock, notBeforeLeeway, true);
var token = ClaimToken.Builder.newInstance().build();

var result = r.checkRule(token, emptyMap());

assertThat(result.succeeded()).isTrue();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,9 @@ public void register(String context, AuthenticationService service) {
public @NotNull AuthenticationService resolve(String context) {
return services.getOrDefault(context, ALL_PASS);
}

@Override
public boolean hasService(String context) {
return services.containsKey(context);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,11 @@ void shouldReturnAllPass_whenNoServiceRegistered() {

assertThat(service).isInstanceOf(AllPassAuthenticationService.class);
}

@Test
void hasService() {
assertThat(registry.hasService("context")).isFalse();
registry.register("context", mock(AuthenticationService.class));
assertThat(registry.hasService("context")).isTrue();
}
}
33 changes: 33 additions & 0 deletions extensions/common/auth/auth-delegated/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)
*
* This program and the accompanying materials are made available under the
* terms of the Apache License, Version 2.0 which is available at
* https://www.apache.org/licenses/LICENSE-2.0
*
* SPDX-License-Identifier: Apache-2.0
*
* Contributors:
* Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation
*
*/

plugins {
`java-library`
}

dependencies {
api(project(":spi:common:auth-spi"))
api(project(":spi:common:token-spi"))
implementation(project(":core:common:token-core")) // for the validation rules
implementation(project(":core:common:lib:crypto-common-lib"))
implementation(libs.jakarta.rsApi)
implementation(libs.nimbus.jwt)

testImplementation(project(":core:common:junit"))
testImplementation(project(":core:common:lib:keys-lib"))
testImplementation(libs.mockserver.netty)
testImplementation(libs.awaitility)
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)
*
* This program and the accompanying materials are made available under the
* terms of the Apache License, Version 2.0 which is available at
* https://www.apache.org/licenses/LICENSE-2.0
*
* SPDX-License-Identifier: Apache-2.0
*
* Contributors:
* Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation
*
*/

package org.eclipse.edc.api.auth.delegated;

import org.eclipse.edc.api.auth.spi.registry.ApiAuthenticationRegistry;
import org.eclipse.edc.keys.spi.KeyParserRegistry;
import org.eclipse.edc.runtime.metamodel.annotation.Extension;
import org.eclipse.edc.runtime.metamodel.annotation.Inject;
import org.eclipse.edc.runtime.metamodel.annotation.Setting;
import org.eclipse.edc.spi.system.ServiceExtension;
import org.eclipse.edc.spi.system.ServiceExtensionContext;
import org.eclipse.edc.token.rules.ExpirationIssuedAtValidationRule;
import org.eclipse.edc.token.rules.NotBeforeValidationRule;
import org.eclipse.edc.token.spi.TokenValidationRulesRegistry;
import org.eclipse.edc.token.spi.TokenValidationService;

import java.time.Clock;

import static com.nimbusds.jose.jwk.source.JWKSourceBuilder.DEFAULT_CACHE_TIME_TO_LIVE;
import static org.eclipse.edc.api.auth.delegated.DelegatedAuthenticationService.MANAGEMENT_API_CONTEXT;

/**
* Extension that registers an AuthenticationService that delegates authentication and authorization to a third-party IdP
*/
@Extension(value = DelegatedAuthenticationExtension.NAME)
public class DelegatedAuthenticationExtension implements ServiceExtension {

public static final int DEFAULT_VALIDATION_TOLERANCE = 5_000;
public static final String NAME = "Delegating Authentication Service Extension";
@Setting(value = "Duration (in ms) that the internal key cache is valid", type = "Long", defaultValue = "" + DEFAULT_CACHE_TIME_TO_LIVE)
public static final String AUTH_SETTING_CACHE_VALIDITY_MS = "edc.api.auth.dac.cache.validity";
@Setting(value = "URL where the third-party IdP's public key(s) can be resolved")
public static final String AUTH_SETTING_KEY_URL = "edc.api.auth.dac.key.url";
@Setting(value = "Default token validation time tolerance (in ms), e.g. for nbf or exp claims", defaultValue = "" + DEFAULT_VALIDATION_TOLERANCE)
private static final String AUTH_SETTING_VALIDATION_TOLERANCE_MS = "edc.api.auth.dac.validation.tolerance";
@Inject
private ApiAuthenticationRegistry authenticationRegistry;
@Inject
private TokenValidationRulesRegistry tokenValidationRulesRegistry;
@Inject
private KeyParserRegistry keyParserRegistry;
@Inject
private TokenValidationService tokenValidationService;
@Inject
private Clock clock;

@Override
public String name() {
return NAME;
}

@Override
public void initialize(ServiceExtensionContext context) {
var monitor = context.getMonitor().withPrefix("Delegated API Authentication");

var keyUrl = context.getConfig().getString(AUTH_SETTING_KEY_URL, null);
if (keyUrl == null) {
monitor.warning("The '%s' setting was not provided, so the DelegatedAuthenticationService will NOT be registered. In this case, the TokenBasedAuthenticationService usually acts as fallback.".formatted(AUTH_SETTING_KEY_URL));
return;
}
var cacheValidityMs = context.getConfig().getLong(AUTH_SETTING_CACHE_VALIDITY_MS, DEFAULT_CACHE_TIME_TO_LIVE);
var tolerance = context.getConfig().getInteger(AUTH_SETTING_VALIDATION_TOLERANCE_MS, DEFAULT_VALIDATION_TOLERANCE);

//todo: currently, only JWKS urls are supported
var resolver = JwksPublicKeyResolver.create(keyParserRegistry, keyUrl, monitor, cacheValidityMs);

tokenValidationRulesRegistry.addRule(MANAGEMENT_API_CONTEXT, new NotBeforeValidationRule(clock, tolerance, true));
tokenValidationRulesRegistry.addRule(MANAGEMENT_API_CONTEXT, new ExpirationIssuedAtValidationRule(clock, tolerance, true));

// always register - this would potentially overwrite other services
authenticationRegistry.register("management-api", new DelegatedAuthenticationService(resolver, monitor, tokenValidationService, tokenValidationRulesRegistry));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)
*
* This program and the accompanying materials are made available under the
* terms of the Apache License, Version 2.0 which is available at
* https://www.apache.org/licenses/LICENSE-2.0
*
* SPDX-License-Identifier: Apache-2.0
*
* Contributors:
* Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation
*
*/

package org.eclipse.edc.api.auth.delegated;

import org.eclipse.edc.api.auth.spi.AuthenticationService;
import org.eclipse.edc.keys.spi.PublicKeyResolver;
import org.eclipse.edc.spi.monitor.Monitor;
import org.eclipse.edc.token.spi.TokenValidationRulesRegistry;
import org.eclipse.edc.token.spi.TokenValidationService;
import org.eclipse.edc.web.spi.exception.AuthenticationFailedException;

import java.util.List;
import java.util.Map;
import java.util.Optional;

import static jakarta.ws.rs.core.HttpHeaders.AUTHORIZATION;

public class DelegatedAuthenticationService implements AuthenticationService {

public static final String MANAGEMENT_API_CONTEXT = "management-api";
@Deprecated(since = "0.7.1")
private static final String X_API_KEY = "x-api-key";
public static final String OLD_API_KEY_WARNING = ("Header '%s' found with the DelegatedAuthenticationService. " +
"Please migrate to using the '%s' header at your earliest convenience, this compatibility feature will be removed in upcoming releases!").formatted(X_API_KEY, AUTHORIZATION);
private final PublicKeyResolver publicKeyResolver;
private final Monitor monitor;
private final TokenValidationService tokenValidationService;
private final TokenValidationRulesRegistry rulesRegistry;

public DelegatedAuthenticationService(PublicKeyResolver publicKeyResolver,
Monitor monitor,
TokenValidationService tokenValidationService,
TokenValidationRulesRegistry rulesRegistry) {
this.publicKeyResolver = publicKeyResolver;
this.monitor = monitor;
this.tokenValidationService = tokenValidationService;
this.rulesRegistry = rulesRegistry;
}

@Override
public boolean isAuthenticated(Map<String, List<String>> headers) {

if (headers == null) {
var msg = "Headers were null";
monitor.warning(msg);
throw new AuthenticationFailedException(msg);
}

var authHeaders = headers.get(AUTHORIZATION);
if (authHeaders == null || authHeaders.isEmpty()) {
// fall back to X-API-Key - backwards compatibility
authHeaders = headers.get(X_API_KEY);
if (authHeaders != null && !authHeaders.isEmpty()) {
monitor.warning(OLD_API_KEY_WARNING);
}
}

return Optional.ofNullable(authHeaders)
.map(this::performTokenValidation)
.orElseThrow(() -> {
var msg = "Header '%s' not present".formatted(AUTHORIZATION);
monitor.warning(msg);
return new AuthenticationFailedException(msg);
});

}

private boolean performTokenValidation(List<String> authHeaders) {
if (authHeaders.size() != 1) {
monitor.warning("Expected exactly 1 Authorization header, found %d".formatted(authHeaders.size()));
return false;
}
var token = authHeaders.get(0);
if (!token.toLowerCase().startsWith("bearer ")) {
monitor.warning("Authorization header must start with 'Bearer '");
return false;
}
token = token.substring(6).trim(); // "bearer" has 7 characters, it could be upper case, lower case or capitalized

var rules = rulesRegistry.getRules(MANAGEMENT_API_CONTEXT);
return tokenValidationService.validate(token, publicKeyResolver, rules).succeeded();
}

}
Loading

0 comments on commit 4441956

Please sign in to comment.