diff --git a/polaris-core/src/main/java/org/apache/polaris/core/auth/AuthenticatedPolarisPrincipal.java b/polaris-core/src/main/java/org/apache/polaris/core/auth/AuthenticatedPolarisPrincipal.java index dbfd404424..b097d1583f 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/auth/AuthenticatedPolarisPrincipal.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/auth/AuthenticatedPolarisPrincipal.java @@ -30,7 +30,7 @@ public class AuthenticatedPolarisPrincipal implements java.security.Principal { public AuthenticatedPolarisPrincipal( @Nonnull PolarisEntity principalEntity, @Nonnull Set activatedPrincipalRoles) { this.principalEntity = principalEntity; - this.activatedPrincipalRoleNames = activatedPrincipalRoles; + this.activatedPrincipalRoleNames = Set.copyOf(activatedPrincipalRoles); } @Override @@ -42,6 +42,13 @@ public PolarisEntity getPrincipalEntity() { return principalEntity; } + /** + * Returns the set of activated principal role names. Activated role names are the roles that were + * explicitly requested by the client when authenticating, through JWT claims or other means. + * + *

By convention, this method returns an empty set when the principal is requesting all + * available principal roles. + */ public Set getActivatedPrincipalRoleNames() { return activatedPrincipalRoleNames; } diff --git a/polaris-core/src/main/java/org/apache/polaris/core/config/ProductionReadinessCheck.java b/polaris-core/src/main/java/org/apache/polaris/core/config/ProductionReadinessCheck.java index d84f204bff..61d1549496 100644 --- a/polaris-core/src/main/java/org/apache/polaris/core/config/ProductionReadinessCheck.java +++ b/polaris-core/src/main/java/org/apache/polaris/core/config/ProductionReadinessCheck.java @@ -32,6 +32,10 @@ static ProductionReadinessCheck of(Error... errors) { return ImmutableProductionReadinessCheck.builder().addErrors(errors).build(); } + static ProductionReadinessCheck of(Iterable errors) { + return ImmutableProductionReadinessCheck.builder().addAllErrors(errors).build(); + } + default boolean ready() { return getErrors().isEmpty(); } diff --git a/quarkus/defaults/build.gradle.kts b/quarkus/defaults/build.gradle.kts index d3f33d9cc5..2b9d3ad488 100644 --- a/quarkus/defaults/build.gradle.kts +++ b/quarkus/defaults/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { compileOnly("io.quarkus:quarkus-smallrye-health") compileOnly("io.quarkus:quarkus-micrometer") compileOnly("io.quarkus:quarkus-micrometer-registry-prometheus") + compileOnly("io.quarkus:quarkus-oidc") compileOnly("io.quarkus:quarkus-opentelemetry") compileOnly("io.quarkus:quarkus-smallrye-context-propagation") } diff --git a/quarkus/defaults/src/main/resources/application.properties b/quarkus/defaults/src/main/resources/application.properties index c7676b2ba0..d9b82239e3 100644 --- a/quarkus/defaults/src/main/resources/application.properties +++ b/quarkus/defaults/src/main/resources/application.properties @@ -30,6 +30,7 @@ quarkus.http.compress-media-types=application/json,text/html,text/plain quarkus.management.enabled=true quarkus.micrometer.enabled=true quarkus.micrometer.export.prometheus.enabled=true +quarkus.oidc.enabled=true quarkus.otel.enabled=true # ---- Runtime Configuration ---- @@ -74,6 +75,18 @@ quarkus.log.category."io.smallrye.config".level=INFO quarkus.management.port=8182 quarkus.management.test-port=0 +# OIDC settings. These settings are required only when using external authentication providers. +# See https://quarkus.io/guides/security-oidc-configuration-properties-reference +# Default tenant (disabled by default, set this to true if you use external authentication) +quarkus.oidc.tenant-enabled=false +# quarkus.oidc.auth-server-url=https://auth.example.com/realms/polaris +# quarkus.oidc.client-id=polaris +# Roles mapping; see https://quarkus.io/guides/security-oidc-bearer-token-authentication#token-claims-and-security-identity-roles +# quarkus.oidc.roles.role-claim-path=realm/groups +# Named tenants: +# quarkus.oidc.idp1.auth-server-url=https://auth.example.com/realms/polaris2 +# quarkus.oidc.idp1.client-id=polaris2 + # quarkus.otel.sdk.disabled is set to `true` by default to avoid spuriour errors about # trace collector connections being impossible to establish. This setting can be enabled # at runtime after configuring other OTel properties for proper trace data collection. @@ -130,7 +143,14 @@ polaris.rate-limiter.token-bucket.window=PT10S polaris.active-roles-provider.type=default +# Polaris authentication settings +polaris.authentication.type=internal polaris.authentication.authenticator.type=default +# Per-realm overrides: +# polaris.authentication.realm1.type=external +# polaris.authentication.realm1.authenticator.type=custom + +# Options effective when using internal auth (can be overridden in per realm): polaris.authentication.token-service.type=default polaris.authentication.token-broker.type=rsa-key-pair polaris.authentication.token-broker.max-token-generation=PT1H @@ -139,6 +159,27 @@ polaris.authentication.token-broker.max-token-generation=PT1H # polaris.authentication.token-broker.symmetric-key.secret=secret # polaris.authentication.token-broker.symmetric-key.file=/tmp/symmetric.key +# OIDC Principals mapping +polaris.oidc.principal-mapper.type=default +# polaris.oidc.principal-mapper.id-claim-path=sub +# polaris.oidc.principal-mapper.name-claim-path=preferred_username +# Per-tenant overrides: +# polaris.oidc.idp1.principal-mapper.id-claim-path=polaris/principal_id +# polaris.oidc.idp1.principal-mapper.name-claim-path=polaris/principal_name + +# OIDC Principal roles mapping +polaris.oidc.principal-roles-mapper.type=default +# Principal role mapping is done through quarkus.oidc.roles.role-claim-path +# The properties below define how the roles mapped by Quarkus are converted to Polaris roles +# polaris.oidc.principal-roles-mapper.filter=PRINCIPAL_ROLE:.* +# polaris.oidc.principal-roles-mapper.mappings[0].regex=PRINCIPAL_ROLE:(.*) +# polaris.oidc.principal-roles-mapper.mappings[0].replacement=PRINCIPAL_ROLE:$1 +# Per-tenant overrides: +# polaris.oidc.idp1.principal-roles-mapper.type=custom +# polaris.oidc.idp1.principal-roles-mapper.filter=POLARIS_ROLE:.* +# polaris.oidc.idp1.principal-roles-mapper.mappings[0].regex=POLARIS_ROLE:(.*) +# polaris.oidc.idp1.principal-roles-mapper.mappings[0].replacement=POLARIS_ROLE:$1 + # If the following properties are unset, the default credential provider chain will be used # polaris.storage.aws.access-key=accessKey # polaris.storage.aws.secret-key=secretKey diff --git a/quarkus/service/build.gradle.kts b/quarkus/service/build.gradle.kts index 97b3daa0de..61d96ec894 100644 --- a/quarkus/service/build.gradle.kts +++ b/quarkus/service/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { implementation("io.quarkus:quarkus-smallrye-health") implementation("io.quarkus:quarkus-micrometer") implementation("io.quarkus:quarkus-micrometer-registry-prometheus") + implementation("io.quarkus:quarkus-oidc") implementation("io.quarkus:quarkus-opentelemetry") implementation("io.quarkus:quarkus-security") implementation("io.quarkus:quarkus-smallrye-context-propagation") diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/ActiveRolesAugmentor.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/ActiveRolesAugmentor.java index b19c93e39c..6ff3210cfd 100644 --- a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/ActiveRolesAugmentor.java +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/ActiveRolesAugmentor.java @@ -32,12 +32,26 @@ /** * A custom {@link SecurityIdentityAugmentor} that adds active roles to the {@link - * SecurityIdentity}. This is used to augment the identity with active roles after authentication. + * SecurityIdentity}. This is used to augment the identity with valid active roles after + * authentication. */ @ApplicationScoped public class ActiveRolesAugmentor implements SecurityIdentityAugmentor { - @Inject ActiveRolesProvider activeRolesProvider; + // must run after AuthenticatingAugmentor + public static final int PRIORITY = AuthenticatingAugmentor.PRIORITY - 1; + + private final ActiveRolesProvider activeRolesProvider; + + @Inject + public ActiveRolesAugmentor(ActiveRolesProvider activeRolesProvider) { + this.activeRolesProvider = activeRolesProvider; + } + + @Override + public int priority() { + return PRIORITY; + } @Override public Uni augment( @@ -45,20 +59,20 @@ public Uni augment( if (identity.isAnonymous()) { return Uni.createFrom().item(identity); } - return context.runBlocking(() -> augmentWithActiveRoles(identity)); + return context.runBlocking(() -> validateActiveRoles(identity)); } - private SecurityIdentity augmentWithActiveRoles(SecurityIdentity identity) { - AuthenticatedPolarisPrincipal polarisPrincipal = - identity.getPrincipal(AuthenticatedPolarisPrincipal.class); - if (polarisPrincipal == null) { + private SecurityIdentity validateActiveRoles(SecurityIdentity identity) { + if (!(identity.getPrincipal() instanceof AuthenticatedPolarisPrincipal)) { throw new AuthenticationFailedException("No Polaris principal found"); } + AuthenticatedPolarisPrincipal polarisPrincipal = + identity.getPrincipal(AuthenticatedPolarisPrincipal.class); Set validRoleNames = activeRolesProvider.getActiveRoles(polarisPrincipal); return QuarkusSecurityIdentity.builder() .setAnonymous(false) .setPrincipal(polarisPrincipal) - .addRoles(validRoleNames) + .addRoles(validRoleNames) // replace the current roles with valid ones .addCredentials(identity.getCredentials()) .addAttributes(identity.getAttributes()) .addPermissionChecker(identity::checkPermission) diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/AuthenticatingAugmentor.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/AuthenticatingAugmentor.java new file mode 100644 index 0000000000..eb5e8d63f2 --- /dev/null +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/AuthenticatingAugmentor.java @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.SecurityIdentityAugmentor; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.apache.iceberg.exceptions.NotAuthorizedException; +import org.apache.polaris.core.auth.AuthenticatedPolarisPrincipal; +import org.apache.polaris.service.auth.Authenticator; +import org.apache.polaris.service.auth.PrincipalAuthInfo; + +/** + * A custom {@link SecurityIdentityAugmentor} that, after Quarkus OIDC or Internal Auth extracted + * and validated the principal credentials, augments the {@link SecurityIdentity} by authenticating + * the principal and setting an {@link AuthenticatedPolarisPrincipal} as the identity's principal. + */ +@ApplicationScoped +public class AuthenticatingAugmentor implements SecurityIdentityAugmentor { + + public static final int PRIORITY = 1000; + + private final Authenticator authenticator; + + @Inject + public AuthenticatingAugmentor( + Authenticator authenticator) { + this.authenticator = authenticator; + } + + @Override + public int priority() { + return PRIORITY; + } + + @Override + public Uni augment( + SecurityIdentity identity, AuthenticationRequestContext context) { + if (identity.isAnonymous()) { + return Uni.createFrom().item(identity); + } + PrincipalAuthInfo authInfo = extractPrincipalAuthInfo(identity); + return context.runBlocking(() -> authenticatePolarisPrincipal(identity, authInfo)); + } + + private PrincipalAuthInfo extractPrincipalAuthInfo(SecurityIdentity identity) { + QuarkusPrincipalAuthInfo credential = identity.getCredential(QuarkusPrincipalAuthInfo.class); + if (credential == null) { + throw new AuthenticationFailedException("No token credential available"); + } + return credential; + } + + private SecurityIdentity authenticatePolarisPrincipal( + SecurityIdentity identity, PrincipalAuthInfo authInfo) { + try { + AuthenticatedPolarisPrincipal polarisPrincipal = + authenticator + .authenticate(authInfo) + .orElseThrow(() -> new NotAuthorizedException("Authentication failed")); + return QuarkusSecurityIdentity.builder(identity).setPrincipal(polarisPrincipal).build(); + } catch (RuntimeException e) { + throw new AuthenticationFailedException(e); + } + } +} diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/PolarisIdentityProvider.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/PolarisIdentityProvider.java deleted file mode 100644 index c3994a9ea6..0000000000 --- a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/PolarisIdentityProvider.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.polaris.service.quarkus.auth; - -import io.quarkus.security.AuthenticationFailedException; -import io.quarkus.security.identity.AuthenticationRequestContext; -import io.quarkus.security.identity.IdentityProvider; -import io.quarkus.security.identity.SecurityIdentity; -import io.quarkus.security.identity.request.TokenAuthenticationRequest; -import io.quarkus.security.runtime.QuarkusSecurityIdentity; -import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; -import io.smallrye.mutiny.Uni; -import io.vertx.ext.web.RoutingContext; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; -import jakarta.ws.rs.NotAuthorizedException; -import org.apache.polaris.core.auth.AuthenticatedPolarisPrincipal; -import org.apache.polaris.service.auth.Authenticator; -import org.apache.polaris.service.quarkus.auth.PolarisAuthenticationMechanism.PolarisTokenCredential; - -/** A custom {@link IdentityProvider} that handles Polaris token authentication requests. */ -@ApplicationScoped -public class PolarisIdentityProvider implements IdentityProvider { - - @Inject Authenticator authenticator; - - @Override - public Class getRequestType() { - return TokenAuthenticationRequest.class; - } - - @Override - public Uni authenticate( - TokenAuthenticationRequest request, AuthenticationRequestContext context) { - if (!(request.getToken() instanceof PolarisTokenCredential)) { - return Uni.createFrom().nullItem(); - } - return context.runBlocking(() -> createSecurityIdentity(request, authenticator)); - } - - public SecurityIdentity createSecurityIdentity( - TokenAuthenticationRequest request, - Authenticator authenticator) { - try { - AuthenticatedPolarisPrincipal principal = - authenticator - .authenticate(request.getToken().getToken()) - .orElseThrow(() -> new NotAuthorizedException("Authentication failed")); - QuarkusSecurityIdentity.Builder builder = - QuarkusSecurityIdentity.builder() - .setPrincipal(principal) - .addCredential(request.getToken()) - .addRoles(principal.getActivatedPrincipalRoleNames()) - .addAttribute(SecurityIdentity.USER_ATTRIBUTE, principal); - RoutingContext routingContext = HttpSecurityUtils.getRoutingContextAttribute(request); - if (routingContext != null) { - builder.addAttribute(RoutingContext.class.getName(), routingContext); - } - return builder.build(); - } catch (RuntimeException e) { - throw new AuthenticationFailedException(e); - } - } -} diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/QuarkusAuthenticationConfiguration.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/QuarkusAuthenticationConfiguration.java index 0065763c43..3cdc2f85c8 100644 --- a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/QuarkusAuthenticationConfiguration.java +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/QuarkusAuthenticationConfiguration.java @@ -19,44 +19,19 @@ package org.apache.polaris.service.quarkus.auth; import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefaults; +import io.smallrye.config.WithParentName; +import io.smallrye.config.WithUnnamedKey; +import java.util.Map; import org.apache.polaris.service.auth.AuthenticationConfiguration; @ConfigMapping(prefix = "polaris.authentication") -public interface QuarkusAuthenticationConfiguration extends AuthenticationConfiguration { +public interface QuarkusAuthenticationConfiguration + extends AuthenticationConfiguration { + @WithParentName + @WithUnnamedKey(DEFAULT_REALM_KEY) + @WithDefaults @Override - QuarkusAuthenticatorConfiguration authenticator(); - - @Override - QuarkusTokenServiceConfiguration tokenService(); - - @Override - QuarkusTokenBrokerConfiguration tokenBroker(); - - interface QuarkusAuthenticatorConfiguration extends AuthenticatorConfiguration { - - /** - * The type of the authenticator. Must be a registered {@link - * org.apache.polaris.service.auth.Authenticator} identifier. - */ - String type(); - } - - interface QuarkusTokenServiceConfiguration extends TokenServiceConfiguration { - - /** - * The type of the OAuth2 service. Must be a registered {@link - * org.apache.polaris.service.catalog.api.IcebergRestOAuth2ApiService} identifier. - */ - String type(); - } - - interface QuarkusTokenBrokerConfiguration extends TokenBrokerConfiguration { - - /** - * The type of the token broker factory. Must be a registered {@link - * org.apache.polaris.service.auth.TokenBrokerFactory} identifier. - */ - String type(); - } + Map realms(); } diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/QuarkusAuthenticationRealmConfiguration.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/QuarkusAuthenticationRealmConfiguration.java new file mode 100644 index 0000000000..b25f98301e --- /dev/null +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/QuarkusAuthenticationRealmConfiguration.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth; + +import io.smallrye.config.WithDefault; +import java.time.Duration; +import org.apache.polaris.service.auth.AuthenticationRealmConfiguration; +import org.apache.polaris.service.auth.AuthenticationType; + +public interface QuarkusAuthenticationRealmConfiguration extends AuthenticationRealmConfiguration { + + @WithDefault("internal") + @Override + AuthenticationType type(); + + @Override + QuarkusAuthenticatorConfiguration authenticator(); + + @Override + QuarkusTokenServiceConfiguration tokenService(); + + @Override + QuarkusTokenBrokerConfiguration tokenBroker(); + + interface QuarkusAuthenticatorConfiguration extends AuthenticatorConfiguration { + + /** + * The type of the identity provider. Must be a registered {@link + * org.apache.polaris.service.auth.Authenticator} identifier. + */ + @WithDefault("default") + String type(); + } + + interface QuarkusTokenServiceConfiguration extends TokenServiceConfiguration { + /** + * The type of the OAuth2 service. Must be a registered {@link + * org.apache.polaris.service.catalog.api.IcebergRestOAuth2ApiService} identifier. + */ + @WithDefault("default") + String type(); + } + + interface QuarkusTokenBrokerConfiguration extends TokenBrokerConfiguration { + + @WithDefault("PT1H") + @Override + Duration maxTokenGeneration(); + + /** + * The type of the token broker factory. Must be a registered {@link + * org.apache.polaris.service.auth.TokenBrokerFactory} identifier. + */ + @WithDefault("rsa-key-pair") + String type(); + } +} diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/QuarkusPrincipalAuthInfo.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/QuarkusPrincipalAuthInfo.java new file mode 100644 index 0000000000..6b693d7dab --- /dev/null +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/QuarkusPrincipalAuthInfo.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth; + +import io.quarkus.security.credential.Credential; +import io.quarkus.security.identity.SecurityIdentity; +import org.apache.polaris.service.auth.PrincipalAuthInfo; + +/** + * Convenience sub-interface of Polaris {@link PrincipalAuthInfo} that also implements Quarkus + * {@link Credential}, thus allowing it to be used as a {@linkplain + * SecurityIdentity#getCredential(Class) security identity credential}. + */ +public interface QuarkusPrincipalAuthInfo extends PrincipalAuthInfo, Credential {} diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/OidcConfiguration.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/OidcConfiguration.java new file mode 100644 index 0000000000..4e856d5e29 --- /dev/null +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/OidcConfiguration.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import io.smallrye.config.WithDefaults; +import io.smallrye.config.WithName; +import io.smallrye.config.WithParentName; +import io.smallrye.config.WithUnnamedKey; +import java.util.Map; +import org.apache.polaris.service.quarkus.auth.external.tenant.OidcTenantResolver; + +/** Polaris-specific configuration for OIDC tenants. */ +@ConfigMapping(prefix = "polaris.oidc") +public interface OidcConfiguration { + + String DEFAULT_TENANT_KEY = ""; + + /** + * Configuration for each OIDC tenant. The tenant ID must have a corresponding entry in {@code + * quarkus.oidc.} configuration. + */ + @WithParentName + @WithUnnamedKey(DEFAULT_TENANT_KEY) + @WithDefaults + Map tenants(); + + /** + * The type of the OIDC tenant resolver. Must be a registered {@link OidcTenantResolver} + * implementation. + */ + @WithDefault("default") + @WithName("tenant-resolver.type") + String tenantResolver(); +} diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/OidcTenantConfiguration.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/OidcTenantConfiguration.java new file mode 100644 index 0000000000..80ed16d291 --- /dev/null +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/OidcTenantConfiguration.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external; + +import io.smallrye.config.WithDefault; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; + +/** Polaris-specific, per-tenant configuration for OIDC authentication. */ +public interface OidcTenantConfiguration { + + /** + * Identity mapping configuration for this OIDC tenant. This configuration is used to map claims + * in the JWT token to Polaris principals. + */ + PrincipalMapper principalMapper(); + + /** + * Roles mapping configuration for this OIDC tenant. This configuration is used to map claims in + * the JWT token to Polaris principal roles. + */ + PrincipalRolesMapper principalRolesMapper(); + + interface PrincipalMapper { + + /** + * The path to the claim that contains the principal ID. Nested paths can be expressed using "/" + * as a separator, e.g. {@code "resource_access/client1/roles"} would look for the "roles" field + * inside the "client1" object inside the "resource_access" object in the token claims. + * + *

Optional. Either this option or {@link #nameClaimPath()} must be provided. + */ + Optional idClaimPath(); + + /** + * The claim that contains the principal name. Nested paths can be expressed using "/" as a + * separator, e.g. {@code "resource_access/client1/roles"} would look for the "roles" field + * inside the "client1" object inside the "resource_access" object in the token claims. + * + *

Optional. Either this option or {@link #idClaimPath()} must be provided. + */ + Optional nameClaimPath(); + + /** + * The type of the principal mapper. Must be a registered {@link + * org.apache.polaris.service.quarkus.auth.external.mapping.PrincipalMapper} identifier. + */ + @WithDefault("default") + String type(); + } + + interface PrincipalRolesMapper { + + /** + * The type of the principal roles mapper. Must be a registered {@link + * org.apache.polaris.service.quarkus.auth.external.mapping.PrincipalRolesMapper} identifier. + */ + @WithDefault("default") + String type(); + + /** + * A regular expression that matches the role names in the identity. Only roles that match this + * regex will be included in the Polaris-specific roles. + */ + Optional filter(); + + /** A list of regex mappings that will be applied to each role name in the identity. */ + List mappings(); + + default Predicate filterPredicate() { + return role -> filter().isEmpty() || role.matches(filter().get()); + } + + default Function mapperFunction() { + return role -> { + for (RegexMapping mapping : mappings()) { + role = mapping.replace(role); + } + return role; + }; + } + + interface RegexMapping { + + /** + * A regular expression that will be applied to each role name in the identity. Along with + * {@link #replacement()}, this regex is used to transform the role names in the identity into + * Polaris-specific roles. + */ + String regex(); + + /** + * The replacement string for the role names in the identity. This is used along with {@link + * #regex()} to transform the role names in the identity into Polaris-specific roles. + */ + String replacement(); + + default String replace(String role) { + return role.replaceAll(regex(), replacement()); + } + } + } +} diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/OidcTenantResolvingAugmentor.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/OidcTenantResolvingAugmentor.java new file mode 100644 index 0000000000..819f0d337d --- /dev/null +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/OidcTenantResolvingAugmentor.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external; + +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.SecurityIdentityAugmentor; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.apache.polaris.service.quarkus.auth.external.tenant.OidcTenantResolver; +import org.eclipse.microprofile.jwt.JsonWebToken; + +/** + * A {@link SecurityIdentityAugmentor} that resolves the Polaris OIDC tenant configuration for the + * given identity and adds it as an attribute to the {@link SecurityIdentity}. + */ +@ApplicationScoped +public class OidcTenantResolvingAugmentor implements SecurityIdentityAugmentor { + + public static final String TENANT_CONFIG_ATTRIBUTE = "polaris-tenant-config"; + + // must run before PrincipalAuthInfoAugmentor + public static final int PRIORITY = PrincipalAuthInfoAugmentor.PRIORITY + 100; + + public static OidcTenantConfiguration getOidcTenantConfig(SecurityIdentity identity) { + return identity.getAttribute(TENANT_CONFIG_ATTRIBUTE); + } + + private final OidcTenantResolver resolver; + + @Inject + public OidcTenantResolvingAugmentor(OidcTenantResolver resolver) { + this.resolver = resolver; + } + + @Override + public int priority() { + return PRIORITY; + } + + @Override + public Uni augment( + SecurityIdentity identity, AuthenticationRequestContext context) { + if (identity.isAnonymous() || !(identity.getPrincipal() instanceof JsonWebToken)) { + return Uni.createFrom().item(identity); + } + return Uni.createFrom().item(() -> setOidcTenantConfig(identity)); + } + + private SecurityIdentity setOidcTenantConfig(SecurityIdentity identity) { + var config = resolver.resolveConfig(identity); + return QuarkusSecurityIdentity.builder(identity) + .addAttribute(TENANT_CONFIG_ATTRIBUTE, config) + .build(); + } +} diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/PrincipalAuthInfoAugmentor.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/PrincipalAuthInfoAugmentor.java new file mode 100644 index 0000000000..5477c20990 --- /dev/null +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/PrincipalAuthInfoAugmentor.java @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external; + +import static org.apache.polaris.service.quarkus.auth.external.OidcTenantResolvingAugmentor.getOidcTenantConfig; + +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.SecurityIdentityAugmentor; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.smallrye.common.annotation.Identifier; +import io.smallrye.mutiny.Uni; +import jakarta.annotation.Nullable; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Any; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import java.util.Set; +import org.apache.polaris.service.quarkus.auth.AuthenticatingAugmentor; +import org.apache.polaris.service.quarkus.auth.QuarkusPrincipalAuthInfo; +import org.apache.polaris.service.quarkus.auth.external.mapping.PrincipalMapper; +import org.apache.polaris.service.quarkus.auth.external.mapping.PrincipalRolesMapper; +import org.eclipse.microprofile.jwt.JsonWebToken; + +/** + * A {@link SecurityIdentityAugmentor} that maps the access token claims, as provided by the OIDC + * authentication mechanism, to Polaris-specific properties (principal and principal roles). + */ +@ApplicationScoped +public class PrincipalAuthInfoAugmentor implements SecurityIdentityAugmentor { + + // must run before the authenticating augmentor + public static final int PRIORITY = AuthenticatingAugmentor.PRIORITY + 100; + + private final Instance principalMappers; + private final Instance principalRoleMappers; + + @Inject + public PrincipalAuthInfoAugmentor( + @Any Instance principalMappers, + @Any Instance principalRoleMappers) { + this.principalMappers = principalMappers; + this.principalRoleMappers = principalRoleMappers; + } + + @Override + public int priority() { + return PRIORITY; + } + + @Override + public Uni augment( + SecurityIdentity identity, AuthenticationRequestContext context) { + if (identity.isAnonymous() || !(identity.getPrincipal() instanceof JsonWebToken)) { + return Uni.createFrom().item(identity); + } + OidcTenantConfiguration config = getOidcTenantConfig(identity); + PrincipalMapper principalMapper = + principalMappers.select(Identifier.Literal.of(config.principalMapper().type())).get(); + PrincipalRolesMapper principalRolesMapper = + principalRoleMappers + .select(Identifier.Literal.of(config.principalRolesMapper().type())) + .get(); + return Uni.createFrom() + .item(() -> setPrincipalAuthInfo(identity, principalMapper, principalRolesMapper)); + } + + protected SecurityIdentity setPrincipalAuthInfo( + SecurityIdentity identity, + PrincipalMapper principalMapper, + PrincipalRolesMapper rolesMapper) { + Long principalId = + principalMapper.mapPrincipalId(identity).stream().boxed().findFirst().orElse(null); + String principalName = principalMapper.mapPrincipalName(identity).orElse(null); + Set principalRoles = rolesMapper.mapPrincipalRoles(identity); + var credential = new OidcPrincipalAuthInfo(principalId, principalName, principalRoles); + // Note: we don't change the identity roles here, this will be done later on + // by the ActiveRolesAugmentor, which will also validate them + return QuarkusSecurityIdentity.builder(identity).addCredential(credential).build(); + } + + protected record OidcPrincipalAuthInfo( + @Nullable Long getPrincipalId, + @Nullable String getPrincipalName, + Set getPrincipalRoles) + implements QuarkusPrincipalAuthInfo {} +} diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/mapping/ClaimsLocator.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/mapping/ClaimsLocator.java new file mode 100644 index 0000000000..ff02febe94 --- /dev/null +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/mapping/ClaimsLocator.java @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external.mapping; + +import jakarta.annotation.Nullable; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.json.JsonNumber; +import jakarta.json.JsonString; +import jakarta.json.JsonValue; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.eclipse.microprofile.jwt.JsonWebToken; + +/** A utility class for locating claims in a JWT token by path. */ +@ApplicationScoped +class ClaimsLocator { + + /** + * Locates a claim, possibly a nested one, in a JWT token by path. + * + *

The path is a string with segments separated by '/' characters. For example, + * "resource_access/client1/roles" would look for the "roles" field inside the "client1" object + * inside the "resource_access" object in the token claims. + * + *

Paths must not start or end with '/' characters, and segments must not be empty. If a + * segment is empty, an {@link IllegalArgumentException} will be thrown. + * + *

Path segments containing '/' characters (such as namespaced claims like {@code + * "https://namespace/claim"}) should be enclosed in double quotes. For example, {@code + * "foo/\"https://namespace/claim\"/bar"} would navigate through three segments: {@code "foo"}, + * {@code "https://namespace/claim"}, and {@code "bar"}. + * + * @param claimPath the path to the claim, with segments separated by '/' characters + * @param token the JWT token + * @return the claim value, or null if the claim is not found or any segment in the path is null + */ + @Nullable + public Object locateClaim(String claimPath, JsonWebToken token) { + if (claimPath == null || claimPath.isEmpty() || token == null) { + throw new IllegalArgumentException("Claim path cannot be empty"); + } + + String[] segments = parseClaimPath(claimPath); + if (segments.length == 0) { + throw new IllegalArgumentException("Claim path cannot be empty"); + } + + Object currentValue = token.getClaim(segments[0]); + for (int i = 1; i < segments.length; i++) { + if (currentValue instanceof Map map) { + currentValue = map.get(segments[i]); + } else { + // If the current value is null or isn't a map, we can't navigate further + return null; + } + } + return currentValue instanceof JsonValue jsonValue ? convert(jsonValue) : currentValue; + } + + /** + * Parses a claim path into segments, properly handling quoted segments that may contain '/' + * characters, such as namespaced claims. + * + * @param claimPath the claim path to parse + * @return an array of path segments + */ + private static String[] parseClaimPath(String claimPath) { + List segments = new ArrayList<>(); + StringBuilder currentSegment = new StringBuilder(); + boolean inQuotes = false; + + for (int i = 0; i < claimPath.length(); i++) { + char c = claimPath.charAt(i); + + if (c == '"') { + // Check if this quote is escaped + if (i > 0 && claimPath.charAt(i - 1) == '\\') { + // Remove the escape character and add the quote + currentSegment.setLength(currentSegment.length() - 1); + currentSegment.append(c); + } else { + // Toggle quote mode + inQuotes = !inQuotes; + } + } else if (c == '/' && !inQuotes) { + // End of segment + if (currentSegment.isEmpty()) { + throw new IllegalArgumentException("Empty segment in claim path: " + claimPath); + } + segments.add(currentSegment.toString()); + currentSegment.setLength(0); + } else { + // Regular character, add to current segment + currentSegment.append(c); + } + } + + // Add the last segment if it's not empty + if (currentSegment.isEmpty()) { + throw new IllegalArgumentException("Empty segment in claim path: " + claimPath); + } + segments.add(currentSegment.toString()); + + if (inQuotes) { + throw new IllegalArgumentException("Unclosed quotes in claim path: " + claimPath); + } + + return segments.toArray(new String[0]); + } + + private static Object convert(JsonValue jsonValue) { + return switch (jsonValue.getValueType()) { + case TRUE -> true; + case FALSE -> false; + case NULL -> null; + case STRING -> ((JsonString) jsonValue).getString(); + case NUMBER -> { + JsonNumber jsonNumber = (JsonNumber) jsonValue; + if (jsonNumber.isIntegral()) yield jsonNumber.longValue(); + yield jsonNumber.doubleValue(); + } + case ARRAY -> jsonValue.asJsonArray().stream().map(ClaimsLocator::convert).toList(); + case OBJECT -> + jsonValue.asJsonObject().entrySet().stream() + .map(e -> new AbstractMap.SimpleEntry<>(e.getKey(), convert(e.getValue()))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + }; + } +} diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/mapping/DefaultPrincipalMapper.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/mapping/DefaultPrincipalMapper.java new file mode 100644 index 0000000000..db891085e8 --- /dev/null +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/mapping/DefaultPrincipalMapper.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external.mapping; + +import static org.apache.polaris.service.quarkus.auth.external.OidcTenantResolvingAugmentor.getOidcTenantConfig; + +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.common.annotation.Identifier; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.Optional; +import java.util.OptionalLong; +import org.eclipse.microprofile.jwt.JsonWebToken; + +/** + * A default implementation of {@link PrincipalMapper}. It maps the {@link SecurityIdentity} to a + * Polaris principal by extracting the ID and name from the JWT claims, based on the configuration + * provided in the OIDC tenant. + */ +@ApplicationScoped +@Identifier("default") +class DefaultPrincipalMapper implements PrincipalMapper { + + private final ClaimsLocator claimsLocator; + + @Inject + public DefaultPrincipalMapper(ClaimsLocator claimsLocator) { + this.claimsLocator = claimsLocator; + } + + @Override + public OptionalLong mapPrincipalId(SecurityIdentity identity) { + var jwt = (JsonWebToken) identity.getPrincipal(); + var principalMapper = getOidcTenantConfig(identity).principalMapper(); + return principalMapper + .idClaimPath() + .map(claimPath -> claimsLocator.locateClaim(claimPath, jwt)) + .map(id -> id instanceof Number ? ((Number) id).longValue() : Long.parseLong(id.toString())) + .map(OptionalLong::of) + .orElse(OptionalLong.empty()); + } + + @Override + public Optional mapPrincipalName(SecurityIdentity identity) { + var jwt = (JsonWebToken) identity.getPrincipal(); + var principalMapper = getOidcTenantConfig(identity).principalMapper(); + return principalMapper + .nameClaimPath() + .map(claimPath -> claimsLocator.locateClaim(claimPath, jwt)) + .map(Object::toString); + } +} diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/mapping/DefaultPrincipalRolesMapper.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/mapping/DefaultPrincipalRolesMapper.java new file mode 100644 index 0000000000..6941f9754c --- /dev/null +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/mapping/DefaultPrincipalRolesMapper.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external.mapping; + +import static org.apache.polaris.service.quarkus.auth.external.OidcTenantResolvingAugmentor.getOidcTenantConfig; + +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.common.annotation.Identifier; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * A default implementation of {@link PrincipalRolesMapper}. It can optionally filter and map the + * roles in the {@link SecurityIdentity} to Polaris roles, based on the configuration provided in + * the OIDC tenant. + */ +@ApplicationScoped +@Identifier("default") +class DefaultPrincipalRolesMapper implements PrincipalRolesMapper { + + @Override + public Set mapPrincipalRoles(SecurityIdentity identity) { + var rolesMapper = getOidcTenantConfig(identity).principalRolesMapper(); + return identity.getRoles().stream() + .filter(rolesMapper.filterPredicate()) + .map(rolesMapper.mapperFunction()) + .collect(Collectors.toSet()); + } +} diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/mapping/PrincipalMapper.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/mapping/PrincipalMapper.java new file mode 100644 index 0000000000..01be962c9e --- /dev/null +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/mapping/PrincipalMapper.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external.mapping; + +import io.quarkus.security.identity.SecurityIdentity; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * Maps the {@link SecurityIdentity}, as provided by the OIDC authentication mechanism, to a Polaris + * principal. + * + *

There are two ways to map the principal: by ID and by name. The ID is a long value that + * uniquely identifies the principal in Polaris, while the name is a string that represents the + * principal's unique name in Polaris. + * + *

At least one of these mappings must be provided, otherwise the authentication will fail. If + * both mappings are available, the ID mapping takes precedence. + */ +public interface PrincipalMapper { + + /** + * Maps the {@link SecurityIdentity} to a Polaris principal. + * + * @param identity the {@link SecurityIdentity} of the user + * @return the Polaris principal, or an empty optional if no mapping is available + */ + OptionalLong mapPrincipalId(SecurityIdentity identity); + + /** + * Maps the {@link SecurityIdentity} to a Polaris principal name. + * + * @param identity the {@link SecurityIdentity} of the user + * @return the Polaris principal name, or an empty optional if no mapping is available + */ + Optional mapPrincipalName(SecurityIdentity identity); +} diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/mapping/PrincipalRolesMapper.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/mapping/PrincipalRolesMapper.java new file mode 100644 index 0000000000..0599219926 --- /dev/null +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/mapping/PrincipalRolesMapper.java @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external.mapping; + +import io.quarkus.security.identity.SecurityIdentity; +import java.util.Set; +import org.apache.polaris.service.auth.DefaultAuthenticator; + +/** + * A mapper for extracting Polaris-specific role names from the {@link SecurityIdentity} of a user. + */ +public interface PrincipalRolesMapper { + + /** + * Converts the role names in the identity to Polaris-specific role names. + * + *

The returned set must contain only valid role names; unrecognized role names should be + * removed, and original names should be converted, if necessary. + * + * @implNote Polaris requires that all role names be prefixed with {@value + * DefaultAuthenticator#PRINCIPAL_ROLE_PREFIX}. The pseudo-role {@value + * DefaultAuthenticator#PRINCIPAL_ROLE_ALL} indicates that the user requests all roles that + * they have been granted. If this role is present, it should be the only role in the returned + * set. + * @param identity the {@link SecurityIdentity} of the user + * @return the converted role names + */ + Set mapPrincipalRoles(SecurityIdentity identity); +} diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/tenant/DefaultOidcTenantResolver.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/tenant/DefaultOidcTenantResolver.java new file mode 100644 index 0000000000..afcfbe88cd --- /dev/null +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/tenant/DefaultOidcTenantResolver.java @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external.tenant; + +import io.quarkus.oidc.runtime.OidcUtils; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.common.annotation.Identifier; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.apache.polaris.service.quarkus.auth.external.OidcConfiguration; +import org.apache.polaris.service.quarkus.auth.external.OidcTenantConfiguration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default OIDC tenant configuration resolver. + * + *

First, it locates the Quarkus OIDC tenant in use based on the identity attributes; then it + * matches the Polaris OIDC tenant configuration by tenant ID. If no matching tenant is found, it + * falls back to the default tenant configuration. + */ +@ApplicationScoped +@Identifier("default") +class DefaultOidcTenantResolver implements OidcTenantResolver { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultOidcTenantResolver.class); + + private final OidcConfiguration config; + + @Inject + public DefaultOidcTenantResolver(OidcConfiguration config) { + this.config = config; + } + + @Override + public OidcTenantConfiguration resolveConfig(SecurityIdentity identity) { + var tenantConfig = config.tenants().get(OidcConfiguration.DEFAULT_TENANT_KEY); + String tenantId = identity.getAttribute(OidcUtils.TENANT_ID_ATTRIBUTE); + if (tenantId != null && !tenantId.equals(OidcUtils.DEFAULT_TENANT_ID)) { + if (config.tenants().containsKey(tenantId)) { + tenantConfig = config.tenants().get(tenantId); + } else { + LOGGER.warn( + "Quarkus OIDC tenant {} not found in Polaris OIDC configuration, " + + "using default Polaris OIDC configuration instead", + tenantId); + } + } + return tenantConfig; + } +} diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/tenant/OidcTenantResolver.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/tenant/OidcTenantResolver.java new file mode 100644 index 0000000000..68a8b442a1 --- /dev/null +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/external/tenant/OidcTenantResolver.java @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external.tenant; + +import io.quarkus.security.identity.SecurityIdentity; +import org.apache.polaris.service.quarkus.auth.external.OidcTenantConfiguration; + +/** Resolves the Polaris OIDC tenant to use for the given {@link SecurityIdentity}. */ +public interface OidcTenantResolver { + + OidcTenantConfiguration resolveConfig(SecurityIdentity identity); +} diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/PolarisAuthenticationMechanism.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/internal/InternalAuthenticationMechanism.java similarity index 53% rename from quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/PolarisAuthenticationMechanism.java rename to quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/internal/InternalAuthenticationMechanism.java index e52e8b9f5b..ad302f0a0a 100644 --- a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/PolarisAuthenticationMechanism.java +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/internal/InternalAuthenticationMechanism.java @@ -16,10 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.polaris.service.quarkus.auth; +package org.apache.polaris.service.quarkus.auth.internal; +import com.google.common.annotations.VisibleForTesting; import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpResponseStatus; +import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.credential.TokenCredential; import io.quarkus.security.identity.IdentityProviderManager; import io.quarkus.security.identity.SecurityIdentity; @@ -31,20 +33,54 @@ import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; import io.smallrye.mutiny.Uni; import io.vertx.ext.web.RoutingContext; +import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import java.util.Collections; import java.util.Set; +import org.apache.polaris.service.auth.AuthenticationRealmConfiguration; +import org.apache.polaris.service.auth.AuthenticationType; +import org.apache.polaris.service.auth.DecodedToken; +import org.apache.polaris.service.auth.TokenBroker; +import org.apache.polaris.service.quarkus.auth.QuarkusPrincipalAuthInfo; -/** A custom {@link HttpAuthenticationMechanism} that handles Polaris token authentication. */ +/** + * A custom {@link HttpAuthenticationMechanism} that handles internal token authentication, that is, + * authentication using tokens provided by Polaris itself. + */ @ApplicationScoped -public class PolarisAuthenticationMechanism implements HttpAuthenticationMechanism { +class InternalAuthenticationMechanism implements HttpAuthenticationMechanism { + + // Must be higher than the OIDC authentication mechanism priority, which is + // HttpAuthenticationMechanism.DEFAULT_PRIORITY + 1, since this mechanism must be tried first. + // See io.quarkus.oidc.runtime.OidcAuthenticationMechanism + public static final int PRIORITY = HttpAuthenticationMechanism.DEFAULT_PRIORITY + 100; private static final String BEARER = "Bearer"; + @VisibleForTesting final AuthenticationRealmConfiguration configuration; + private final TokenBroker tokenBroker; + + @Inject + public InternalAuthenticationMechanism( + AuthenticationRealmConfiguration configuration, TokenBroker tokenBroker) { + this.configuration = configuration; + this.tokenBroker = tokenBroker; + } + + @Override + public int getPriority() { + return PRIORITY; + } + @Override public Uni authenticate( RoutingContext context, IdentityProviderManager identityProviderManager) { + if (configuration.type() == AuthenticationType.EXTERNAL) { + return Uni.createFrom().nullItem(); + } + String authHeader = context.request().getHeader("Authorization"); if (authHeader == null) { return Uni.createFrom().nullItem(); @@ -56,9 +92,24 @@ public Uni authenticate( } String credential = authHeader.substring(spaceIdx + 1); + + DecodedToken token; + try { + token = tokenBroker.verify(credential); + } catch (Exception e) { + return configuration.type() == AuthenticationType.MIXED + ? Uni.createFrom().nullItem() // let other auth mechanisms handle it + : Uni.createFrom().failure(new AuthenticationFailedException(e)); // stop here + } + + if (token == null) { + return Uni.createFrom().nullItem(); + } + return identityProviderManager.authenticate( HttpSecurityUtils.setRoutingContextAttribute( - new TokenAuthenticationRequest(new PolarisTokenCredential(credential)), context)); + new TokenAuthenticationRequest(new InternalPrincipalAuthInfo(credential, token)), + context)); } @Override @@ -80,9 +131,31 @@ public Uni getCredentialTransport(RoutingContext contex .item(new HttpCredentialTransport(HttpCredentialTransport.Type.AUTHORIZATION, BEARER)); } - static class PolarisTokenCredential extends TokenCredential { - PolarisTokenCredential(String credential) { + static class InternalPrincipalAuthInfo extends TokenCredential + implements QuarkusPrincipalAuthInfo { + + private final DecodedToken token; + + InternalPrincipalAuthInfo(String credential, DecodedToken token) { super(credential, "bearer"); + this.token = token; + } + + @Nullable + @Override + public Long getPrincipalId() { + return token.getPrincipalId(); + } + + @Nullable + @Override + public String getPrincipalName() { + return token.getPrincipalName(); + } + + @Override + public Set getPrincipalRoles() { + return token.getPrincipalRoles(); } } } diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/internal/InternalIdentityProvider.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/internal/InternalIdentityProvider.java new file mode 100644 index 0000000000..46e5cfe5fb --- /dev/null +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/auth/internal/InternalIdentityProvider.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.internal; + +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.TokenAuthenticationRequest; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; +import jakarta.enterprise.context.ApplicationScoped; +import java.security.Principal; + +/** A custom {@link IdentityProvider} that handles internal token authentication requests. */ +@ApplicationScoped +class InternalIdentityProvider implements IdentityProvider { + + @Override + public Class getRequestType() { + return TokenAuthenticationRequest.class; + } + + @Override + public Uni authenticate( + TokenAuthenticationRequest request, AuthenticationRequestContext context) { + if (!(request.getToken() + instanceof InternalAuthenticationMechanism.InternalPrincipalAuthInfo credential)) { + return Uni.createFrom().nullItem(); + } + InternalTokenPrincipal principal = new InternalTokenPrincipal(credential.getPrincipalName()); + return Uni.createFrom() + .item( + QuarkusSecurityIdentity.builder() + .setPrincipal(principal) + .addCredential(credential) + .addAttribute( + RoutingContext.class.getName(), + HttpSecurityUtils.getRoutingContextAttribute(request)) + .build()); + } + + private record InternalTokenPrincipal(String getName) implements Principal {} +} diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/config/ProductionReadinessChecks.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/config/ProductionReadinessChecks.java index bac6ba02f7..5d9524f4c6 100644 --- a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/config/ProductionReadinessChecks.java +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/config/ProductionReadinessChecks.java @@ -25,20 +25,19 @@ import jakarta.enterprise.inject.Produces; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.List; import org.apache.polaris.core.config.ProductionReadinessCheck; import org.apache.polaris.core.config.ProductionReadinessCheck.Error; import org.apache.polaris.core.persistence.MetaStoreManagerFactory; -import org.apache.polaris.service.auth.AuthenticationConfiguration; -import org.apache.polaris.service.auth.AuthenticationConfiguration.TokenBrokerConfiguration.RSAKeyPairConfiguration; -import org.apache.polaris.service.auth.AuthenticationConfiguration.TokenBrokerConfiguration.SymmetricKeyConfiguration; -import org.apache.polaris.service.auth.JWTRSAKeyPairFactory; -import org.apache.polaris.service.auth.JWTSymmetricKeyFactory; -import org.apache.polaris.service.auth.TokenBrokerFactory; +import org.apache.polaris.service.auth.AuthenticationRealmConfiguration.TokenBrokerConfiguration.RSAKeyPairConfiguration; +import org.apache.polaris.service.auth.AuthenticationRealmConfiguration.TokenBrokerConfiguration.SymmetricKeyConfiguration; +import org.apache.polaris.service.auth.AuthenticationType; import org.apache.polaris.service.context.DefaultRealmContextResolver; import org.apache.polaris.service.context.RealmContextResolver; import org.apache.polaris.service.context.TestRealmContextResolver; import org.apache.polaris.service.persistence.InMemoryPolarisMetaStoreManagerFactory; +import org.apache.polaris.service.quarkus.auth.QuarkusAuthenticationConfiguration; import org.eclipse.microprofile.config.Config; import org.eclipse.microprofile.config.ConfigValue; import org.slf4j.Logger; @@ -80,43 +79,54 @@ public void warnOnFailedChecks( } @Produces - public ProductionReadinessCheck checkTokenBroker( - AuthenticationConfiguration configuration, TokenBrokerFactory factory) { - if (factory instanceof JWTRSAKeyPairFactory) { - if (configuration - .tokenBroker() - .rsaKeyPair() - .map(RSAKeyPairConfiguration::publicKeyFile) - .isEmpty()) { - return ProductionReadinessCheck.of( - Error.of( - "A public key file wasn't provided and will be generated.", - "polaris.authentication.token-broker.rsa-key-pair.public-key-file")); - } - if (configuration - .tokenBroker() - .rsaKeyPair() - .map(RSAKeyPairConfiguration::privateKeyFile) - .isEmpty()) { - return ProductionReadinessCheck.of( - Error.of( - "A private key file wasn't provided and will be generated.", - "polaris.authentication.token-broker.rsa-key-pair.private-key-file")); - } - } - if (factory instanceof JWTSymmetricKeyFactory) { - if (configuration - .tokenBroker() - .symmetricKey() - .map(SymmetricKeyConfiguration::secret) - .isPresent()) { - return ProductionReadinessCheck.of( - Error.of( - "A symmetric key secret was provided through configuration rather than through a secret file.", - "polaris.authentication.token-broker.symmetric-key.secret")); - } - } - return ProductionReadinessCheck.OK; + public ProductionReadinessCheck checkTokenBrokers( + QuarkusAuthenticationConfiguration configuration) { + List errors = new ArrayList<>(); + configuration + .realms() + .forEach( + (realm, config) -> { + if (config.type() != AuthenticationType.EXTERNAL) { + if (config.tokenBroker().type().equals("rsa-key-pair")) { + if (config + .tokenBroker() + .rsaKeyPair() + .map(RSAKeyPairConfiguration::publicKeyFile) + .isEmpty()) { + errors.add( + Error.of( + "A public key file wasn't provided and will be generated.", + "polaris.authentication.%stoken-broker.rsa-key-pair.public-key-file" + .formatted(authRealmSegment(realm)))); + } + if (config + .tokenBroker() + .rsaKeyPair() + .map(RSAKeyPairConfiguration::privateKeyFile) + .isEmpty()) { + errors.add( + Error.of( + "A private key file wasn't provided and will be generated.", + "polaris.authentication.%stoken-broker.rsa-key-pair.private-key-file" + .formatted(authRealmSegment(realm)))); + } + } + if (config.tokenBroker().type().equals("symmetric-key")) { + if (config + .tokenBroker() + .symmetricKey() + .map(SymmetricKeyConfiguration::secret) + .isPresent()) { + errors.add( + Error.of( + "A symmetric key secret was provided through configuration rather than through a secret file.", + "polaris.authentication.%stoken-broker.symmetric-key.secret" + .formatted(authRealmSegment(realm)))); + } + } + } + }); + return ProductionReadinessCheck.of(errors); } @Produces @@ -150,4 +160,8 @@ public ProductionReadinessCheck checkRealmResolver(Config config, RealmContextRe } return ProductionReadinessCheck.OK; } + + private static String authRealmSegment(String realm) { + return realm.equals(QuarkusAuthenticationConfiguration.DEFAULT_REALM_KEY) ? "" : realm + "."; + } } diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/config/QuarkusProducers.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/config/QuarkusProducers.java index 625e57e45d..17672ce2bc 100644 --- a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/config/QuarkusProducers.java +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/config/QuarkusProducers.java @@ -18,12 +18,12 @@ */ package org.apache.polaris.service.quarkus.config; -import io.quarkus.runtime.StartupEvent; import io.smallrye.common.annotation.Identifier; import io.smallrye.context.SmallRyeManagedExecutor; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.RequestScoped; import jakarta.enterprise.event.Observes; +import jakarta.enterprise.event.Startup; import jakarta.enterprise.inject.Any; import jakarta.enterprise.inject.Disposes; import jakarta.enterprise.inject.Instance; @@ -50,7 +50,10 @@ import org.apache.polaris.core.secrets.UserSecretsManagerFactory; import org.apache.polaris.core.storage.cache.StorageCredentialCache; import org.apache.polaris.service.auth.ActiveRolesProvider; +import org.apache.polaris.service.auth.AuthenticationType; import org.apache.polaris.service.auth.Authenticator; +import org.apache.polaris.service.auth.PrincipalAuthInfo; +import org.apache.polaris.service.auth.TokenBroker; import org.apache.polaris.service.auth.TokenBrokerFactory; import org.apache.polaris.service.catalog.api.IcebergRestOAuth2ApiService; import org.apache.polaris.service.catalog.io.FileIOFactory; @@ -59,6 +62,8 @@ import org.apache.polaris.service.context.RealmContextFilter; import org.apache.polaris.service.context.RealmContextResolver; import org.apache.polaris.service.quarkus.auth.QuarkusAuthenticationConfiguration; +import org.apache.polaris.service.quarkus.auth.QuarkusAuthenticationRealmConfiguration; +import org.apache.polaris.service.quarkus.auth.external.tenant.OidcTenantResolver; import org.apache.polaris.service.quarkus.catalog.io.QuarkusFileIOConfiguration; import org.apache.polaris.service.quarkus.context.QuarkusRealmContextConfiguration; import org.apache.polaris.service.quarkus.persistence.QuarkusPersistenceConfiguration; @@ -165,7 +170,7 @@ public UserSecretsManagerFactory userSecretsManagerFactory( * credentials printed to stdout immediately. */ public void maybeBootstrap( - @Observes StartupEvent event, + @Observes Startup event, MetaStoreManagerFactory factory, QuarkusPersistenceConfiguration config, RealmContextConfiguration realmContextConfiguration) { @@ -189,24 +194,36 @@ public TokenBucketFactory tokenBucketFactory( } @Produces - public Authenticator authenticator( - QuarkusAuthenticationConfiguration config, - @Any Instance> authenticators) { + @RequestScoped + public Authenticator authenticator( + QuarkusAuthenticationRealmConfiguration config, + @Any + Instance> + authenticators) { return authenticators.select(Identifier.Literal.of(config.authenticator().type())).get(); } @Produces + @RequestScoped public IcebergRestOAuth2ApiService icebergRestOAuth2ApiService( - QuarkusAuthenticationConfiguration config, + QuarkusAuthenticationRealmConfiguration config, @Any Instance services) { - return services.select(Identifier.Literal.of(config.tokenService().type())).get(); + String type = + config.type() == AuthenticationType.EXTERNAL ? "disabled" : config.tokenService().type(); + return services.select(Identifier.Literal.of(type)).get(); } @Produces - public TokenBrokerFactory tokenBrokerFactory( - QuarkusAuthenticationConfiguration config, + @RequestScoped + public TokenBroker tokenBroker( + QuarkusAuthenticationRealmConfiguration config, + RealmContext realmContext, @Any Instance tokenBrokerFactories) { - return tokenBrokerFactories.select(Identifier.Literal.of(config.tokenBroker().type())).get(); + String type = + config.type() == AuthenticationType.EXTERNAL ? "none" : config.tokenBroker().type(); + TokenBrokerFactory tokenBrokerFactory = + tokenBrokerFactories.select(Identifier.Literal.of(type)).get(); + return tokenBrokerFactory.apply(realmContext); } // other beans @@ -251,11 +268,25 @@ public PolarisEntityManager polarisEntityManager( return factory.getOrCreateEntityManager(realmContext); } + @Produces + @RequestScoped + public QuarkusAuthenticationRealmConfiguration realmAuthConfig( + QuarkusAuthenticationConfiguration config, RealmContext realmContext) { + return config.forRealm(realmContext); + } + @Produces public ActiveRolesProvider activeRolesProvider( - @ConfigProperty(name = "polaris.active-roles-provider.type") String persistenceType, + @ConfigProperty(name = "polaris.active-roles-provider.type") String activeRolesProviderType, @Any Instance activeRolesProviders) { - return activeRolesProviders.select(Identifier.Literal.of(persistenceType)).get(); + return activeRolesProviders.select(Identifier.Literal.of(activeRolesProviderType)).get(); + } + + @Produces + public OidcTenantResolver oidcTenantResolver( + org.apache.polaris.service.quarkus.auth.external.OidcConfiguration config, + @Any Instance resolvers) { + return resolvers.select(Identifier.Literal.of(config.tenantResolver())).get(); } public void closeTaskExecutor(@Disposes @Identifier("task-executor") ManagedExecutor executor) { diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/ActiveRolesAugmentorTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/ActiveRolesAugmentorTest.java new file mode 100644 index 0000000000..219da7061a --- /dev/null +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/ActiveRolesAugmentorTest.java @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.smallrye.mutiny.Uni; +import java.security.Principal; +import java.util.Set; +import org.apache.polaris.core.auth.AuthenticatedPolarisPrincipal; +import org.apache.polaris.service.auth.ActiveRolesProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class ActiveRolesAugmentorTest { + + private ActiveRolesAugmentor augmentor; + private ActiveRolesProvider activeRolesProvider; + + @BeforeEach + public void setup() { + activeRolesProvider = mock(ActiveRolesProvider.class); + augmentor = new ActiveRolesAugmentor(activeRolesProvider); + } + + @Test + public void testAugmentAnonymousIdentity() { + // Given + SecurityIdentity anonymousIdentity = + QuarkusSecurityIdentity.builder().setAnonymous(true).build(); + + // When + Uni result = augmentor.augment(anonymousIdentity, null); + + // Then + assertThat(result.await().indefinitely()).isSameAs(anonymousIdentity); + } + + @Test + public void testAugmentNonPolarisPrincipal() { + // Given + Principal nonPolarisPrincipal = mock(Principal.class); + SecurityIdentity identity = + QuarkusSecurityIdentity.builder().setPrincipal(nonPolarisPrincipal).build(); + + // When/Then + assertThatThrownBy( + () -> augmentor.augment(identity, Uni.createFrom()::item).await().indefinitely()) + .isInstanceOf(AuthenticationFailedException.class) + .hasMessage("No Polaris principal found"); + } + + @ParameterizedTest + @ValueSource(strings = {"role1", "role1,role2", "role1,role2,role3"}) + public void testAugmentWithValidRoles(String rolesString) { + // Given + Set roles = Set.of(rolesString.split(",")); + AuthenticatedPolarisPrincipal polarisPrincipal = mock(AuthenticatedPolarisPrincipal.class); + SecurityIdentity identity = + QuarkusSecurityIdentity.builder().setPrincipal(polarisPrincipal).build(); + + when(activeRolesProvider.getActiveRoles(polarisPrincipal)).thenReturn(roles); + + // When + SecurityIdentity result = + augmentor.augment(identity, Uni.createFrom()::item).await().indefinitely(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getPrincipal()).isSameAs(polarisPrincipal); + assertThat(result.getRoles()).containsExactlyInAnyOrderElementsOf(roles); + } + + @Test + public void testAugmentWithEmptyRoles() { + // Given + Set roles = Set.of(); + AuthenticatedPolarisPrincipal polarisPrincipal = mock(AuthenticatedPolarisPrincipal.class); + SecurityIdentity identity = + QuarkusSecurityIdentity.builder().setPrincipal(polarisPrincipal).build(); + + when(activeRolesProvider.getActiveRoles(polarisPrincipal)).thenReturn(roles); + + // When + SecurityIdentity result = + augmentor.augment(identity, Uni.createFrom()::item).await().indefinitely(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getPrincipal()).isSameAs(polarisPrincipal); + assertThat(result.getRoles()).isEmpty(); + } +} diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/AuthenticatingAugmentorTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/AuthenticatingAugmentorTest.java new file mode 100644 index 0000000000..eead63e279 --- /dev/null +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/AuthenticatingAugmentorTest.java @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.smallrye.mutiny.Uni; +import java.security.Principal; +import java.util.Optional; +import org.apache.iceberg.exceptions.NotAuthorizedException; +import org.apache.polaris.core.auth.AuthenticatedPolarisPrincipal; +import org.apache.polaris.service.auth.Authenticator; +import org.apache.polaris.service.auth.PrincipalAuthInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class AuthenticatingAugmentorTest { + + private AuthenticatingAugmentor augmentor; + private Authenticator authenticator; + + @BeforeEach + @SuppressWarnings("unchecked") + public void setup() { + authenticator = mock(Authenticator.class); + augmentor = new AuthenticatingAugmentor(authenticator); + } + + @Test + public void testAugmentAnonymousIdentity() { + // Given + SecurityIdentity anonymousIdentity = + QuarkusSecurityIdentity.builder().setAnonymous(true).build(); + + // When + Uni result = augmentor.augment(anonymousIdentity, null); + + // Then + assertThat(result.await().indefinitely()).isSameAs(anonymousIdentity); + } + + @Test + public void testAugmentMissingCredential() { + // Given + Principal nonPolarisPrincipal = mock(Principal.class); + SecurityIdentity identity = + QuarkusSecurityIdentity.builder().setPrincipal(nonPolarisPrincipal).build(); + + // When/Then + assertThatThrownBy( + () -> augmentor.augment(identity, Uni.createFrom()::item).await().indefinitely()) + .isInstanceOf(AuthenticationFailedException.class) + .hasMessage("No token credential available"); + } + + @Test + public void testAugmentAuthenticationFailure() { + // Given + Principal nonPolarisPrincipal = mock(Principal.class); + QuarkusPrincipalAuthInfo credential = mock(QuarkusPrincipalAuthInfo.class); + SecurityIdentity identity = + QuarkusSecurityIdentity.builder() + .setPrincipal(nonPolarisPrincipal) + .addCredential(credential) + .build(); + + when(authenticator.authenticate(credential)).thenReturn(Optional.empty()); + + // When/Then + assertThatThrownBy( + () -> augmentor.augment(identity, Uni.createFrom()::item).await().indefinitely()) + .isInstanceOf(AuthenticationFailedException.class) + .hasCauseInstanceOf(NotAuthorizedException.class); + } + + @Test + public void testAugmentRuntimeException() { + // Given + Principal nonPolarisPrincipal = mock(Principal.class); + QuarkusPrincipalAuthInfo credential = mock(QuarkusPrincipalAuthInfo.class); + SecurityIdentity identity = + QuarkusSecurityIdentity.builder() + .setPrincipal(nonPolarisPrincipal) + .addCredential(credential) + .build(); + + RuntimeException exception = new NotAuthorizedException("Authentication error"); + when(authenticator.authenticate(credential)).thenThrow(exception); + + // When/Then + assertThatThrownBy( + () -> augmentor.augment(identity, Uni.createFrom()::item).await().indefinitely()) + .isInstanceOf(AuthenticationFailedException.class) + .hasCause(exception); + } + + @Test + public void testAugmentSuccessfulAuthentication() { + // Given + AuthenticatedPolarisPrincipal polarisPrincipal = mock(AuthenticatedPolarisPrincipal.class); + when(polarisPrincipal.getName()).thenReturn("user1"); + QuarkusPrincipalAuthInfo credential = mock(QuarkusPrincipalAuthInfo.class); + SecurityIdentity identity = + QuarkusSecurityIdentity.builder() + .setPrincipal(polarisPrincipal) + .addCredential(credential) + .build(); + + when(authenticator.authenticate(credential)).thenReturn(Optional.of(polarisPrincipal)); + + // When + SecurityIdentity result = + augmentor.augment(identity, Uni.createFrom()::item).await().indefinitely(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getPrincipal()).isSameAs(polarisPrincipal); + assertThat(result.getPrincipal().getName()).isEqualTo("user1"); + } +} diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/TokenUtils.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/TokenUtils.java index 0943230845..f5c2c8ec08 100644 --- a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/TokenUtils.java +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/TokenUtils.java @@ -18,7 +18,7 @@ */ package org.apache.polaris.service.quarkus.auth; -import static org.apache.polaris.service.auth.BasePolarisAuthenticator.PRINCIPAL_ROLE_ALL; +import static org.apache.polaris.service.auth.DefaultAuthenticator.PRINCIPAL_ROLE_ALL; import static org.apache.polaris.service.context.TestRealmContextResolver.REALM_PROPERTY_KEY; import static org.assertj.core.api.Assertions.assertThat; diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/OidcTenantResolvingAugmentorTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/OidcTenantResolvingAugmentorTest.java new file mode 100644 index 0000000000..159c8dd7bd --- /dev/null +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/OidcTenantResolvingAugmentorTest.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external; + +import static org.apache.polaris.service.quarkus.auth.external.OidcTenantResolvingAugmentor.getOidcTenantConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.smallrye.mutiny.Uni; +import java.security.Principal; +import org.apache.polaris.service.quarkus.auth.external.tenant.OidcTenantResolver; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class OidcTenantResolvingAugmentorTest { + + private OidcTenantResolvingAugmentor augmentor; + private OidcTenantResolver resolver; + + @BeforeEach + public void setup() { + resolver = mock(OidcTenantResolver.class); + augmentor = new OidcTenantResolvingAugmentor(resolver); + } + + @Test + public void testAugmentAnonymousIdentity() { + // Given + SecurityIdentity anonymousIdentity = + QuarkusSecurityIdentity.builder().setAnonymous(true).build(); + + // When + Uni result = augmentor.augment(anonymousIdentity, null); + + // Then + assertThat(result.await().indefinitely()).isSameAs(anonymousIdentity); + } + + @Test + public void testAugmentNonOidcPrincipal() { + // Given + Principal nonOidcPrincipal = mock(Principal.class); + SecurityIdentity identity = + QuarkusSecurityIdentity.builder().setPrincipal(nonOidcPrincipal).build(); + + // When + Uni result = augmentor.augment(identity, null); + + // Then + assertThat(result.await().indefinitely()).isSameAs(identity); + } + + @Test + public void testAugmentOidcPrincipal() { + // Given + JsonWebToken oidcPrincipal = mock(JsonWebToken.class); + SecurityIdentity identity = + QuarkusSecurityIdentity.builder() + .setPrincipal(oidcPrincipal) + .addRole("PRINCIPAL_ROLE:ALL") + .build(); + + OidcTenantConfiguration config = mock(OidcTenantConfiguration.class); + when(resolver.resolveConfig(identity)).thenReturn(config); + + // When + SecurityIdentity result = + augmentor.augment(identity, Uni.createFrom()::item).await().indefinitely(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getPrincipal()).isSameAs(oidcPrincipal); + assertThat(result.getRoles()).containsExactlyInAnyOrder("PRINCIPAL_ROLE:ALL"); + assertThat(getOidcTenantConfig(result)).isSameAs(config); + } +} diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/PrincipalAuthInfoAugmentorTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/PrincipalAuthInfoAugmentorTest.java new file mode 100644 index 0000000000..04eb115ccf --- /dev/null +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/PrincipalAuthInfoAugmentorTest.java @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external; + +import static org.apache.polaris.service.quarkus.auth.external.OidcTenantResolvingAugmentor.TENANT_CONFIG_ATTRIBUTE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.smallrye.common.annotation.Identifier; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.inject.Instance; +import java.security.Principal; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import org.apache.polaris.service.quarkus.auth.external.OidcTenantConfiguration.PrincipalMapper; +import org.apache.polaris.service.quarkus.auth.external.OidcTenantConfiguration.PrincipalRolesMapper; +import org.apache.polaris.service.quarkus.auth.external.PrincipalAuthInfoAugmentor.OidcPrincipalAuthInfo; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PrincipalAuthInfoAugmentorTest { + + private PrincipalAuthInfoAugmentor augmentor; + private org.apache.polaris.service.quarkus.auth.external.mapping.PrincipalMapper principalMapper; + private org.apache.polaris.service.quarkus.auth.external.mapping.PrincipalRolesMapper + principalRolesMapper; + private OidcTenantConfiguration config; + + @BeforeEach + public void setup() { + principalMapper = + mock(org.apache.polaris.service.quarkus.auth.external.mapping.PrincipalMapper.class); + principalRolesMapper = + mock(org.apache.polaris.service.quarkus.auth.external.mapping.PrincipalRolesMapper.class); + config = mock(OidcTenantConfiguration.class); + when(config.principalMapper()).thenReturn(mock(PrincipalMapper.class)); + when(config.principalRolesMapper()).thenReturn(mock(PrincipalRolesMapper.class)); + when(config.principalMapper().type()).thenReturn("default"); + when(config.principalRolesMapper().type()).thenReturn("default"); + @SuppressWarnings("unchecked") + Instance + principalMappers = mock(Instance.class); + when(principalMappers.select(Identifier.Literal.of("default"))).thenReturn(principalMappers); + when(principalMappers.get()).thenReturn(principalMapper); + @SuppressWarnings("unchecked") + Instance + principalRoleMappers = mock(Instance.class); + when(principalRoleMappers.select(Identifier.Literal.of("default"))) + .thenReturn(principalRoleMappers); + when(principalRoleMappers.get()).thenReturn(principalRolesMapper); + augmentor = new PrincipalAuthInfoAugmentor(principalMappers, principalRoleMappers); + } + + @Test + public void testAugmentAnonymousIdentity() { + // Given + SecurityIdentity anonymousIdentity = + QuarkusSecurityIdentity.builder().setAnonymous(true).build(); + + // When + Uni result = augmentor.augment(anonymousIdentity, null); + + // Then + assertThat(result.await().indefinitely()).isSameAs(anonymousIdentity); + } + + @Test + public void testAugmentNonOidcPrincipal() { + // Given + Principal nonOidcPrincipal = mock(Principal.class); + SecurityIdentity identity = + QuarkusSecurityIdentity.builder().setPrincipal(nonOidcPrincipal).build(); + + // When + Uni result = augmentor.augment(identity, null); + + // Then + assertThat(result.await().indefinitely()).isSameAs(identity); + } + + @Test + public void testAugmentOidcPrincipal() { + // Given + JsonWebToken oidcPrincipal = mock(JsonWebToken.class); + SecurityIdentity identity = + QuarkusSecurityIdentity.builder() + .setPrincipal(oidcPrincipal) + .addRole("ROLE1") + .addAttribute(TENANT_CONFIG_ATTRIBUTE, config) + .build(); + + when(principalMapper.mapPrincipalId(identity)).thenReturn(OptionalLong.of(123L)); + when(principalMapper.mapPrincipalName(identity)).thenReturn(Optional.of("root")); + when(principalRolesMapper.mapPrincipalRoles(identity)).thenReturn(Set.of("MAPPED_ROLE1")); + + // When + SecurityIdentity result = + augmentor.augment(identity, Uni.createFrom()::item).await().indefinitely(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getPrincipal()).isSameAs(oidcPrincipal); + assertThat(result.getCredential(OidcPrincipalAuthInfo.class)) + .isEqualTo(new OidcPrincipalAuthInfo(123L, "root", Set.of("MAPPED_ROLE1"))); + // the identity roles should not change, since this is done by the ActiveRolesAugmentor + assertThat(result.getRoles()).containsExactlyInAnyOrder("ROLE1"); + } +} diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/mapping/ClaimsLocatorTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/mapping/ClaimsLocatorTest.java new file mode 100644 index 0000000000..5eb07b03ee --- /dev/null +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/mapping/ClaimsLocatorTest.java @@ -0,0 +1,179 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external.mapping; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import jakarta.json.Json; +import jakarta.json.JsonValue; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +public class ClaimsLocatorTest { + + private ClaimsLocator claimsLocator; + private JsonWebToken token; + + @BeforeEach + public void setup() { + claimsLocator = new ClaimsLocator(); + token = mock(JsonWebToken.class); + } + + @Test + public void nullToken() { + assertThatThrownBy(() -> claimsLocator.locateClaim("sub", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Claim path cannot be empty"); + } + + @Test + public void nullPath() { + assertThatThrownBy(() -> claimsLocator.locateClaim(null, token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Claim path cannot be empty"); + } + + @Test + public void emptyPath() { + assertThatThrownBy(() -> claimsLocator.locateClaim("", token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Claim path cannot be empty"); + } + + @ParameterizedTest + @ValueSource(strings = {"/", "/foo", "foo//bar", "foo/"}) + public void emtpySegment(String path) { + assertThatThrownBy(() -> claimsLocator.locateClaim(path, token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Empty segment in claim path: " + path); + } + + @Test + public void simpleClaim() { + when(token.getClaim("sub")).thenReturn("user123"); + Object result = claimsLocator.locateClaim("sub", token); + assertThat(result).isEqualTo("user123"); + } + + @Test + public void nestedClaim() { + Map resourceAccess = + Map.of("client1", Map.of("roles", List.of("admin", "user"))); + when(token.getClaim("resource_access")).thenReturn(resourceAccess); + Object result = claimsLocator.locateClaim("resource_access/client1/roles", token); + assertThat(result).isEqualTo(List.of("admin", "user")); + } + + @Test + public void nonExistentClaim() { + when(token.getClaim("non_existent")).thenReturn(null); + Object result = claimsLocator.locateClaim("non_existent", token); + assertThat(result).isNull(); + } + + @Test + public void nonExistentNestedClaim() { + when(token.getClaim("resource_access")).thenReturn(Map.of()); + Object result = claimsLocator.locateClaim("resource_access/non_existent/roles", token); + assertThat(result).isNull(); + } + + @Test + public void nonMapIntermediateValue() { + when(token.getClaim("sub")).thenReturn("user123"); + Object result = claimsLocator.locateClaim("sub/roles", token); + assertThat(result).isNull(); + } + + @Test + public void namespacedClaim() { + String namespacedClaimName = "https://any.namespace/roles"; + when(token.getClaim(namespacedClaimName)).thenReturn(List.of("admin", "user")); + Object result = claimsLocator.locateClaim("\"" + namespacedClaimName + "\"", token); + assertThat(result).isEqualTo(List.of("admin", "user")); + } + + @Test + public void nestedNamespacedClaim() { + String namespacedClaimName = "https://any.namespace/roles"; + Map resourceMap = Map.of(namespacedClaimName, List.of("admin", "user")); + when(token.getClaim("resource")).thenReturn(resourceMap); + Object result = claimsLocator.locateClaim("resource/\"" + namespacedClaimName + "\"", token); + assertThat(result).isEqualTo(List.of("admin", "user")); + } + + @Test + public void namespacedClaimWithNestedPath() { + String namespacedClaimName = "https://any.namespace/resource_access"; + Map nestedMap = Map.of("roles", List.of("admin", "user")); + when(token.getClaim(namespacedClaimName)).thenReturn(nestedMap); + Object result = claimsLocator.locateClaim("\"" + namespacedClaimName + "\"/roles", token); + assertThat(result).isEqualTo(List.of("admin", "user")); + } + + @Test + public void escapedQuotes() { + when(token.getClaim("claim\"with\"quotes")).thenReturn("value"); + Object result = claimsLocator.locateClaim("\"claim\\\"with\\\"quotes\"", token); + assertThat(result).isEqualTo("value"); + } + + @Test + public void unclosedQuotes() { + assertThatThrownBy(() -> claimsLocator.locateClaim("\"unclosed", token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unclosed quotes in claim path"); + } + + @ParameterizedTest + @MethodSource + public void jsonValues(JsonValue jsonValue, Object expected) { + when(token.getClaim("claim")).thenReturn(jsonValue); + Object result = claimsLocator.locateClaim("claim", token); + assertThat(result).isEqualTo(expected); + } + + static Stream jsonValues() { + return Stream.of( + Arguments.of(JsonValue.TRUE, true), + Arguments.of(JsonValue.FALSE, false), + Arguments.of(JsonValue.NULL, null), + Arguments.of( + Json.createObjectBuilder().add("firstName", "Duke").add("lastName", "Java").build(), + Map.of("firstName", "Duke", "lastName", "Java")), + Arguments.of( + Json.createArrayBuilder().add("Java").add("Python").add("JavaScript").build(), + List.of("Java", "Python", "JavaScript")), + Arguments.of(Json.createValue("Hello, World!"), "Hello, World!"), + Arguments.of(Json.createValue(123), 123L), + Arguments.of(Json.createValue(123.45), 123.45d)); + } +} diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/mapping/DefaultPrincipalMapperTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/mapping/DefaultPrincipalMapperTest.java new file mode 100644 index 0000000000..842c35fab5 --- /dev/null +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/mapping/DefaultPrincipalMapperTest.java @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external.mapping; + +import static org.apache.polaris.service.quarkus.auth.external.OidcTenantResolvingAugmentor.TENANT_CONFIG_ATTRIBUTE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.common.annotation.Identifier; +import jakarta.enterprise.inject.Instance; +import java.util.Optional; +import java.util.stream.Stream; +import org.apache.polaris.service.quarkus.auth.external.OidcTenantConfiguration; +import org.apache.polaris.service.quarkus.auth.external.OidcTenantConfiguration.PrincipalMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class DefaultPrincipalMapperTest { + + private DefaultPrincipalMapper mapper; + private ClaimsLocator claimsLocator; + private SecurityIdentity identity; + + @BeforeEach + public void setup() { + claimsLocator = mock(ClaimsLocator.class); + identity = mock(SecurityIdentity.class); + PrincipalMapper principalMapping = mock(PrincipalMapper.class); + when(principalMapping.idClaimPath()).thenReturn(Optional.of("id_path")); + when(principalMapping.nameClaimPath()).thenReturn(Optional.of("name_path")); + OidcTenantConfiguration config = mock(OidcTenantConfiguration.class); + when(config.principalMapper()).thenReturn(principalMapping); + when(identity.getAttribute(TENANT_CONFIG_ATTRIBUTE)).thenReturn(config); + @SuppressWarnings("unchecked") + Instance strategies = mock(Instance.class); + when(strategies.select(Identifier.Literal.of("default"))).thenReturn(strategies); + when(strategies.get()).thenReturn(new DefaultPrincipalRolesMapper()); + mapper = new DefaultPrincipalMapper(claimsLocator); + } + + @ParameterizedTest + @MethodSource + public void mapPrincipalId(Object claim, long expected) { + when(claimsLocator.locateClaim(eq("id_path"), any())).thenReturn(claim); + long result = mapper.mapPrincipalId(identity).orElse(-1); + assertThat(result).isEqualTo(expected); + } + + static Stream mapPrincipalId() { + return Stream.of(Arguments.of(123L, 123L), Arguments.of("123", 123L), Arguments.of(null, -1)); + } + + @ParameterizedTest + @MethodSource + public void mapPrincipalName(Object claim, String expected) { + when(claimsLocator.locateClaim(eq("name_path"), any())).thenReturn(claim); + String result = mapper.mapPrincipalName(identity).orElse(null); + assertThat(result).isEqualTo(expected); + } + + static Stream mapPrincipalName() { + return Stream.of( + Arguments.of("testUser", "testUser"), Arguments.of(123, "123"), Arguments.of(null, null)); + } +} diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/mapping/DefaultPrincipalRolesMapperTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/mapping/DefaultPrincipalRolesMapperTest.java new file mode 100644 index 0000000000..8ee1d633b8 --- /dev/null +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/mapping/DefaultPrincipalRolesMapperTest.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external.mapping; + +import static org.apache.polaris.service.quarkus.auth.external.OidcTenantResolvingAugmentor.TENANT_CONFIG_ATTRIBUTE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.quarkus.security.identity.SecurityIdentity; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; +import org.apache.polaris.service.quarkus.auth.external.OidcTenantConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class DefaultPrincipalRolesMapperTest { + + private DefaultPrincipalRolesMapper mapper; + private SecurityIdentity identity; + + @BeforeEach + public void setup() { + identity = mock(SecurityIdentity.class); + OidcTenantConfiguration.PrincipalRolesMapper rolesMapper = + mock(OidcTenantConfiguration.PrincipalRolesMapper.class); + when(rolesMapper.filter()).thenReturn(Optional.of("POLARIS_ROLE:.*")); + OidcTenantConfiguration.PrincipalRolesMapper.RegexMapping mapping = + mock(OidcTenantConfiguration.PrincipalRolesMapper.RegexMapping.class); + when(mapping.regex()).thenReturn("POLARIS_ROLE:(.*)"); + when(mapping.replacement()).thenReturn("PRINCIPAL_ROLE:$1"); + when(mapping.replace(any())).thenCallRealMethod(); + when(rolesMapper.mappings()).thenReturn(List.of(mapping)); + when(rolesMapper.filterPredicate()).thenCallRealMethod(); + when(rolesMapper.mapperFunction()).thenCallRealMethod(); + OidcTenantConfiguration config = mock(OidcTenantConfiguration.class); + when(config.principalRolesMapper()).thenReturn(rolesMapper); + when(identity.getAttribute(TENANT_CONFIG_ATTRIBUTE)).thenReturn(config); + mapper = new DefaultPrincipalRolesMapper(); + } + + @ParameterizedTest + @MethodSource + public void mapPrincipalRoles(Set input, Set expected) { + when(identity.getRoles()).thenReturn(input); + Set actual = mapper.mapPrincipalRoles(identity); + assertThat(actual).isEqualTo(expected); + } + + static Stream mapPrincipalRoles() { + return Stream.of( + Arguments.of(Set.of(), Set.of()), + Arguments.of(Set.of("POLARIS_ROLE:role1"), Set.of("PRINCIPAL_ROLE:role1")), + Arguments.of( + Set.of("POLARIS_ROLE:role1", "POLARIS_ROLE:role2"), + Set.of("PRINCIPAL_ROLE:role1", "PRINCIPAL_ROLE:role2")), + Arguments.of( + Set.of("POLARIS_ROLE:role1", "OTHER_ROLE:role2"), Set.of("PRINCIPAL_ROLE:role1"))); + } +} diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/tenant/DefaultOidcTenantResolverTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/tenant/DefaultOidcTenantResolverTest.java new file mode 100644 index 0000000000..82304421d8 --- /dev/null +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/external/tenant/DefaultOidcTenantResolverTest.java @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.external.tenant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import io.quarkus.oidc.runtime.OidcUtils; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import java.util.HashMap; +import java.util.Map; +import org.apache.polaris.service.quarkus.auth.external.OidcConfiguration; +import org.apache.polaris.service.quarkus.auth.external.OidcTenantConfiguration; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class DefaultOidcTenantResolverTest { + + private DefaultOidcTenantResolver resolver; + private OidcConfiguration polarisOidcConfig; + + @BeforeEach + public void setup() { + polarisOidcConfig = mock(OidcConfiguration.class); + resolver = new DefaultOidcTenantResolver(polarisOidcConfig); + } + + @Test + public void testResolveConfigWithMatchingTenant() { + // Given + String tenantId = "tenant1"; + SecurityIdentity identity = + QuarkusSecurityIdentity.builder() + .setPrincipal(mock(JsonWebToken.class)) + .addAttribute(OidcUtils.TENANT_ID_ATTRIBUTE, tenantId) + .build(); + + Map polarisTenants = new HashMap<>(); + OidcTenantConfiguration polarisTenantConfig = mock(OidcTenantConfiguration.class); + polarisTenants.put(tenantId, polarisTenantConfig); + + doReturn(polarisTenants).when(polarisOidcConfig).tenants(); + + // When + OidcTenantConfiguration result = resolver.resolveConfig(identity); + + // Then + assertThat(result).isSameAs(polarisTenantConfig); + } + + @Test + public void testResolveConfigWithoutMatchingTenant() { + // Given + String tenantId = "tenant1"; + SecurityIdentity identity = + QuarkusSecurityIdentity.builder() + .setPrincipal(mock(JsonWebToken.class)) + .addAttribute(OidcUtils.TENANT_ID_ATTRIBUTE, tenantId) + .build(); + + Map polarisTenants = new HashMap<>(); + OidcTenantConfiguration polarisTenantConfig = mock(OidcTenantConfiguration.class); + polarisTenants.put(OidcConfiguration.DEFAULT_TENANT_KEY, polarisTenantConfig); + + doReturn(polarisTenants).when(polarisOidcConfig).tenants(); + + // When + OidcTenantConfiguration result = resolver.resolveConfig(identity); + + // Then + assertThat(result).isSameAs(polarisTenantConfig); + } + + @Test + public void testResolveConfigWithDefaultTenant() { + // Given + SecurityIdentity identity = + QuarkusSecurityIdentity.builder() + .setPrincipal(mock(JsonWebToken.class)) + .addAttribute(OidcUtils.TENANT_ID_ATTRIBUTE, OidcUtils.DEFAULT_TENANT_ID) + .build(); + + Map polarisTenants = new HashMap<>(); + OidcTenantConfiguration polarisTenantConfig = mock(OidcTenantConfiguration.class); + polarisTenants.put(OidcConfiguration.DEFAULT_TENANT_KEY, polarisTenantConfig); + + doReturn(polarisTenants).when(polarisOidcConfig).tenants(); + + // When + OidcTenantConfiguration result = resolver.resolveConfig(identity); + + // Then + assertThat(result).isSameAs(polarisTenantConfig); + } + + @Test + public void testResolveConfigWithoutTenant() { + // Given + SecurityIdentity identity = + QuarkusSecurityIdentity.builder().setPrincipal(mock(JsonWebToken.class)).build(); + + Map polarisTenants = new HashMap<>(); + OidcTenantConfiguration polarisTenantConfig = mock(OidcTenantConfiguration.class); + polarisTenants.put(OidcConfiguration.DEFAULT_TENANT_KEY, polarisTenantConfig); + + doReturn(polarisTenants).when(polarisOidcConfig).tenants(); + + // When + OidcTenantConfiguration result = resolver.resolveConfig(identity); + + // Then + assertThat(result).isSameAs(polarisTenantConfig); + } +} diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/internal/InternalAuthenticationMechanismTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/internal/InternalAuthenticationMechanismTest.java new file mode 100644 index 0000000000..f36ff11c8d --- /dev/null +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/internal/InternalAuthenticationMechanismTest.java @@ -0,0 +1,173 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.TokenAuthenticationRequest; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; +import org.apache.iceberg.exceptions.NotAuthorizedException; +import org.apache.polaris.service.auth.AuthenticationRealmConfiguration; +import org.apache.polaris.service.auth.AuthenticationType; +import org.apache.polaris.service.auth.DecodedToken; +import org.apache.polaris.service.auth.TokenBroker; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +public class InternalAuthenticationMechanismTest { + + private InternalAuthenticationMechanism mechanism; + private AuthenticationRealmConfiguration configuration; + private TokenBroker tokenBroker; + private IdentityProviderManager identityProviderManager; + private RoutingContext routingContext; + + @BeforeEach + public void setup() { + configuration = mock(AuthenticationRealmConfiguration.class); + tokenBroker = mock(TokenBroker.class); + identityProviderManager = mock(IdentityProviderManager.class); + routingContext = mock(RoutingContext.class); + mechanism = new InternalAuthenticationMechanism(configuration, tokenBroker); + } + + @ParameterizedTest + @CsvSource({ + "INTERNAL , true", + "EXTERNAL , false", + "MIXED , true", + }) + public void testShouldProcess(AuthenticationType type, boolean expectedResult) { + when(configuration.type()).thenReturn(type); + assertThat( + mechanism.configuration.type() == AuthenticationType.INTERNAL + || mechanism.configuration.type() == AuthenticationType.MIXED) + .isEqualTo(expectedResult); + } + + @Test + public void testAuthenticateWithNoAuthHeader() { + when(configuration.type()).thenReturn(AuthenticationType.INTERNAL); + when(routingContext.request()).thenReturn(mock(io.vertx.core.http.HttpServerRequest.class)); + when(routingContext.request().getHeader("Authorization")).thenReturn(null); + + Uni result = mechanism.authenticate(routingContext, identityProviderManager); + + assertThat(result.await().indefinitely()).isNull(); + verify(tokenBroker, never()).verify(any()); + } + + @Test + public void testAuthenticateWithInvalidAuthHeaderFormat() { + when(configuration.type()).thenReturn(AuthenticationType.INTERNAL); + when(routingContext.request()).thenReturn(mock(io.vertx.core.http.HttpServerRequest.class)); + when(routingContext.request().getHeader("Authorization")).thenReturn("InvalidFormat"); + + Uni result = mechanism.authenticate(routingContext, identityProviderManager); + + assertThat(result.await().indefinitely()).isNull(); + verify(tokenBroker, never()).verify(any()); + } + + @Test + public void testAuthenticateWithNonBearerAuthHeader() { + when(configuration.type()).thenReturn(AuthenticationType.INTERNAL); + when(routingContext.request()).thenReturn(mock(io.vertx.core.http.HttpServerRequest.class)); + when(routingContext.request().getHeader("Authorization")).thenReturn("Basic dXNlcjpwYXNz"); + + Uni result = mechanism.authenticate(routingContext, identityProviderManager); + + assertThat(result.await().indefinitely()).isNull(); + verify(tokenBroker, never()).verify(any()); + } + + @Test + public void testAuthenticateWithInvalidTokenInternalAuth() { + when(configuration.type()).thenReturn(AuthenticationType.INTERNAL); + when(routingContext.request()).thenReturn(mock(io.vertx.core.http.HttpServerRequest.class)); + when(routingContext.request().getHeader("Authorization")).thenReturn("Bearer invalidToken"); + + NotAuthorizedException cause = new NotAuthorizedException("Invalid token"); + when(tokenBroker.verify("invalidToken")).thenThrow(cause); + + SecurityIdentity securityIdentity = mock(SecurityIdentity.class); + when(identityProviderManager.authenticate(any())) + .thenReturn(Uni.createFrom().item(securityIdentity)); + + Uni result = mechanism.authenticate(routingContext, identityProviderManager); + + assertThatThrownBy(() -> result.await().indefinitely()) + .isInstanceOf(AuthenticationFailedException.class) + .hasCause(cause); + verify(tokenBroker).verify("invalidToken"); + verify(identityProviderManager, never()).authenticate(any(TokenAuthenticationRequest.class)); + } + + @Test + public void testAuthenticateWithInvalidTokenMixedAuth() { + when(configuration.type()).thenReturn(AuthenticationType.MIXED); + when(routingContext.request()).thenReturn(mock(io.vertx.core.http.HttpServerRequest.class)); + when(routingContext.request().getHeader("Authorization")).thenReturn("Bearer invalidToken"); + + NotAuthorizedException cause = new NotAuthorizedException("Invalid token"); + when(tokenBroker.verify("invalidToken")).thenThrow(cause); + + SecurityIdentity securityIdentity = mock(SecurityIdentity.class); + when(identityProviderManager.authenticate(any())) + .thenReturn(Uni.createFrom().item(securityIdentity)); + + Uni result = mechanism.authenticate(routingContext, identityProviderManager); + + assertThat(result.await().indefinitely()).isNull(); + verify(tokenBroker).verify("invalidToken"); + verify(identityProviderManager, never()).authenticate(any(TokenAuthenticationRequest.class)); + } + + @Test + public void testAuthenticateWithValidToken() { + when(configuration.type()).thenReturn(AuthenticationType.INTERNAL); + when(routingContext.request()).thenReturn(mock(io.vertx.core.http.HttpServerRequest.class)); + when(routingContext.request().getHeader("Authorization")).thenReturn("Bearer validToken"); + + DecodedToken decodedToken = mock(DecodedToken.class); + when(tokenBroker.verify("validToken")).thenReturn(decodedToken); + + SecurityIdentity securityIdentity = mock(SecurityIdentity.class); + when(identityProviderManager.authenticate(any())) + .thenReturn(Uni.createFrom().item(securityIdentity)); + + Uni result = mechanism.authenticate(routingContext, identityProviderManager); + + assertThat(result.await().indefinitely()).isSameAs(securityIdentity); + verify(tokenBroker).verify("validToken"); + verify(identityProviderManager).authenticate(any(TokenAuthenticationRequest.class)); + } +} diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/internal/InternalIdentityProviderTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/internal/InternalIdentityProviderTest.java new file mode 100644 index 0000000000..18b623713c --- /dev/null +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/auth/internal/InternalIdentityProviderTest.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.quarkus.auth.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.quarkus.security.credential.TokenCredential; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.TokenAuthenticationRequest; +import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; +import java.security.Principal; +import org.apache.polaris.service.quarkus.auth.internal.InternalAuthenticationMechanism.InternalPrincipalAuthInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class InternalIdentityProviderTest { + + private InternalIdentityProvider provider; + private AuthenticationRequestContext context; + + @BeforeEach + public void setup() { + provider = new InternalIdentityProvider(); + context = mock(AuthenticationRequestContext.class); + } + + @Test + public void testAuthenticateWithWrongCredential() { + TokenCredential nonInternalCredential = mock(TokenCredential.class); + TokenAuthenticationRequest request = new TokenAuthenticationRequest(nonInternalCredential); + + Uni result = provider.authenticate(request, context); + + assertThat(result.await().indefinitely()).isNull(); + } + + @Test + public void testAuthenticateWithValidCredential() { + // Create a mock InternalPrincipalAuthInfo + InternalPrincipalAuthInfo credential = mock(InternalPrincipalAuthInfo.class); + when(credential.getPrincipalName()).thenReturn("testUser"); + + // Create a request with the credential and a routing context attribute + RoutingContext routingContext = mock(RoutingContext.class); + TokenAuthenticationRequest request = new TokenAuthenticationRequest(credential); + HttpSecurityUtils.setRoutingContextAttribute(request, routingContext); + + // Authenticate the request + Uni result = provider.authenticate(request, context); + + // Verify the result + SecurityIdentity identity = result.await().indefinitely(); + assertThat(identity).isNotNull(); + + // Verify the principal + Principal principal = identity.getPrincipal(); + assertThat(principal).isNotNull(); + assertThat(principal.getName()).isEqualTo("testUser"); + + // Verify the credential is set + assertThat(identity.getCredential(InternalPrincipalAuthInfo.class)).isSameAs(credential); + + // Verify the routing context attribute is set + assertThat((RoutingContext) identity.getAttribute(RoutingContext.class.getName())) + .isSameAs(routingContext); + } +} diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/it/QuarkusApplicationIntegrationTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/it/QuarkusApplicationIntegrationTest.java index 9c72c9b065..c98da4d5a9 100644 --- a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/it/QuarkusApplicationIntegrationTest.java +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/it/QuarkusApplicationIntegrationTest.java @@ -26,8 +26,6 @@ import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.QuarkusTestProfile; import io.quarkus.test.junit.TestProfile; -import io.smallrye.common.annotation.Identifier; -import jakarta.inject.Inject; import java.io.IOException; import java.time.Instant; import java.util.Map; @@ -39,7 +37,6 @@ import org.apache.iceberg.rest.auth.AuthSession; import org.apache.iceberg.rest.auth.OAuth2Util; import org.apache.iceberg.rest.responses.OAuthTokenResponse; -import org.apache.polaris.service.auth.TokenBrokerFactory; import org.apache.polaris.service.it.env.ClientCredentials; import org.apache.polaris.service.it.env.PolarisApiEndpoints; import org.apache.polaris.service.it.test.PolarisApplicationIntegrationTest; @@ -61,10 +58,6 @@ public Map getConfigOverrides() { } } - @Inject - @Identifier("rsa-key-pair") - TokenBrokerFactory tokenBrokerFactory; - @Test public void testIcebergRestApiRefreshExpiredToken( PolarisApiEndpoints endpoints, ClientCredentials clientCredentials) throws IOException { diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/ratelimiter/RateLimiterFilterTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/ratelimiter/RateLimiterFilterTest.java index 3cd95bf1e8..a5f66ec5be 100644 --- a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/ratelimiter/RateLimiterFilterTest.java +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/ratelimiter/RateLimiterFilterTest.java @@ -77,7 +77,11 @@ public Map getConfigOverrides() { "polaris.metrics.tags.environment", "prod", "polaris.realm-context.type", - "test"); + "test", + "polaris.authentication.token-broker.type", + "symmetric-key", + "polaris.authentication.token-broker.symmetric-key.secret", + "secret"); } } diff --git a/quarkus/service/src/test/resources/logback-test.xml b/quarkus/service/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..6e963a3473 --- /dev/null +++ b/quarkus/service/src/test/resources/logback-test.xml @@ -0,0 +1,32 @@ + + + + + + + %date{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/service/common/src/main/java/org/apache/polaris/service/auth/AuthenticationConfiguration.java b/service/common/src/main/java/org/apache/polaris/service/auth/AuthenticationConfiguration.java index 21fd912a78..8a8fda78ef 100644 --- a/service/common/src/main/java/org/apache/polaris/service/auth/AuthenticationConfiguration.java +++ b/service/common/src/main/java/org/apache/polaris/service/auth/AuthenticationConfiguration.java @@ -18,60 +18,22 @@ */ package org.apache.polaris.service.auth; -import java.nio.file.Path; -import java.time.Duration; -import java.util.Optional; +import java.util.Map; +import org.apache.polaris.core.context.RealmContext; -public interface AuthenticationConfiguration { +public interface AuthenticationConfiguration { - /** The configuration for the authenticator. */ - AuthenticatorConfiguration authenticator(); + String DEFAULT_REALM_KEY = ""; - interface AuthenticatorConfiguration {} + Map realms(); - /** The configuration for the OAuth2 service that delivers OAuth2 tokens. */ - TokenServiceConfiguration tokenService(); - - interface TokenServiceConfiguration {} - - /** - * The configuration for the token broker factory. Token brokers are used by both the - * authenticator and the token service. - */ - TokenBrokerConfiguration tokenBroker(); - - interface TokenBrokerConfiguration { - - /** The maximum token duration. */ - Duration maxTokenGeneration(); - - /** Configuration for the rsa-key-pair token broker factory. */ - Optional rsaKeyPair(); - - /** Configuration for the symmetric-key token broker factory. */ - Optional symmetricKey(); - - interface RSAKeyPairConfiguration { - - /** The path to the public key file. */ - Path publicKeyFile(); - - /** The path to the private key file. */ - Path privateKeyFile(); - } - - interface SymmetricKeyConfiguration { - - /** - * The secret to use for both signing and verifying signatures. Either this option of {@link - * #file()} must be provided. - */ - Optional secret(); + default R forRealm(RealmContext realmContext) { + return forRealm(realmContext.getRealmIdentifier()); + } - /** - * The file to read the secret from. Either this option of {@link #secret()} must be provided. - */ - Optional file(); - } + default R forRealm(String realmIdentifier) { + return realms().containsKey(realmIdentifier) + ? realms().get(realmIdentifier) + : realms().get(DEFAULT_REALM_KEY); } } diff --git a/service/common/src/main/java/org/apache/polaris/service/auth/AuthenticationRealmConfiguration.java b/service/common/src/main/java/org/apache/polaris/service/auth/AuthenticationRealmConfiguration.java new file mode 100644 index 0000000000..e6ffae0041 --- /dev/null +++ b/service/common/src/main/java/org/apache/polaris/service/auth/AuthenticationRealmConfiguration.java @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.auth; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.Optional; + +public interface AuthenticationRealmConfiguration { + + /** The type of authentication for this realm. */ + AuthenticationType type(); + + /** + * The configuration for the authenticator. The authenticator is responsible for validating token + * credentials and mapping those credentials to an existing Polaris principal. + */ + AuthenticatorConfiguration authenticator(); + + interface AuthenticatorConfiguration {} + + /** + * The configuration for the OAuth2 service that delivers OAuth2 tokens. Only relevant when using + * internal authentication (using Polaris as the authorization server). + */ + TokenServiceConfiguration tokenService(); + + interface TokenServiceConfiguration {} + + /** + * The configuration for the token broker factory. Token brokers are used by both the + * authenticator and the token service. Only relevant when using internal authentication (using + * Polaris as the authorization server). + */ + TokenBrokerConfiguration tokenBroker(); + + interface TokenBrokerConfiguration { + + /** The maximum token duration. */ + Duration maxTokenGeneration(); + + /** Configuration for the rsa-key-pair token broker factory. */ + Optional rsaKeyPair(); + + /** Configuration for the symmetric-key token broker factory. */ + Optional symmetricKey(); + + interface RSAKeyPairConfiguration { + + /** The path to the public key file. */ + Path publicKeyFile(); + + /** The path to the private key file. */ + Path privateKeyFile(); + } + + interface SymmetricKeyConfiguration { + + /** + * The secret to use for both signing and verifying signatures. Either this option of {@link + * #file()} must be provided. + */ + Optional secret(); + + /** + * The file to read the secret from. Either this option of {@link #secret()} must be provided. + */ + Optional file(); + } + } +} diff --git a/service/common/src/main/java/org/apache/polaris/service/auth/AuthenticationType.java b/service/common/src/main/java/org/apache/polaris/service/auth/AuthenticationType.java new file mode 100644 index 0000000000..2c87152abd --- /dev/null +++ b/service/common/src/main/java/org/apache/polaris/service/auth/AuthenticationType.java @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.auth; + +public enum AuthenticationType { + + /** Polaris will be the only identity provider. */ + INTERNAL, + + /** + * Polaris will delegate authentication to an external identity provider (e.g., Keycloak). The + * internal token endpoint will be deactivated. + */ + EXTERNAL, + + /** + * Polaris will act as an identity provider, but it will also support external authentication. The + * internal token endpoint will be activated and will always be tried first. If the token issuer + * is not Polaris, the token will be passed to the external identity provider for validation. + */ + MIXED, +} diff --git a/service/common/src/main/java/org/apache/polaris/service/auth/Authenticator.java b/service/common/src/main/java/org/apache/polaris/service/auth/Authenticator.java index 4e1a45f3f2..5ea5f72685 100644 --- a/service/common/src/main/java/org/apache/polaris/service/auth/Authenticator.java +++ b/service/common/src/main/java/org/apache/polaris/service/auth/Authenticator.java @@ -20,8 +20,29 @@ import java.security.Principal; import java.util.Optional; +import org.apache.iceberg.exceptions.NotAuthorizedException; +import org.apache.iceberg.exceptions.ServiceFailureException; +/** + * An interface for authenticating principals based on provided credentials. + * + * @param the type of credentials used for authentication + * @param

the type of principal that is returned upon successful authentication + */ public interface Authenticator { - Optional

authenticate(C credentials); + /** + * Authenticates the given credentials and returns an optional principal. + * + *

If the credentials are not valid or if the authentication fails, implementations may choose + * to return an empty optional or throw an exception. Returning empty will generally translate + * into a {@link NotAuthorizedException}. + * + * @param credentials the credentials to authenticate + * @return an optional principal if authentication is successful, or an empty optional if + * authentication fails. + * @throws NotAuthorizedException if the credentials are not authorized + * @throws ServiceFailureException if there is a failure in the authentication service + */ + Optional

authenticate(C credentials) throws NotAuthorizedException, ServiceFailureException; } diff --git a/service/common/src/main/java/org/apache/polaris/service/auth/DecodedToken.java b/service/common/src/main/java/org/apache/polaris/service/auth/DecodedToken.java index 487173f34f..73fada6752 100644 --- a/service/common/src/main/java/org/apache/polaris/service/auth/DecodedToken.java +++ b/service/common/src/main/java/org/apache/polaris/service/auth/DecodedToken.java @@ -18,12 +18,33 @@ */ package org.apache.polaris.service.auth; -public interface DecodedToken { - Long getPrincipalId(); +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * A specialized {@link PrincipalAuthInfo} used for internal authentication, when Polaris is the + * identity provider. + */ +public interface DecodedToken extends PrincipalAuthInfo { String getClientId(); String getSub(); String getScope(); + + @Override + default String getPrincipalName() { + // Polaris stores the principal ID in the "sub" claim as a string, + // and in the "principal_id" claim as a numeric value. It doesn't store + // the principal name in the token, so we return null here. + return null; + } + + @Override + default Set getPrincipalRoles() { + // Polaris stores the principal roles in the "scope" claim + return Arrays.stream(getScope().split(" ")).collect(Collectors.toSet()); + } } diff --git a/service/common/src/main/java/org/apache/polaris/service/auth/DefaultActiveRolesProvider.java b/service/common/src/main/java/org/apache/polaris/service/auth/DefaultActiveRolesProvider.java index 43d960c414..1d4a507877 100644 --- a/service/common/src/main/java/org/apache/polaris/service/auth/DefaultActiveRolesProvider.java +++ b/service/common/src/main/java/org/apache/polaris/service/auth/DefaultActiveRolesProvider.java @@ -82,7 +82,9 @@ protected List loadActivePrincipalRoles( principal.getId()); throw new NotAuthorizedException("Unable to authenticate"); } - boolean allRoles = tokenRoles.contains(BasePolarisAuthenticator.PRINCIPAL_ROLE_ALL); + + // FIXME how to distinguish allRoles from no roles at all? + boolean allRoles = tokenRoles.isEmpty(); Predicate includeRoleFilter = allRoles ? r -> true : r -> tokenRoles.contains(r.getName()); List activeRoles = diff --git a/service/common/src/main/java/org/apache/polaris/service/auth/BasePolarisAuthenticator.java b/service/common/src/main/java/org/apache/polaris/service/auth/DefaultAuthenticator.java similarity index 51% rename from service/common/src/main/java/org/apache/polaris/service/auth/BasePolarisAuthenticator.java rename to service/common/src/main/java/org/apache/polaris/service/auth/DefaultAuthenticator.java index ad2f9da4cf..c039bb82f9 100644 --- a/service/common/src/main/java/org/apache/polaris/service/auth/BasePolarisAuthenticator.java +++ b/service/common/src/main/java/org/apache/polaris/service/auth/DefaultAuthenticator.java @@ -18,7 +18,9 @@ */ package org.apache.polaris.service.auth; -import java.util.Arrays; +import io.smallrye.common.annotation.Identifier; +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; import java.util.HashSet; import java.util.Optional; import java.util.Set; @@ -27,7 +29,6 @@ import org.apache.iceberg.exceptions.ServiceFailureException; import org.apache.polaris.core.auth.AuthenticatedPolarisPrincipal; import org.apache.polaris.core.context.CallContext; -import org.apache.polaris.core.context.RealmContext; import org.apache.polaris.core.entity.PolarisEntity; import org.apache.polaris.core.entity.PolarisEntitySubType; import org.apache.polaris.core.entity.PolarisEntityType; @@ -38,48 +39,51 @@ import org.slf4j.LoggerFactory; /** - * Base implementation of {@link Authenticator} constructs a {@link AuthenticatedPolarisPrincipal} - * from the token parsed by subclasses. The {@link AuthenticatedPolarisPrincipal} is read from the - * {@link PolarisMetaStoreManager} for the current {@link RealmContext}. If the token defines a - * non-empty set of scopes, only the principal roles specified in the scopes will be active for the - * current principal. Only the grants assigned to these roles will be active in the current request. + * The default authenticator that resolves a {@link PrincipalAuthInfo} to an {@link + * AuthenticatedPolarisPrincipal}. + * + *

This authenticator is used in both internal and external authentication scenarios. */ -public abstract class BasePolarisAuthenticator - implements Authenticator { +@RequestScoped +@Identifier("default") +public class DefaultAuthenticator + implements Authenticator { + public static final String PRINCIPAL_ROLE_ALL = "PRINCIPAL_ROLE:ALL"; public static final String PRINCIPAL_ROLE_PREFIX = "PRINCIPAL_ROLE:"; - private static final Logger LOGGER = LoggerFactory.getLogger(BasePolarisAuthenticator.class); - protected final MetaStoreManagerFactory metaStoreManagerFactory; - protected final CallContext callContext; + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultAuthenticator.class); - protected BasePolarisAuthenticator( - MetaStoreManagerFactory metaStoreManagerFactory, CallContext callContext) { - this.metaStoreManagerFactory = metaStoreManagerFactory; - this.callContext = callContext; - } + @Inject MetaStoreManagerFactory metaStoreManagerFactory; + @Inject CallContext callContext; - protected Optional getPrincipal(DecodedToken tokenInfo) { - LOGGER.debug("Resolving principal for tokenInfo client_id={}", tokenInfo.getClientId()); + @Override + public Optional authenticate(PrincipalAuthInfo credentials) { + LOGGER.debug("Resolving principal for credentials={}", credentials); PolarisMetaStoreManager metaStoreManager = metaStoreManagerFactory.getOrCreateMetaStoreManager(callContext.getRealmContext()); - PolarisEntity principal; + PolarisEntity principal = null; try { - principal = - tokenInfo.getPrincipalId() > 0 - ? PolarisEntity.of( - metaStoreManager.loadEntity( - callContext.getPolarisCallContext(), - 0L, - tokenInfo.getPrincipalId(), - PolarisEntityType.PRINCIPAL)) - : PolarisEntity.of( - metaStoreManager.readEntityByName( - callContext.getPolarisCallContext(), - null, - PolarisEntityType.PRINCIPAL, - PolarisEntitySubType.NULL_SUBTYPE, - tokenInfo.getSub())); + // If the principal id is present, prefer to use it to load the principal entity, + // otherwise, use the principal name to load the entity. + if (credentials.getPrincipalId() != null && credentials.getPrincipalId() > 0) { + principal = + PolarisEntity.of( + metaStoreManager.loadEntity( + callContext.getPolarisCallContext(), + 0L, + credentials.getPrincipalId(), + PolarisEntityType.PRINCIPAL)); + } else if (credentials.getPrincipalName() != null) { + principal = + PolarisEntity.of( + metaStoreManager.readEntityByName( + callContext.getPolarisCallContext(), + null, + PolarisEntityType.PRINCIPAL, + PolarisEntitySubType.NULL_SUBTYPE, + credentials.getPrincipalName())); + } } catch (Exception e) { LOGGER .atError() @@ -88,22 +92,21 @@ protected Optional getPrincipal(DecodedToken toke .log("Unable to authenticate user with token"); throw new ServiceFailureException("Unable to fetch principal entity"); } - if (principal == null) { - LOGGER.warn( - "Failed to resolve principal from tokenInfo client_id={}", tokenInfo.getClientId()); + if (principal == null || principal.getType() != PolarisEntityType.PRINCIPAL) { + LOGGER.warn("Failed to resolve principal from credentials={}", credentials); throw new NotAuthorizedException("Unable to authenticate"); } + LOGGER.debug("Resolved principal: {}", principal); + + boolean allRoles = credentials.getPrincipalRoles().contains(PRINCIPAL_ROLE_ALL); + Set activatedPrincipalRoles = new HashSet<>(); - // TODO: Consolidate the divergent "scopes" logic between test-bearer-token and token-exchange. - if (tokenInfo.getScope() != null && !tokenInfo.getScope().equals(PRINCIPAL_ROLE_ALL)) { + if (!allRoles) { activatedPrincipalRoles.addAll( - Arrays.stream(tokenInfo.getScope().split(" ")) - .map( - s -> // strip the principal_role prefix, if present - s.startsWith(PRINCIPAL_ROLE_PREFIX) - ? s.substring(PRINCIPAL_ROLE_PREFIX.length()) - : s) + credentials.getPrincipalRoles().stream() + .filter(s -> s.startsWith(PRINCIPAL_ROLE_PREFIX)) + .map(s -> s.substring(PRINCIPAL_ROLE_PREFIX.length())) .toList()); } diff --git a/service/common/src/main/java/org/apache/polaris/service/auth/DefaultOAuth2ApiService.java b/service/common/src/main/java/org/apache/polaris/service/auth/DefaultOAuth2ApiService.java index fea9caf549..fb2fb5750c 100644 --- a/service/common/src/main/java/org/apache/polaris/service/auth/DefaultOAuth2ApiService.java +++ b/service/common/src/main/java/org/apache/polaris/service/auth/DefaultOAuth2ApiService.java @@ -46,12 +46,12 @@ public class DefaultOAuth2ApiService implements IcebergRestOAuth2ApiService { private static final String BEARER = "bearer"; - private final TokenBrokerFactory tokenBrokerFactory; + private final TokenBroker tokenBroker; private final CallContext callContext; @Inject - public DefaultOAuth2ApiService(TokenBrokerFactory tokenBrokerFactory, CallContext callContext) { - this.tokenBrokerFactory = tokenBrokerFactory; + public DefaultOAuth2ApiService(TokenBroker tokenBroker, CallContext callContext) { + this.tokenBroker = tokenBroker; this.callContext = callContext; } @@ -70,7 +70,6 @@ public Response getToken( RealmContext realmContext, SecurityContext securityContext) { - TokenBroker tokenBroker = tokenBrokerFactory.apply(realmContext); if (!tokenBroker.supportsGrantType(grantType)) { return OAuthUtils.getResponseFromError(OAuthTokenErrorResponse.Error.unsupported_grant_type); } diff --git a/service/common/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthenticator.java b/service/common/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthenticator.java deleted file mode 100644 index 900a4ddd36..0000000000 --- a/service/common/src/main/java/org/apache/polaris/service/auth/DefaultPolarisAuthenticator.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.polaris.service.auth; - -import io.smallrye.common.annotation.Identifier; -import jakarta.enterprise.context.RequestScoped; -import jakarta.inject.Inject; -import java.util.Optional; -import org.apache.polaris.core.auth.AuthenticatedPolarisPrincipal; -import org.apache.polaris.core.context.CallContext; -import org.apache.polaris.core.persistence.MetaStoreManagerFactory; - -@RequestScoped -@Identifier("default") -public class DefaultPolarisAuthenticator extends BasePolarisAuthenticator { - - private final TokenBrokerFactory tokenBrokerFactory; - - public DefaultPolarisAuthenticator() { - this(null, null, null); - } - - @Inject - public DefaultPolarisAuthenticator( - MetaStoreManagerFactory metaStoreManagerFactory, - TokenBrokerFactory tokenBrokerFactory, - CallContext callContext) { - super(metaStoreManagerFactory, callContext); - this.tokenBrokerFactory = tokenBrokerFactory; - } - - @Override - public Optional authenticate(String credentials) { - TokenBroker handler = tokenBrokerFactory.apply(callContext.getRealmContext()); - DecodedToken decodedToken = handler.verify(credentials); - return getPrincipal(decodedToken); - } -} diff --git a/service/common/src/main/java/org/apache/polaris/service/auth/DisabledOAuth2ApiService.java b/service/common/src/main/java/org/apache/polaris/service/auth/DisabledOAuth2ApiService.java new file mode 100644 index 0000000000..16877e0976 --- /dev/null +++ b/service/common/src/main/java/org/apache/polaris/service/auth/DisabledOAuth2ApiService.java @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.auth; + +import io.smallrye.common.annotation.Identifier; +import jakarta.enterprise.context.ApplicationScoped; +import org.apache.polaris.service.catalog.api.IcebergRestOAuth2ApiService; + +@ApplicationScoped +@Identifier("disabled") +public class DisabledOAuth2ApiService implements IcebergRestOAuth2ApiService {} diff --git a/service/common/src/main/java/org/apache/polaris/service/auth/JWTBroker.java b/service/common/src/main/java/org/apache/polaris/service/auth/JWTBroker.java index dafe0732dc..c0ce0b471c 100644 --- a/service/common/src/main/java/org/apache/polaris/service/auth/JWTBroker.java +++ b/service/common/src/main/java/org/apache/polaris/service/auth/JWTBroker.java @@ -25,6 +25,7 @@ import com.auth0.jwt.interfaces.JWTVerifier; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Objects; import java.util.Optional; import java.util.UUID; import org.apache.commons.lang3.StringUtils; @@ -120,7 +121,7 @@ public TokenResponse generateFromToken( metaStoreManager.loadEntity( CallContext.getCurrentContext().getPolarisCallContext(), 0L, - decodedToken.getPrincipalId(), + Objects.requireNonNull(decodedToken.getPrincipalId()), PolarisEntityType.PRINCIPAL); if (!principalLookup.isSuccess() || principalLookup.getEntity().getType() != PolarisEntityType.PRINCIPAL) { @@ -186,6 +187,6 @@ public boolean supportsRequestedTokenType(TokenType tokenType) { } private String scopes(String scope) { - return StringUtils.isNotBlank(scope) ? scope : BasePolarisAuthenticator.PRINCIPAL_ROLE_ALL; + return StringUtils.isNotBlank(scope) ? scope : DefaultAuthenticator.PRINCIPAL_ROLE_ALL; } } diff --git a/service/common/src/main/java/org/apache/polaris/service/auth/JWTRSAKeyPairFactory.java b/service/common/src/main/java/org/apache/polaris/service/auth/JWTRSAKeyPairFactory.java index deae809b1d..ee74caf466 100644 --- a/service/common/src/main/java/org/apache/polaris/service/auth/JWTRSAKeyPairFactory.java +++ b/service/common/src/main/java/org/apache/polaris/service/auth/JWTRSAKeyPairFactory.java @@ -25,34 +25,47 @@ import java.nio.file.Files; import java.nio.file.Path; import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import org.apache.polaris.core.context.RealmContext; import org.apache.polaris.core.persistence.MetaStoreManagerFactory; -import org.apache.polaris.service.auth.AuthenticationConfiguration.TokenBrokerConfiguration; -import org.apache.polaris.service.auth.AuthenticationConfiguration.TokenBrokerConfiguration.RSAKeyPairConfiguration; +import org.apache.polaris.core.persistence.PolarisMetaStoreManager; +import org.apache.polaris.service.auth.AuthenticationRealmConfiguration.TokenBrokerConfiguration.RSAKeyPairConfiguration; @ApplicationScoped @Identifier("rsa-key-pair") public class JWTRSAKeyPairFactory implements TokenBrokerFactory { private final MetaStoreManagerFactory metaStoreManagerFactory; - private final TokenBrokerConfiguration tokenBrokerConfiguration; - private final RSAKeyPairConfiguration keyPairConfiguration; + private final AuthenticationConfiguration authenticationConfiguration; + + private final ConcurrentMap tokenBrokers = new ConcurrentHashMap<>(); @Inject public JWTRSAKeyPairFactory( MetaStoreManagerFactory metaStoreManagerFactory, AuthenticationConfiguration authenticationConfiguration) { this.metaStoreManagerFactory = metaStoreManagerFactory; - this.tokenBrokerConfiguration = authenticationConfiguration.tokenBroker(); - this.keyPairConfiguration = - tokenBrokerConfiguration.rsaKeyPair().orElseGet(this::generateKeyPair); + this.authenticationConfiguration = authenticationConfiguration; } @Override public TokenBroker apply(RealmContext realmContext) { + return tokenBrokers.computeIfAbsent( + realmContext.getRealmIdentifier(), k -> createTokenBroker(realmContext)); + } + + private JWTRSAKeyPair createTokenBroker(RealmContext realmContext) { + AuthenticationRealmConfiguration config = authenticationConfiguration.forRealm(realmContext); + Duration maxTokenGeneration = config.tokenBroker().maxTokenGeneration(); + RSAKeyPairConfiguration keyPairConfiguration = + config.tokenBroker().rsaKeyPair().orElseGet(this::generateKeyPair); + PolarisMetaStoreManager metaStoreManager = + metaStoreManagerFactory.getOrCreateMetaStoreManager(realmContext); return new JWTRSAKeyPair( - metaStoreManagerFactory.getOrCreateMetaStoreManager(realmContext), - (int) tokenBrokerConfiguration.maxTokenGeneration().toSeconds(), + metaStoreManager, + (int) maxTokenGeneration.toSeconds(), keyPairConfiguration.publicKeyFile(), keyPairConfiguration.privateKeyFile()); } diff --git a/service/common/src/main/java/org/apache/polaris/service/auth/JWTSymmetricKeyFactory.java b/service/common/src/main/java/org/apache/polaris/service/auth/JWTSymmetricKeyFactory.java index d08754ef03..ed33800646 100644 --- a/service/common/src/main/java/org/apache/polaris/service/auth/JWTSymmetricKeyFactory.java +++ b/service/common/src/main/java/org/apache/polaris/service/auth/JWTSymmetricKeyFactory.java @@ -27,38 +27,49 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.function.Supplier; import org.apache.polaris.core.context.RealmContext; import org.apache.polaris.core.persistence.MetaStoreManagerFactory; -import org.apache.polaris.service.auth.AuthenticationConfiguration.TokenBrokerConfiguration.SymmetricKeyConfiguration; +import org.apache.polaris.service.auth.AuthenticationRealmConfiguration.TokenBrokerConfiguration.SymmetricKeyConfiguration; @ApplicationScoped @Identifier("symmetric-key") public class JWTSymmetricKeyFactory implements TokenBrokerFactory { private final MetaStoreManagerFactory metaStoreManagerFactory; - private final Duration maxTokenGeneration; - private final Supplier secretSupplier; + private final AuthenticationConfiguration authenticationConfiguration; + + private final ConcurrentMap tokenBrokers = + new ConcurrentHashMap<>(); @Inject public JWTSymmetricKeyFactory( MetaStoreManagerFactory metaStoreManagerFactory, AuthenticationConfiguration authenticationConfiguration) { this.metaStoreManagerFactory = metaStoreManagerFactory; - this.maxTokenGeneration = authenticationConfiguration.tokenBroker().maxTokenGeneration(); + this.authenticationConfiguration = authenticationConfiguration; + } + + @Override + public TokenBroker apply(RealmContext realmContext) { + return tokenBrokers.computeIfAbsent( + realmContext.getRealmIdentifier(), k -> createTokenBroker(realmContext)); + } + + private JWTSymmetricKeyBroker createTokenBroker(RealmContext realmContext) { + AuthenticationRealmConfiguration config = authenticationConfiguration.forRealm(realmContext); + Duration maxTokenGeneration = config.tokenBroker().maxTokenGeneration(); SymmetricKeyConfiguration symmetricKeyConfiguration = - authenticationConfiguration + config .tokenBroker() .symmetricKey() .orElseThrow(() -> new IllegalStateException("Symmetric key configuration is missing")); String secret = symmetricKeyConfiguration.secret().orElse(null); Path file = symmetricKeyConfiguration.file().orElse(null); checkState(secret != null || file != null, "Either file or secret must be set"); - this.secretSupplier = secret != null ? () -> secret : readSecretFromDisk(file); - } - - @Override - public TokenBroker apply(RealmContext realmContext) { + Supplier secretSupplier = secret != null ? () -> secret : readSecretFromDisk(file); return new JWTSymmetricKeyBroker( metaStoreManagerFactory.getOrCreateMetaStoreManager(realmContext), (int) maxTokenGeneration.toSeconds(), diff --git a/service/common/src/main/java/org/apache/polaris/service/auth/PrincipalAuthInfo.java b/service/common/src/main/java/org/apache/polaris/service/auth/PrincipalAuthInfo.java new file mode 100644 index 0000000000..ad9aa204a3 --- /dev/null +++ b/service/common/src/main/java/org/apache/polaris/service/auth/PrincipalAuthInfo.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.service.auth; + +import jakarta.annotation.Nullable; +import java.util.Set; + +/** + * Principal information extracted from authentication data (typically, an access token) by the + * configured authentication mechanism. Used to determine the principal id, name, and roles while + * authenticating a request. + * + * @see DefaultAuthenticator + */ +public interface PrincipalAuthInfo { + + /** The principal id, or null if unknown. Used for principal lookups by id. */ + @Nullable + Long getPrincipalId(); + + /** The principal name, or null if unknown. Used for principal lookups by name. */ + @Nullable + String getPrincipalName(); + + /** + * The principal roles present in the token. The special {@link + * DefaultAuthenticator#PRINCIPAL_ROLE_ALL} can be used to denote a request for all principal + * roles that the principal has access to. + */ + Set getPrincipalRoles(); +} diff --git a/service/common/src/test/java/org/apache/polaris/service/auth/BasePolarisAuthenticatorTest.java b/service/common/src/test/java/org/apache/polaris/service/auth/DefaultAuthenticatorTest.java similarity index 82% rename from service/common/src/test/java/org/apache/polaris/service/auth/BasePolarisAuthenticatorTest.java rename to service/common/src/test/java/org/apache/polaris/service/auth/DefaultAuthenticatorTest.java index ec9eda26a8..50008f8d50 100644 --- a/service/common/src/test/java/org/apache/polaris/service/auth/BasePolarisAuthenticatorTest.java +++ b/service/common/src/test/java/org/apache/polaris/service/auth/DefaultAuthenticatorTest.java @@ -20,11 +20,9 @@ import static org.mockito.Mockito.when; -import java.util.Optional; import org.apache.iceberg.exceptions.NotAuthorizedException; import org.apache.iceberg.exceptions.ServiceFailureException; import org.apache.polaris.core.PolarisCallContext; -import org.apache.polaris.core.auth.AuthenticatedPolarisPrincipal; import org.apache.polaris.core.context.CallContext; import org.apache.polaris.core.context.RealmContext; import org.apache.polaris.core.entity.PolarisEntityType; @@ -37,29 +35,24 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; -public class BasePolarisAuthenticatorTest { +public class DefaultAuthenticatorTest { - private BasePolarisAuthenticator authenticator; + private DefaultAuthenticator authenticator; private PolarisMetaStoreManager metaStoreManager; private PolarisCallContext polarisCallContext; - private CallContext callContext; @BeforeEach public void setUp() { RealmContext realmContext = () -> "test"; polarisCallContext = Mockito.mock(PolarisCallContext.class); - callContext = CallContext.of(realmContext, polarisCallContext); + CallContext callContext = CallContext.of(realmContext, polarisCallContext); metaStoreManager = Mockito.mock(PolarisMetaStoreManager.class); MetaStoreManagerFactory metaStoreManagerFactory = Mockito.mock(MetaStoreManagerFactory.class); when(metaStoreManagerFactory.getOrCreateMetaStoreManager(realmContext)) .thenReturn(metaStoreManager); - authenticator = - new BasePolarisAuthenticator(metaStoreManagerFactory, callContext) { - @Override - public Optional authenticate(String credentials) { - return Optional.empty(); - } - }; + authenticator = new DefaultAuthenticator(); + authenticator.metaStoreManagerFactory = metaStoreManagerFactory; + authenticator.callContext = callContext; } @Test @@ -71,7 +64,7 @@ public void testFetchPrincipalThrowsServiceExceptionOnMetastoreException() { polarisCallContext, 0L, principalId, PolarisEntityType.PRINCIPAL)) .thenThrow(new RuntimeException("Metastore exception")); - Assertions.assertThatThrownBy(() -> authenticator.getPrincipal(token)) + Assertions.assertThatThrownBy(() -> authenticator.authenticate(token)) .isInstanceOf(ServiceFailureException.class) .hasMessage("Unable to fetch principal entity"); } @@ -86,7 +79,7 @@ public void testFetchPrincipalThrowsNotAuthorizedWhenNotFound() { polarisCallContext, 0L, principalId, PolarisEntityType.PRINCIPAL)) .thenReturn(new EntityResult(BaseResult.ReturnStatus.ENTITY_NOT_FOUND, "")); - Assertions.assertThatThrownBy(() -> authenticator.getPrincipal(token)) + Assertions.assertThatThrownBy(() -> authenticator.authenticate(token)) .isInstanceOf(NotAuthorizedException.class) .hasMessage("Unable to authenticate"); } diff --git a/service/common/src/test/java/org/apache/polaris/service/auth/DefaultOAuth2ApiServiceTest.java b/service/common/src/test/java/org/apache/polaris/service/auth/DefaultOAuth2ApiServiceTest.java index 03eb2aa206..8c8772a61b 100644 --- a/service/common/src/test/java/org/apache/polaris/service/auth/DefaultOAuth2ApiServiceTest.java +++ b/service/common/src/test/java/org/apache/polaris/service/auth/DefaultOAuth2ApiServiceTest.java @@ -48,10 +48,8 @@ void setUp() { @Test public void testNoSupportGrantType() { - TokenBrokerFactory tokenBrokerFactory = Mockito.mock(); RealmContext realmContext = () -> "realm"; TokenBroker tokenBroker = Mockito.mock(); - when(tokenBrokerFactory.apply(realmContext)).thenReturn(tokenBroker); when(tokenBroker.supportsGrantType(CLIENT_CREDENTIALS)).thenReturn(false); when(tokenBroker.supportsRequestedTokenType(TokenType.ACCESS_TOKEN)).thenReturn(true); when(tokenBroker.generateFromClientSecrets( @@ -70,7 +68,7 @@ public void testNoSupportGrantType() { .grantType(CLIENT_CREDENTIALS) .requestedTokenType(TokenType.ACCESS_TOKEN) .realmContext(realmContext) - .invoke(new DefaultOAuth2ApiService(tokenBrokerFactory, callContext)); + .invoke(new DefaultOAuth2ApiService(tokenBroker, callContext)); Assertions.assertThat(response.getEntity()) .isInstanceOf(OAuthTokenErrorResponse.class) .asInstanceOf(InstanceOfAssertFactories.type(OAuthTokenErrorResponse.class)) @@ -81,10 +79,8 @@ public void testNoSupportGrantType() { @Test public void testNoSupportRequestedTokenType() { - TokenBrokerFactory tokenBrokerFactory = Mockito.mock(); RealmContext realmContext = () -> "realm"; TokenBroker tokenBroker = Mockito.mock(); - when(tokenBrokerFactory.apply(realmContext)).thenReturn(tokenBroker); when(tokenBroker.supportsGrantType(CLIENT_CREDENTIALS)).thenReturn(true); when(tokenBroker.supportsRequestedTokenType(TokenType.ACCESS_TOKEN)).thenReturn(false); when(tokenBroker.generateFromClientSecrets( @@ -103,7 +99,7 @@ public void testNoSupportRequestedTokenType() { .grantType(CLIENT_CREDENTIALS) .requestedTokenType(TokenType.ACCESS_TOKEN) .realmContext(realmContext) - .invoke(new DefaultOAuth2ApiService(tokenBrokerFactory, callContext)); + .invoke(new DefaultOAuth2ApiService(tokenBroker, callContext)); Assertions.assertThat(response.getEntity()) .isInstanceOf(OAuthTokenErrorResponse.class) .asInstanceOf(InstanceOfAssertFactories.type(OAuthTokenErrorResponse.class)) @@ -114,10 +110,8 @@ public void testNoSupportRequestedTokenType() { @Test public void testSupportClientIdNoSecret() { - TokenBrokerFactory tokenBrokerFactory = Mockito.mock(); RealmContext realmContext = () -> "realm"; TokenBroker tokenBroker = Mockito.mock(); - when(tokenBrokerFactory.apply(realmContext)).thenReturn(tokenBroker); when(tokenBroker.supportsGrantType(CLIENT_CREDENTIALS)).thenReturn(true); when(tokenBroker.supportsRequestedTokenType(TokenType.ACCESS_TOKEN)).thenReturn(true); when(tokenBroker.generateFromClientSecrets( @@ -135,7 +129,7 @@ public void testSupportClientIdNoSecret() { .grantType(CLIENT_CREDENTIALS) .requestedTokenType(TokenType.ACCESS_TOKEN) .realmContext(realmContext) - .invoke(new DefaultOAuth2ApiService(tokenBrokerFactory, callContext)); + .invoke(new DefaultOAuth2ApiService(tokenBroker, callContext)); Assertions.assertThat(response.getEntity()) .isInstanceOf(OAuthTokenResponse.class) .asInstanceOf(InstanceOfAssertFactories.type(OAuthTokenResponse.class)) @@ -144,10 +138,8 @@ public void testSupportClientIdNoSecret() { @Test public void testSupportClientIdAndSecret() { - TokenBrokerFactory tokenBrokerFactory = Mockito.mock(); RealmContext realmContext = () -> "realm"; TokenBroker tokenBroker = Mockito.mock(); - when(tokenBrokerFactory.apply(realmContext)).thenReturn(tokenBroker); when(tokenBroker.supportsGrantType(CLIENT_CREDENTIALS)).thenReturn(true); when(tokenBroker.supportsRequestedTokenType(TokenType.ACCESS_TOKEN)).thenReturn(true); when(tokenBroker.generateFromClientSecrets( @@ -166,7 +158,7 @@ public void testSupportClientIdAndSecret() { .grantType(CLIENT_CREDENTIALS) .requestedTokenType(TokenType.ACCESS_TOKEN) .realmContext(realmContext) - .invoke(new DefaultOAuth2ApiService(tokenBrokerFactory, callContext)); + .invoke(new DefaultOAuth2ApiService(tokenBroker, callContext)); Assertions.assertThat(response.getEntity()) .isInstanceOf(OAuthTokenResponse.class) .asInstanceOf(InstanceOfAssertFactories.type(OAuthTokenResponse.class)) @@ -175,10 +167,8 @@ public void testSupportClientIdAndSecret() { @Test public void testReadClientCredentialsFromAuthHeader() { - TokenBrokerFactory tokenBrokerFactory = Mockito.mock(); RealmContext realmContext = () -> "realm"; TokenBroker tokenBroker = Mockito.mock(); - when(tokenBrokerFactory.apply(realmContext)).thenReturn(tokenBroker); when(tokenBroker.supportsGrantType(TokenRequestValidator.TOKEN_EXCHANGE)).thenReturn(true); when(tokenBroker.supportsRequestedTokenType(TokenType.ACCESS_TOKEN)).thenReturn(true); when(tokenBroker.generateFromClientSecrets( @@ -199,7 +189,7 @@ public void testReadClientCredentialsFromAuthHeader() { .grantType(TokenRequestValidator.TOKEN_EXCHANGE) .requestedTokenType(TokenType.ACCESS_TOKEN) .realmContext(realmContext) - .invoke(new DefaultOAuth2ApiService(tokenBrokerFactory, callContext)); + .invoke(new DefaultOAuth2ApiService(tokenBroker, callContext)); Assertions.assertThat(response.getEntity()) .isInstanceOf(OAuthTokenResponse.class) .asInstanceOf(InstanceOfAssertFactories.type(OAuthTokenResponse.class)) @@ -208,10 +198,8 @@ public void testReadClientCredentialsFromAuthHeader() { @Test public void testAuthHeaderRequiresValidCredentialPair() { - TokenBrokerFactory tokenBrokerFactory = Mockito.mock(); RealmContext realmContext = () -> "realm"; TokenBroker tokenBroker = Mockito.mock(); - when(tokenBrokerFactory.apply(realmContext)).thenReturn(tokenBroker); when(tokenBroker.supportsGrantType(TokenRequestValidator.TOKEN_EXCHANGE)).thenReturn(true); when(tokenBroker.supportsRequestedTokenType(TokenType.ACCESS_TOKEN)).thenReturn(true); when(tokenBroker.generateFromClientSecrets( @@ -232,7 +220,7 @@ public void testAuthHeaderRequiresValidCredentialPair() { .grantType(TokenRequestValidator.TOKEN_EXCHANGE) .requestedTokenType(TokenType.ACCESS_TOKEN) .realmContext(realmContext) - .invoke(new DefaultOAuth2ApiService(tokenBrokerFactory, callContext)); + .invoke(new DefaultOAuth2ApiService(tokenBroker, callContext)); Assertions.assertThat(response.getEntity()) .isInstanceOf(OAuthTokenErrorResponse.class) .asInstanceOf(InstanceOfAssertFactories.type(OAuthTokenErrorResponse.class)) @@ -243,10 +231,8 @@ public void testAuthHeaderRequiresValidCredentialPair() { @Test public void testReadClientSecretFromAuthHeader() { - TokenBrokerFactory tokenBrokerFactory = Mockito.mock(); RealmContext realmContext = () -> "realm"; TokenBroker tokenBroker = Mockito.mock(); - when(tokenBrokerFactory.apply(realmContext)).thenReturn(tokenBroker); when(tokenBroker.supportsGrantType(TokenRequestValidator.TOKEN_EXCHANGE)).thenReturn(true); when(tokenBroker.supportsRequestedTokenType(TokenType.ACCESS_TOKEN)).thenReturn(true); @@ -270,7 +256,7 @@ public void testReadClientSecretFromAuthHeader() { .grantType(TokenRequestValidator.TOKEN_EXCHANGE) .requestedTokenType(TokenType.ACCESS_TOKEN) .realmContext(realmContext) - .invoke(new DefaultOAuth2ApiService(tokenBrokerFactory, callContext)); + .invoke(new DefaultOAuth2ApiService(tokenBroker, callContext)); Assertions.assertThat(response.getEntity()) .isInstanceOf(OAuthTokenResponse.class) .asInstanceOf(InstanceOfAssertFactories.type(OAuthTokenResponse.class))