Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement Delegated Authentication Service #4270

Merged
merged 14 commits into from
Jun 17, 2024
Merged
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 @@ -18,6 +18,8 @@
import org.eclipse.edc.token.rules.ExpirationIssuedAtValidationRule;
import org.eclipse.edc.token.spi.TokenValidationRule;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import java.sql.Date;
import java.time.Clock;
Expand Down Expand Up @@ -60,15 +62,19 @@ void validationKoBecauseExpirationTimeNotRespected() {
.contains("Token has expired (exp)");
}

@Test
void validationKoBecauseExpirationTimeNotProvided() {
@ParameterizedTest(name = "Allow exp null: {0}")
@ValueSource(booleans = { true, false })
void validationKoBecauseExpirationTimeNotProvided(boolean allowNull) {
var r = new ExpirationIssuedAtValidationRule(clock, 0, allowNull);
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 expiration time (exp) claim is missing in token");
assertThat(result.succeeded()).isEqualTo(allowNull);
if (!allowNull) {
paullatzelsperger marked this conversation as resolved.
Show resolved Hide resolved
assertThat(result.getFailureMessages()).hasSize(1)
.contains("Required expiration time (exp) claim is missing in token");
}
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
import org.eclipse.edc.token.rules.NotBeforeValidationRule;
import org.eclipse.edc.token.spi.TokenValidationRule;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import java.sql.Date;
import java.time.Clock;
Expand Down Expand Up @@ -60,15 +62,19 @@ void validationKoBecauseNotBeforeTimeNotRespected() {
.contains("Current date/time with leeway before the not before (nbf) claim in token");
}

@Test
void validationKoBecauseNotBeforeTimeNotProvided() {
@ParameterizedTest(name = "Allow nbf null: {0}")
@ValueSource(booleans = { true, false })
void validationKoBecauseNotBeforeTimeNotProvided(boolean allowNull) {
paullatzelsperger marked this conversation as resolved.
Show resolved Hide resolved
var r = new NotBeforeValidationRule(clock, notBeforeLeeway, allowNull);
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");
assertThat(result.succeeded()).isEqualTo(allowNull);
if (!allowNull) {
assertThat(result.getFailureMessages()).hasSize(1)
.contains("Required not before (nbf) claim is missing in token");
}
}

}
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) 2020 - 2022 Microsoft Corporation
*
paullatzelsperger marked this conversation as resolved.
Show resolved Hide resolved
* 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:
* Microsoft Corporation - initial API and implementation
*
*/

plugins {
`java-library`
}

dependencies {
api(project(":spi:common:auth-spi"))
api(project(":spi:common:token-spi"))
api(project(":core:common:token-core")) // for the validation rules
paullatzelsperger marked this conversation as resolved.
Show resolved Hide resolved
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,87 @@
/*
* 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.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.Provides;
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
*/
@Provides(AuthenticationService.class)
@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 = new JwksPublicKeyResolver(keyParserRegistry, keyUrl, cacheValidityMs, monitor);

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

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
paullatzelsperger marked this conversation as resolved.
Show resolved Hide resolved
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);
}
paullatzelsperger marked this conversation as resolved.
Show resolved Hide resolved

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
Loading