This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new e7b6b0ca7b [ #9767]feat(oauth): Support Regex to have user mapping
from principal (#9788)
e7b6b0ca7b is described below
commit e7b6b0ca7b05902237e41fd861772e9eb20dc8d2
Author: Bharath Krishna <[email protected]>
AuthorDate: Thu Jan 29 22:38:19 2026 -0800
[ #9767]feat(oauth): Support Regex to have user mapping from principal
(#9788)
### What changes were proposed in this pull request?
- Core framework: PrincipalMapper interface, RegexPrincipalMapper
implementation, PrincipalMapperFactory
- Kerberos utility: KerberosPrincipalParser for parsing
primary[/instance][@REALM] format
- Authenticator integration: Modified KerberosAuthenticator,
JwksTokenValidator, and StaticSignKeyValidator to apply principal
mapping
- Configuration: Added 4 new properties (2 for OAuth, 2 for Kerberos)
supporting regex patterns or custom mapper classes
- Documentation: Added principal mapping section with OAuth/Kerberos
examples and custom mapper implementation guide
### Why are the changes needed?
Organizations need to transform authenticated principals into different
user identities for authorization:
OAuth: Extract username from email ([email protected] → user)
Kerberos: Extract primary from service principals (HTTP/server@REALM →
HTTP)
Multi-realm: Route by realm ([email protected] → dev_user)
Fixes #9767
### Does this PR introduce _any_ user-facing change?
New configuration properties (all optional, defaults maintain backward
compatibility):
```
gravitino.authenticator.oauth.principalMapperType (default: regex)
gravitino.authenticator.oauth.principalMapperPattern (default: ^(.*)$)
gravitino.authenticator.kerberos.principalMapperType (default: regex)
gravitino.authenticator.kerberos.principalMapperPattern (default: ([^@]+).*)
```
Backward compatible: Because the default values preserve existing
behavior.
### How was this patch tested?
- New test cases across 3 test classes: TestRegexPrincipalMapper,
TestKerberosPrincipalParser, TestPrincipalMapperFactory
- Existing tests: All OAuth and Kerberos authenticator tests pass
---
.../apache/gravitino/auth/KerberosPrincipal.java | 162 ++++++++++++++
.../gravitino/auth/KerberosPrincipalMapper.java | 86 ++++++++
.../org/apache/gravitino/auth/PrincipalMapper.java | 40 ++++
.../gravitino/auth/PrincipalMapperFactory.java | 56 +++++
.../gravitino/auth/RegexPrincipalMapper.java | 100 +++++++++
.../gravitino/auth/TestKerberosPrincipal.java | 122 +++++++++++
.../auth/TestKerberosPrincipalMapper.java | 174 ++++++++++++++++
.../gravitino/auth/TestPrincipalMapperFactory.java | 83 ++++++++
.../gravitino/auth/TestRegexPrincipalMapper.java | 140 +++++++++++++
docs/security/how-to-authenticate.md | 79 +++++++
.../server/authentication/JwksTokenValidator.java | 12 +-
.../authentication/KerberosAuthenticator.java | 35 ++--
.../server/authentication/KerberosConfig.java | 22 ++
.../server/authentication/OAuthConfig.java | 22 ++
.../authentication/StaticSignKeyValidator.java | 35 +++-
.../authentication/TestJwksTokenValidator.java | 159 ++++++++++++++
.../authentication/TestStaticSignKeyValidator.java | 232 +++++++++++++++++++++
17 files changed, 1536 insertions(+), 23 deletions(-)
diff --git
a/core/src/main/java/org/apache/gravitino/auth/KerberosPrincipal.java
b/core/src/main/java/org/apache/gravitino/auth/KerberosPrincipal.java
new file mode 100644
index 0000000000..c4c1099789
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/auth/KerberosPrincipal.java
@@ -0,0 +1,162 @@
+/*
+ * 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.gravitino.auth;
+
+import java.security.Principal;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Represents a Kerberos principal with its structured components.
+ *
+ * <p>A Kerberos principal has the format: {@code primary[/instance][@REALM]}
where:
+ *
+ * <ul>
+ * <li><b>primary</b> (required): The primary component, typically the user
or service name
+ * <li><b>instance</b> (optional): The secondary component, often a hostname
for service
+ * principals
+ * <li><b>realm</b> (optional): The Kerberos realm (domain), typically
uppercase
+ * </ul>
+ *
+ * <p>Examples:
+ *
+ * <ul>
+ * <li>{@code john} - primary only
+ * <li>{@code [email protected]} - primary with realm
+ * <li>{@code HTTP/[email protected]} - service principal with
instance and realm
+ * </ul>
+ */
+public class KerberosPrincipal implements Principal {
+
+ private final String username;
+ private final String instance;
+ private final String realm;
+ private final String fullPrincipal;
+
+ /**
+ * Creates a new Kerberos principal.
+ *
+ * @param username the primary username component (required, cannot be null
or empty)
+ * @param instance the instance component (optional, can be null)
+ * @param realm the realm component (optional, can be null)
+ * @throws IllegalArgumentException if username is null or empty
+ */
+ public KerberosPrincipal(String username, String instance, String realm) {
+ if (username == null || username.isEmpty()) {
+ throw new IllegalArgumentException("Username cannot be null or empty");
+ }
+ this.username = username;
+ this.instance = instance;
+ this.realm = realm;
+ this.fullPrincipal = buildFullPrincipal(username, instance, realm);
+ }
+
+ /**
+ * Gets the primary component (username). This is the name used for
authentication.
+ *
+ * @return the username (never null or empty)
+ */
+ @Override
+ public String getName() {
+ return username;
+ }
+
+ /**
+ * Gets the instance (secondary component) of this principal, if present.
+ *
+ * @return an Optional containing the instance, or empty if no instance
+ */
+ public Optional<String> getInstance() {
+ return Optional.ofNullable(instance);
+ }
+
+ /**
+ * Gets the realm of this principal, if present.
+ *
+ * @return an Optional containing the realm, or empty if no realm
+ */
+ public Optional<String> getRealm() {
+ return Optional.ofNullable(realm);
+ }
+
+ /**
+ * Gets the full principal string in Kerberos format.
+ *
+ * @return the full principal string (e.g., "user/instance@REALM")
+ */
+ public String getFullPrincipal() {
+ return fullPrincipal;
+ }
+
+ /**
+ * Gets the primary component with instance (if present), without realm.
+ *
+ * @return primary/instance (e.g., "HTTP/server") or just primary if no
instance
+ */
+ public String getPrimaryWithInstance() {
+ if (instance != null && !instance.isEmpty()) {
+ return username + "/" + instance;
+ }
+ return username;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof KerberosPrincipal)) {
+ return false;
+ }
+ KerberosPrincipal that = (KerberosPrincipal) o;
+ return Objects.equals(username, that.username)
+ && Objects.equals(instance, that.instance)
+ && Objects.equals(realm, that.realm);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(username, instance, realm);
+ }
+
+ @Override
+ public String toString() {
+ return "KerberosPrincipal{" + "fullPrincipal='" + fullPrincipal + '\'' +
'}';
+ }
+
+ /**
+ * Builds the full principal string from components.
+ *
+ * @param username the username component
+ * @param instance the instance component (optional)
+ * @param realm the realm component (optional)
+ * @return the full principal string
+ */
+ private static String buildFullPrincipal(String username, String instance,
String realm) {
+ StringBuilder sb = new StringBuilder(username);
+ if (instance != null && !instance.isEmpty()) {
+ sb.append('/').append(instance);
+ }
+ if (realm != null && !realm.isEmpty()) {
+ sb.append('@').append(realm);
+ }
+ return sb.toString();
+ }
+}
diff --git
a/core/src/main/java/org/apache/gravitino/auth/KerberosPrincipalMapper.java
b/core/src/main/java/org/apache/gravitino/auth/KerberosPrincipalMapper.java
new file mode 100644
index 0000000000..32748a28c9
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/auth/KerberosPrincipalMapper.java
@@ -0,0 +1,86 @@
+/*
+ * 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.gravitino.auth;
+
+import java.security.Principal;
+
+/**
+ * Kerberos principal mapper that parses Kerberos principal strings into
{@link KerberosPrincipal}
+ * objects.
+ *
+ * <p>This mapper is thread-safe and can be shared across multiple
authentication requests.
+ */
+public class KerberosPrincipalMapper implements PrincipalMapper {
+
+ /**
+ * Maps a Kerberos principal string to a KerberosPrincipal object.
+ *
+ * @param principal the Kerberos principal string to parse (e.g.,
"user/instance@REALM")
+ * @return a KerberosPrincipal object containing the parsed components
+ * @throws IllegalArgumentException if the principal string is null, empty,
or has invalid format
+ */
+ @Override
+ public Principal map(String principal) {
+ if (principal == null || principal.isEmpty()) {
+ throw new IllegalArgumentException("Principal string cannot be null or
empty");
+ }
+
+ // Kerberos principal format: user[/instance][@REALM]
+ // Find positions of '/' and '@'
+ int slashIndex = principal.indexOf('/');
+ int atIndex = principal.indexOf('@');
+
+ String username;
+ String instance = null;
+ String realm = null;
+
+ // Extract username (up to '/' or '@', whichever comes first)
+ if (slashIndex >= 0 && atIndex >= 0) {
+ // Both '/' and '@' present
+ if (slashIndex < atIndex) {
+ // Format: user/instance@REALM
+ username = principal.substring(0, slashIndex);
+ instance = principal.substring(slashIndex + 1, atIndex);
+ realm = principal.substring(atIndex + 1);
+ } else {
+ // Invalid format: '@' before '/'
+ throw new IllegalArgumentException("Invalid Kerberos principal format:
" + principal);
+ }
+ } else if (slashIndex >= 0) {
+ // Only '/' present: user/instance
+ username = principal.substring(0, slashIndex);
+ instance = principal.substring(slashIndex + 1);
+ } else if (atIndex >= 0) {
+ // Only '@' present: user@REALM
+ username = principal.substring(0, atIndex);
+ realm = principal.substring(atIndex + 1);
+ } else {
+ // No delimiters: just username
+ username = principal;
+ }
+
+ // Validate username is not empty
+ if (username.isEmpty()) {
+ throw new IllegalArgumentException("Username cannot be empty in
principal: " + principal);
+ }
+
+ return new KerberosPrincipal(username, instance, realm);
+ }
+}
diff --git a/core/src/main/java/org/apache/gravitino/auth/PrincipalMapper.java
b/core/src/main/java/org/apache/gravitino/auth/PrincipalMapper.java
new file mode 100644
index 0000000000..f4af0e6c3d
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/auth/PrincipalMapper.java
@@ -0,0 +1,40 @@
+/*
+ * 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.gravitino.auth;
+
+import java.security.Principal;
+
+/**
+ * Interface for mapping authenticated principals to user identities.
+ *
+ * <p>Implementations should be thread-safe as they may be shared across
multiple authentication
+ * requests.
+ */
+public interface PrincipalMapper {
+
+ /**
+ * Maps a principal string to a Principal object.
+ *
+ * @param principal the principal string to map (e.g., "[email protected]",
"user/instance@REALM")
+ * @return a Principal object containing the mapped username
+ * @throws IllegalArgumentException if the principal cannot be mapped
+ */
+ Principal map(String principal);
+}
diff --git
a/core/src/main/java/org/apache/gravitino/auth/PrincipalMapperFactory.java
b/core/src/main/java/org/apache/gravitino/auth/PrincipalMapperFactory.java
new file mode 100644
index 0000000000..01439fd020
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/auth/PrincipalMapperFactory.java
@@ -0,0 +1,56 @@
+/*
+ * 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.gravitino.auth;
+
+/** Factory class for creating {@link PrincipalMapper} instances. */
+public class PrincipalMapperFactory {
+
+ private PrincipalMapperFactory() {}
+
+ /**
+ * Creates a principal mapper based on the type and configuration.
+ *
+ * @param mapperType "regex" for built-in regex mapper, or fully qualified
class name for custom
+ * mapper
+ * @param regexPattern the regex pattern (only used when mapperType is
"regex")
+ * @return a configured PrincipalMapper instance
+ * @throws IllegalArgumentException if the mapper cannot be created
+ */
+ public static PrincipalMapper create(String mapperType, String regexPattern)
{
+ if ("regex".equalsIgnoreCase(mapperType)) {
+ return new RegexPrincipalMapper(regexPattern);
+ }
+
+ // Load custom mapper class
+ try {
+ Class<?> clazz = Class.forName(mapperType);
+ if (!PrincipalMapper.class.isAssignableFrom(clazz)) {
+ throw new IllegalArgumentException(
+ "Class " + mapperType + " does not implement PrincipalMapper");
+ }
+ return (PrincipalMapper) clazz.getDeclaredConstructor().newInstance();
+ } catch (ClassNotFoundException e) {
+ throw new IllegalArgumentException("Unknown principal mapper type: " +
mapperType, e);
+ } catch (Exception e) {
+ throw new IllegalArgumentException(
+ "Failed to instantiate principal mapper: " + mapperType, e);
+ }
+ }
+}
diff --git
a/core/src/main/java/org/apache/gravitino/auth/RegexPrincipalMapper.java
b/core/src/main/java/org/apache/gravitino/auth/RegexPrincipalMapper.java
new file mode 100644
index 0000000000..a72d4e39ff
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/auth/RegexPrincipalMapper.java
@@ -0,0 +1,100 @@
+/*
+ * 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.gravitino.auth;
+
+import java.security.Principal;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.gravitino.UserPrincipal;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Regex-based principal mapper that extracts username using regex patterns
with capturing groups.
+ *
+ * <p>This implementation is thread-safe as Pattern.matcher() creates
thread-local Matcher
+ * instances.
+ */
+public class RegexPrincipalMapper implements PrincipalMapper {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(RegexPrincipalMapper.class);
+
+ private final Pattern pattern;
+
+ /**
+ * Creates a new regex principal mapper.
+ *
+ * @param patternStr the regex pattern with a capturing group (required)
+ * @throws IllegalArgumentException if the pattern string has invalid regex
syntax
+ */
+ public RegexPrincipalMapper(String patternStr) {
+ if (patternStr == null || patternStr.isEmpty()) {
+ throw new IllegalArgumentException("Pattern string cannot be null or
empty");
+ }
+ this.pattern = Pattern.compile(patternStr);
+ LOG.info("Initialized RegexPrincipalMapper with pattern: {}", patternStr);
+ }
+
+ /**
+ * Maps a principal string to a Principal using the configured regex pattern.
+ *
+ * @param principal the principal string to map
+ * @return a Principal containing the extracted username from the first
capturing group, or the
+ * original principal if no match
+ * @throws IllegalArgumentException if pattern matching fails
+ */
+ @Override
+ public Principal map(String principal) {
+ if (principal == null) {
+ return null;
+ }
+
+ try {
+ Matcher matcher = pattern.matcher(principal);
+
+ // If pattern matches and has at least one capturing group, return the
first group
+ if (matcher.find() && matcher.groupCount() >= 1) {
+ String extracted = matcher.group(1);
+ // Return extracted value if it's not null and not empty, otherwise
original principal
+ String username = (extracted != null && !extracted.isEmpty()) ?
extracted : principal;
+ return new UserPrincipal(username);
+ }
+
+ // If pattern doesn't match or has no capturing groups, return original
principal
+ return new UserPrincipal(principal);
+
+ } catch (Exception e) {
+ String message =
+ String.format(
+ "Error applying regex pattern '%s' to principal '%s'",
pattern.pattern(), principal);
+ LOG.error("{}: {}", message, e.getMessage());
+ throw new IllegalArgumentException(message, e);
+ }
+ }
+
+ /**
+ * Gets the configured regex pattern string.
+ *
+ * @return the regex pattern string
+ */
+ public String getPatternString() {
+ return pattern.pattern();
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/auth/TestKerberosPrincipal.java
b/core/src/test/java/org/apache/gravitino/auth/TestKerberosPrincipal.java
new file mode 100644
index 0000000000..99fa0add2f
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/auth/TestKerberosPrincipal.java
@@ -0,0 +1,122 @@
+/*
+ * 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.gravitino.auth;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+public class TestKerberosPrincipal {
+
+ @Test
+ public void testBasicConstruction() {
+ KerberosPrincipal principal = new KerberosPrincipal("john", "admin",
"EXAMPLE.COM");
+
+ assertEquals("john", principal.getName());
+ assertEquals("john/admin", principal.getPrimaryWithInstance());
+ assertTrue(principal.getInstance().isPresent());
+ assertEquals("admin", principal.getInstance().get());
+ assertTrue(principal.getRealm().isPresent());
+ assertEquals("EXAMPLE.COM", principal.getRealm().get());
+ assertEquals("john/[email protected]", principal.getFullPrincipal());
+ }
+
+ @Test
+ public void testPrincipalWithoutInstance() {
+ KerberosPrincipal principal = new KerberosPrincipal("john", null,
"EXAMPLE.COM");
+
+ assertEquals("john", principal.getName());
+ assertEquals("john", principal.getPrimaryWithInstance());
+ assertFalse(principal.getInstance().isPresent());
+ assertTrue(principal.getRealm().isPresent());
+ assertEquals("EXAMPLE.COM", principal.getRealm().get());
+ assertEquals("[email protected]", principal.getFullPrincipal());
+ }
+
+ @Test
+ public void testPrincipalWithoutRealm() {
+ KerberosPrincipal principal = new KerberosPrincipal("john", "admin", null);
+
+ assertEquals("john", principal.getName());
+ assertEquals("john/admin", principal.getPrimaryWithInstance());
+ assertTrue(principal.getInstance().isPresent());
+ assertEquals("admin", principal.getInstance().get());
+ assertFalse(principal.getRealm().isPresent());
+ assertEquals("john/admin", principal.getFullPrincipal());
+ }
+
+ @Test
+ public void testMinimalPrincipal() {
+ KerberosPrincipal principal = new KerberosPrincipal("john", null, null);
+
+ assertEquals("john", principal.getName());
+ assertEquals("john", principal.getPrimaryWithInstance());
+ assertFalse(principal.getInstance().isPresent());
+ assertFalse(principal.getRealm().isPresent());
+ assertEquals("john", principal.getFullPrincipal());
+ }
+
+ @Test
+ public void testNullUsernameThrowsException() {
+ assertThrows(IllegalArgumentException.class, () -> new
KerberosPrincipal(null, null, null));
+ }
+
+ @Test
+ public void testEmptyUsernameThrowsException() {
+ assertThrows(IllegalArgumentException.class, () -> new
KerberosPrincipal("", null, null));
+ }
+
+ @Test
+ public void testEquality() {
+ KerberosPrincipal principal1 = new KerberosPrincipal("john", "admin",
"EXAMPLE.COM");
+ KerberosPrincipal principal2 = new KerberosPrincipal("john", "admin",
"EXAMPLE.COM");
+ KerberosPrincipal principal3 = new KerberosPrincipal("john", "other",
"EXAMPLE.COM");
+
+ assertEquals(principal1, principal2);
+ assertEquals(principal1.hashCode(), principal2.hashCode());
+ assertFalse(principal1.equals(principal3));
+ }
+
+ @Test
+ public void testToString() {
+ KerberosPrincipal principal =
+ new KerberosPrincipal("HTTP", "server.example.com", "EXAMPLE.COM");
+ String toString = principal.toString();
+
+ assertNotNull(toString);
+ assertTrue(toString.contains("HTTP/[email protected]"));
+ }
+
+ @Test
+ public void testServicePrincipal() {
+ KerberosPrincipal principal =
+ new KerberosPrincipal("HTTP", "server.example.com", "EXAMPLE.COM");
+
+ assertEquals("HTTP", principal.getName());
+ assertEquals("HTTP/server.example.com",
principal.getPrimaryWithInstance());
+ assertEquals("server.example.com", principal.getInstance().get());
+ assertEquals("EXAMPLE.COM", principal.getRealm().get());
+ assertEquals("HTTP/[email protected]",
principal.getFullPrincipal());
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/auth/TestKerberosPrincipalMapper.java
b/core/src/test/java/org/apache/gravitino/auth/TestKerberosPrincipalMapper.java
new file mode 100644
index 0000000000..334ec53f9c
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/auth/TestKerberosPrincipalMapper.java
@@ -0,0 +1,174 @@
+/*
+ * 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.gravitino.auth;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class TestKerberosPrincipalMapper {
+
+ private KerberosPrincipalMapper mapper;
+
+ @BeforeEach
+ public void setUp() {
+ mapper = new KerberosPrincipalMapper();
+ }
+
+ @Test
+ public void testUsernameOnly() {
+ KerberosPrincipal principal = (KerberosPrincipal) mapper.map("john");
+
+ assertEquals("john", principal.getName());
+ assertEquals("john", principal.getPrimaryWithInstance());
+ assertFalse(principal.getInstance().isPresent());
+ assertFalse(principal.getRealm().isPresent());
+ assertEquals("john", principal.getFullPrincipal());
+ }
+
+ @Test
+ public void testUsernameWithRealm() {
+ KerberosPrincipal principal = (KerberosPrincipal)
mapper.map("[email protected]");
+
+ assertEquals("john", principal.getName());
+ assertEquals("john", principal.getPrimaryWithInstance());
+ assertFalse(principal.getInstance().isPresent());
+ assertTrue(principal.getRealm().isPresent());
+ assertEquals("EXAMPLE.COM", principal.getRealm().get());
+ assertEquals("[email protected]", principal.getFullPrincipal());
+ }
+
+ @Test
+ public void testServicePrincipalWithInstanceAndRealm() {
+ KerberosPrincipal principal =
+ (KerberosPrincipal) mapper.map("HTTP/[email protected]");
+
+ assertEquals("HTTP", principal.getName());
+ assertEquals("HTTP/server.example.com",
principal.getPrimaryWithInstance());
+ assertTrue(principal.getInstance().isPresent());
+ assertEquals("server.example.com", principal.getInstance().get());
+ assertTrue(principal.getRealm().isPresent());
+ assertEquals("EXAMPLE.COM", principal.getRealm().get());
+ assertEquals("HTTP/[email protected]",
principal.getFullPrincipal());
+ }
+
+ @Test
+ public void testUsernameWithInstance() {
+ KerberosPrincipal principal = (KerberosPrincipal) mapper.map("user/admin");
+
+ assertEquals("user", principal.getName());
+ assertEquals("user/admin", principal.getPrimaryWithInstance());
+ assertTrue(principal.getInstance().isPresent());
+ assertEquals("admin", principal.getInstance().get());
+ assertFalse(principal.getRealm().isPresent());
+ assertEquals("user/admin", principal.getFullPrincipal());
+ }
+
+ @Test
+ public void testNullPrincipal() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> {
+ mapper.map(null);
+ });
+ }
+
+ @Test
+ public void testEmptyPrincipal() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> {
+ mapper.map("");
+ });
+ }
+
+ @Test
+ public void testEmptyUsername() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> {
+ mapper.map("@EXAMPLE.COM");
+ });
+ }
+
+ @Test
+ public void testInvalidFormatRealmBeforeSlash() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> {
+ mapper.map("[email protected]/instance");
+ });
+ }
+
+ @Test
+ public void testConstructorWithNullUsername() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> {
+ new KerberosPrincipal(null, null, null);
+ });
+ }
+
+ @Test
+ public void testConstructorWithEmptyUsername() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> {
+ new KerberosPrincipal("", null, null);
+ });
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ KerberosPrincipal principal1 = new KerberosPrincipal("john", "admin",
"EXAMPLE.COM");
+ KerberosPrincipal principal2 = new KerberosPrincipal("john", "admin",
"EXAMPLE.COM");
+ KerberosPrincipal principal3 = new KerberosPrincipal("john", null,
"EXAMPLE.COM");
+
+ assertEquals(principal1, principal2);
+ assertEquals(principal1.hashCode(), principal2.hashCode());
+ assertFalse(principal1.equals(principal3));
+ }
+
+ @Test
+ public void testToString() {
+ KerberosPrincipal principal =
+ (KerberosPrincipal) mapper.map("HTTP/[email protected]");
+ String toString = principal.toString();
+
+ assertNotNull(toString);
+ assertTrue(toString.contains("HTTP/[email protected]"));
+ }
+
+ @Test
+ public void testConstructorDirectly() {
+ KerberosPrincipal principal = new KerberosPrincipal("john", "admin",
"EXAMPLE.COM");
+
+ assertEquals("john", principal.getName());
+ assertEquals("john/admin", principal.getPrimaryWithInstance());
+ assertEquals("admin", principal.getInstance().get());
+ assertEquals("EXAMPLE.COM", principal.getRealm().get());
+ assertEquals("john/[email protected]", principal.getFullPrincipal());
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/auth/TestPrincipalMapperFactory.java
b/core/src/test/java/org/apache/gravitino/auth/TestPrincipalMapperFactory.java
new file mode 100644
index 0000000000..117e6167a8
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/auth/TestPrincipalMapperFactory.java
@@ -0,0 +1,83 @@
+/*
+ * 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.gravitino.auth;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.security.Principal;
+import org.apache.gravitino.UserPrincipal;
+import org.junit.jupiter.api.Test;
+
+public class TestPrincipalMapperFactory {
+
+ @Test
+ public void testCreateRegexMapper() {
+ PrincipalMapper mapper = PrincipalMapperFactory.create("regex",
"([^@]+)@.*");
+
+ assertNotNull(mapper);
+ assertTrue(mapper instanceof RegexPrincipalMapper);
+
+ Principal principal = mapper.map("[email protected]");
+ assertEquals("user", principal.getName());
+ }
+
+ @Test
+ public void testCreateRegexMapperWithDefaultPattern() {
+ PrincipalMapper mapper = PrincipalMapperFactory.create("regex", "^(.*)$");
+
+ assertNotNull(mapper);
+ assertTrue(mapper instanceof RegexPrincipalMapper);
+
+ Principal principal = mapper.map("[email protected]");
+ assertTrue(principal instanceof UserPrincipal);
+ assertEquals("[email protected]", principal.getName());
+ }
+
+ @Test
+ public void testCreateCustomMapperWithInvalidClass() {
+ IllegalArgumentException exception =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> {
+ PrincipalMapperFactory.create("unknown.InvalidClass", null);
+ });
+ assertTrue(exception.getMessage().contains("Unknown principal mapper
type"));
+ }
+
+ public static class TestCustomMapper implements PrincipalMapper {
+ @Override
+ public Principal map(String principal) {
+ return new UserPrincipal("custom:" + principal);
+ }
+ }
+
+ @Test
+ public void testCreateCustomMapperWithInlineClass() {
+ String className = TestCustomMapper.class.getName();
+ PrincipalMapper mapper = PrincipalMapperFactory.create(className, null);
+ assertNotNull(mapper);
+ assertTrue(mapper instanceof TestCustomMapper);
+ Principal principal = mapper.map("foo");
+ assertEquals("custom:foo", principal.getName());
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/auth/TestRegexPrincipalMapper.java
b/core/src/test/java/org/apache/gravitino/auth/TestRegexPrincipalMapper.java
new file mode 100644
index 0000000000..d56dd94ceb
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/auth/TestRegexPrincipalMapper.java
@@ -0,0 +1,140 @@
+/*
+ * 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.gravitino.auth;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.security.Principal;
+import org.apache.gravitino.UserPrincipal;
+import org.junit.jupiter.api.Test;
+
+public class TestRegexPrincipalMapper {
+
+ @Test
+ public void testExtractUsernameFromEmail() {
+ RegexPrincipalMapper mapper = new RegexPrincipalMapper("([^@]+)@.*");
+ Principal principal = mapper.map("[email protected]");
+
+ assertNotNull(principal);
+ assertEquals("john.doe", principal.getName());
+ assertTrue(principal instanceof UserPrincipal);
+ }
+
+ @Test
+ public void testExtractUsernameFromKerberosPrincipal() {
+ RegexPrincipalMapper mapper = new RegexPrincipalMapper("([^/@]+).*");
+
+ // Test user@REALM format
+ Principal principal1 = mapper.map("[email protected]");
+ assertNotNull(principal1);
+ assertEquals("john", principal1.getName());
+
+ // Test user/instance@REALM format
+ Principal principal2 = mapper.map("HTTP/[email protected]");
+ assertNotNull(principal2);
+ assertEquals("HTTP", principal2.getName());
+ }
+
+ @Test
+ public void testNullPattern() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> {
+ new RegexPrincipalMapper(null);
+ });
+ }
+
+ @Test
+ public void testEmptyPattern() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> {
+ new RegexPrincipalMapper("");
+ });
+ }
+
+ @Test
+ public void testNullPrincipal() {
+ RegexPrincipalMapper mapper = new RegexPrincipalMapper("([^@]+)@.*");
+ Principal principal = mapper.map(null);
+
+ assertNull(principal);
+ }
+
+ @Test
+ public void testPatternWithNoCapturingGroup() {
+ RegexPrincipalMapper mapper = new RegexPrincipalMapper(".*@company.com");
+ Principal principal = mapper.map("[email protected]");
+
+ // If pattern matches but has no capturing group, return original
+ assertNotNull(principal);
+ assertEquals("[email protected]", principal.getName());
+ }
+
+ @Test
+ public void testPatternDoesNotMatch() {
+ RegexPrincipalMapper mapper = new
RegexPrincipalMapper("([^@]+)@company.com");
+ Principal principal = mapper.map("[email protected]");
+
+ // If pattern doesn't match, return original
+ assertNotNull(principal);
+ assertEquals("[email protected]", principal.getName());
+ }
+
+ @Test
+ public void testInvalidRegexPattern() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> {
+ new RegexPrincipalMapper("[invalid(regex");
+ });
+ }
+
+ @Test
+ public void testEmptyCapturingGroup() {
+ RegexPrincipalMapper mapper = new RegexPrincipalMapper("().*");
+ Principal principal = mapper.map("[email protected]");
+
+ // If capturing group is empty, return original principal
+ assertNotNull(principal);
+ assertEquals("[email protected]", principal.getName());
+ }
+
+ @Test
+ public void testComplexPattern() {
+ // Extract username from Azure AD format: [email protected]
+ RegexPrincipalMapper mapper = new
RegexPrincipalMapper("([^@]+)@[^.]+\\..*");
+ Principal principal = mapper.map("[email protected]");
+
+ assertNotNull(principal);
+ assertEquals("john.doe", principal.getName());
+ }
+
+ @Test
+ public void testGetPatternString() {
+ String pattern = "([^@]+)@.*";
+ RegexPrincipalMapper mapper = new RegexPrincipalMapper(pattern);
+ assertEquals(pattern, mapper.getPatternString());
+ }
+}
diff --git a/docs/security/how-to-authenticate.md
b/docs/security/how-to-authenticate.md
index ef5338cb5f..b349f0b80e 100644
--- a/docs/security/how-to-authenticate.md
+++ b/docs/security/how-to-authenticate.md
@@ -113,6 +113,81 @@ GravitinoClient client = GravitinoClient.builder(uri)
.build();
```
+### Principal mapping
+
+Gravitino supports principal mapping to transform authenticated principals
(from OAuth or Kerberos) into user identities for authorization. By default,
Gravitino uses regex-based mapping.
+
+#### OAuth principal mapping
+
+For OAuth authentication, principals are extracted from JWT claims (configured
via `gravitino.authenticator.oauth.principalFields`). You can customize how
these principals are mapped:
+
+```text
+# Use default regex mapper that extracts everything (passes through unchanged)
+gravitino.authenticator.oauth.principalMapper = regex
+gravitino.authenticator.oauth.principalMapper.regex.pattern = ^(.*)$
+
+# Extract username from email (e.g., [email protected] -> user)
+gravitino.authenticator.oauth.principalMapper = regex
+gravitino.authenticator.oauth.principalMapper.regex.pattern = ([^@]+)@.*
+
+# Use custom mapper implementation
+gravitino.authenticator.oauth.principalMapper = com.example.MyCustomMapper
+```
+
+#### Kerberos principal mapping
+
+For Kerberos authentication, principals follow the format
`primary[/instance][@REALM]`. The default mapper extracts the primary component
(username before `@`):
+
+```text
+# Default: Extract primary component (user@REALM -> user, HTTP/server@REALM ->
HTTP)
+gravitino.authenticator.kerberos.principalMapper = regex
+gravitino.authenticator.kerberos.principalMapper.regex.pattern = ([^@]+).*
+
+# Extract only the first part before '/' (HTTP/server@REALM -> HTTP)
+gravitino.authenticator.kerberos.principalMapper = regex
+gravitino.authenticator.kerberos.principalMapper.regex.pattern = ([^/@]+).*
+```
+
+#### Custom principal mapper
+
+For advanced use cases, implement the `PrincipalMapper` interface:
+
+```java
+package com.example;
+
+import org.apache.gravitino.auth.KerberosPrincipal;
+import org.apache.gravitino.auth.KerberosPrincipalMapper;
+import org.apache.gravitino.auth.PrincipalMapper;
+
+import java.security.Principal;
+
+public class RealmBasedMapper implements PrincipalMapper {
+ private final KerberosPrincipalMapper parser = new KerberosPrincipalMapper();
+
+ @Override
+ public Principal map(String principal) {
+ // Parse Kerberos principal components
+ KerberosPrincipal krbPrincipal = (KerberosPrincipal) parser.map(principal);
+
+ // Route based on realm
+ if ("DEV.EXAMPLE.COM".equals(krbPrincipal.getRealm().orElse(null))) {
+ return () -> "dev_" + krbPrincipal.getName();
+ } else if
("PROD.EXAMPLE.COM".equals(krbPrincipal.getRealm().orElse(null))) {
+ return () -> "prod_" + krbPrincipal.getName();
+ }
+
+ // Default: use primary with instance (e.g., "HTTP/server")
+ return () -> krbPrincipal.getPrimaryWithInstance();
+ }
+}
+```
+
+Configure Gravitino to use your custom mapper:
+
+```text
+gravitino.authenticator.kerberos.principalMapper = com.example.RealmBasedMapper
+```
+
### Server configuration
Gravitino server and Gravitino Iceberg REST server share the same
configuration items, you doesn't need to add `gravitino.iceberg-rest` prefix
for Gravitino Iceberg REST server.
@@ -134,8 +209,12 @@ Gravitino server and Gravitino Iceberg REST server share
the same configuration
| `gravitino.authenticator.oauth.jwksUri` | JWKS URI for
server-side OAuth token validation. Required when using JWKS-based validation.
| (none)
| Yes if `tokenValidatorClass` is
`org.apache.gravitino.server.authentication.JwksTokenValidator` | 1. [...]
| `gravitino.authenticator.oauth.principalFields` | JWT claim field(s) to
use as principal identity. Comma-separated list for fallback in order (e.g.,
'preferred_username,email,sub').
| `sub` | No
| 1. [...]
| `gravitino.authenticator.oauth.tokenValidatorClass` | Fully qualified class
name of the OAuth token validator implementation. Use
`org.apache.gravitino.server.authentication.JwksTokenValidator` for JWKS-based
validation or
`org.apache.gravitino.server.authentication.StaticSignKeyValidator` for static
key validation. |
`org.apache.gravitino.server.authentication.StaticSignKeyValidator` | No
| 1. [...]
+| `gravitino.authenticator.oauth.principalMapper` | Principal mapper type for
OAuth. Use 'regex' for regex-based mapping, or provide a fully qualified class
name implementing `org.apache.gravitino.auth.PrincipalMapper`.
| `regex` | No
| 1.2.0 [...]
+| `gravitino.authenticator.oauth.principalMapper.regex.pattern` | Regex
pattern for OAuth principal mapping. First capture group becomes the mapped
principal. Only used when principalMapper is 'regex'.
| `^(.*)$`
| No
[...]
| `gravitino.authenticator.kerberos.principal` | Indicates the Kerberos
principal to be used for HTTP endpoint. Principal should start with `HTTP/`.
| (none) | Yes if
use `kerberos` as the authenticator
| 0. [...]
| `gravitino.authenticator.kerberos.keytab` | Location of the keytab
file with the credentials for the principal.
| (none) | Yes if
use `kerberos` as the authenticator
| 0. [...]
+| `gravitino.authenticator.kerberos.principalMapper` | Principal mapper type
for Kerberos. Use 'regex' for regex-based mapping, or provide a fully qualified
class name implementing `org.apache.gravitino.auth.PrincipalMapper`.
| `regex` | No
| 1.2.0 [...]
+| `gravitino.authenticator.kerberos.principalMapper.regex.pattern` | Regex
pattern for Kerberos principal mapping. First capture group becomes the mapped
principal. Only used when principalMapper is 'regex'.
| `([^@]+).*`
| No
[...]
The signature algorithms that Gravitino supports follows:
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authentication/JwksTokenValidator.java
b/server-common/src/main/java/org/apache/gravitino/server/authentication/JwksTokenValidator.java
index 5bd0681d47..e358a8998d 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authentication/JwksTokenValidator.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authentication/JwksTokenValidator.java
@@ -36,7 +36,8 @@ import java.util.List;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.Config;
-import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.auth.PrincipalMapper;
+import org.apache.gravitino.auth.PrincipalMapperFactory;
import org.apache.gravitino.exceptions.UnauthorizedException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -55,6 +56,7 @@ public class JwksTokenValidator implements
OAuthTokenValidator {
private String expectedIssuer;
private List<String> principalFields;
private long allowSkewSeconds;
+ private PrincipalMapper principalMapper;
@Override
public void initialize(Config config) {
@@ -63,6 +65,11 @@ public class JwksTokenValidator implements
OAuthTokenValidator {
this.principalFields = config.get(OAuthConfig.PRINCIPAL_FIELDS);
this.allowSkewSeconds = config.get(OAuthConfig.ALLOW_SKEW_SECONDS);
+ // Create principal mapper based on configuration
+ String mapperType = config.get(OAuthConfig.PRINCIPAL_MAPPER);
+ String regexPattern =
config.get(OAuthConfig.PRINCIPAL_MAPPER_REGEX_PATTERN);
+ this.principalMapper = PrincipalMapperFactory.create(mapperType,
regexPattern);
+
LOG.info("Initializing JWKS token validator");
if (StringUtils.isBlank(jwksUri)) {
@@ -132,7 +139,8 @@ public class JwksTokenValidator implements
OAuthTokenValidator {
throw new UnauthorizedException("No valid principal found in token");
}
- return new UserPrincipal(principal);
+ // Use principal mapper to extract username
+ return principalMapper.map(principal);
} catch (Exception e) {
LOG.error("JWKS JWT validation error: {}", e.getMessage());
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authentication/KerberosAuthenticator.java
b/server-common/src/main/java/org/apache/gravitino/server/authentication/KerberosAuthenticator.java
index c5aff415cb..a14f3b3abb 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authentication/KerberosAuthenticator.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authentication/KerberosAuthenticator.java
@@ -11,22 +11,21 @@
*/
package org.apache.gravitino.server.authentication;
-import com.google.common.base.Splitter;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.security.PrivilegedActionException;
import java.security.PrivilegedExceptionAction;
import java.util.Base64;
-import java.util.List;
import javax.security.auth.Subject;
import javax.security.auth.kerberos.KerberosPrincipal;
import javax.security.auth.kerberos.KeyTab;
import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.Config;
-import org.apache.gravitino.UserPrincipal;
import org.apache.gravitino.auth.AuthConstants;
import org.apache.gravitino.auth.KerberosUtils;
+import org.apache.gravitino.auth.PrincipalMapper;
+import org.apache.gravitino.auth.PrincipalMapperFactory;
import org.apache.gravitino.exceptions.UnauthorizedException;
import org.ietf.jgss.GSSContext;
import org.ietf.jgss.GSSCredential;
@@ -50,6 +49,7 @@ public class KerberosAuthenticator implements Authenticator {
public static final Logger LOG =
LoggerFactory.getLogger(KerberosAuthenticator.class);
private final Subject serverSubject = new Subject();
private GSSManager gssManager;
+ private PrincipalMapper principalMapper;
@Override
public void initialize(Config config) throws RuntimeException {
@@ -75,6 +75,13 @@ public class KerberosAuthenticator implements Authenticator {
KeyTab keytabInstance = KeyTab.getInstance(keytabFile);
serverSubject.getPrivateCredentials().add(keytabInstance);
+ // Initialize principal mapper. The default regex pattern '([^@]+).*'
+ // extracts everything before '@' (e.g., "user" from "user@REALM",
+ // "HTTP/host" from "HTTP/host@REALM")
+ String mapperType = config.get(KerberosConfig.PRINCIPAL_MAPPER);
+ String regexPattern =
config.get(KerberosConfig.PRINCIPAL_MAPPER_REGEX_PATTERN);
+ this.principalMapper = PrincipalMapperFactory.create(mapperType,
regexPattern);
+
gssManager =
Subject.doAs(
serverSubject,
@@ -170,23 +177,13 @@ public class KerberosAuthenticator implements
Authenticator {
throw new UnauthorizedException("GssContext isn't established",
challenge);
}
- // Usually principal names are in the form 'user/instance@REALM' or
'user@REALM'.
- List<String> principalComponents =
- Splitter.on('@').splitToList(gssContext.getSrcName().toString());
- if (principalComponents.size() != 2) {
- throw new UnauthorizedException("Principal has wrong format",
AuthConstants.NEGOTIATE);
- }
+ // Extract principal from GSS context and map it using configured mapper
+ String principalString = gssContext.getSrcName().toString();
- String user = principalComponents.get(0);
- // TODO: We will have KerberosUserPrincipal in the future.
- // We can put more information of Kerberos to the KerberosUserPrincipal
- // For example, we can put the token into the KerberosUserPrincipal,
- // We can return the token to the client in the AuthenticationFilter. It
will be convenient
- // for client to establish the security context. Hadoop uses the cookie
to store the token.
- // For now, we don't store it in the cookie. I can have a simple
implementation. first.
- // It's also not required for the protocol.
- // https://datatracker.ietf.org/doc/html/rfc4559
- return new UserPrincipal(user);
+ // Use principal mapper to extract the principal
+ // This allows for flexible mapping strategies (regex, kerberos-specific
parsing, etc.)
+ // The mapper will handle validation and extraction based on its
configured type
+ return principalMapper.map(principalString);
} finally {
if (gssContext != null) {
gssContext.dispose();
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authentication/KerberosConfig.java
b/server-common/src/main/java/org/apache/gravitino/server/authentication/KerberosConfig.java
index bec463420d..ab0a76ddc7 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authentication/KerberosConfig.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authentication/KerberosConfig.java
@@ -42,4 +42,26 @@ public interface KerberosConfig {
.stringConf()
.checkValue(StringUtils::isNotBlank,
ConfigConstants.NOT_BLANK_ERROR_MSG)
.create();
+
+ ConfigEntry<String> PRINCIPAL_MAPPER =
+ new ConfigBuilder(KERBEROS_CONFIG_PREFIX + "principalMapper")
+ .doc(
+ "Type of principal mapper to use for Kerberos authentication. "
+ + "Built-in value: 'regex' (uses regex pattern to extract
username). "
+ + "Can also be a fully qualified class name implementing
PrincipalMapper for custom logic. "
+ + "Custom mappers can use KerberosPrincipalMapper for
structured Kerberos principal parsing.")
+ .version(ConfigConstants.VERSION_1_2_0)
+ .stringConf()
+ .createWithDefault("regex");
+
+ ConfigEntry<String> PRINCIPAL_MAPPER_REGEX_PATTERN =
+ new ConfigBuilder(KERBEROS_CONFIG_PREFIX +
"principalMapper.regex.pattern")
+ .doc(
+ "Regex pattern to extract the username from the Kerberos
principal. "
+ + "Only used when principalMapper is 'regex'. "
+ + "The pattern should contain at least one capturing group. "
+ + "Default pattern '([^@]+).*' extracts everything before
'@' (including instance if present).")
+ .version(ConfigConstants.VERSION_1_2_0)
+ .stringConf()
+ .createWithDefault("([^@]+).*");
}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authentication/OAuthConfig.java
b/server-common/src/main/java/org/apache/gravitino/server/authentication/OAuthConfig.java
index c16b625960..3d3bfdbfa8 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authentication/OAuthConfig.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authentication/OAuthConfig.java
@@ -144,4 +144,26 @@ public interface OAuthConfig {
.version(ConfigConstants.VERSION_1_0_0)
.stringConf()
.createWithDefault("org.apache.gravitino.server.authentication.StaticSignKeyValidator");
+
+ ConfigEntry<String> PRINCIPAL_MAPPER =
+ new ConfigBuilder(OAUTH_CONFIG_PREFIX + "principalMapper")
+ .doc(
+ "Type of principal mapper to use for OAuth/JWT principals. "
+ + "Built-in value: 'regex' (uses regex pattern to extract
username). "
+ + "Default pattern '^(.*)$' keeps the principal unchanged. "
+ + "Can also be a fully qualified class name implementing
PrincipalMapper for custom logic.")
+ .version(ConfigConstants.VERSION_1_2_0)
+ .stringConf()
+ .createWithDefault("regex");
+
+ ConfigEntry<String> PRINCIPAL_MAPPER_REGEX_PATTERN =
+ new ConfigBuilder(OAUTH_CONFIG_PREFIX + "principalMapper.regex.pattern")
+ .doc(
+ "Regex pattern to extract the username from the OAuth principal
field. "
+ + "Only used when principalMapper is 'regex'. "
+ + "The pattern should contain at least one capturing group. "
+ + "Default pattern '^(.*)$' matches the entire principal.")
+ .version(ConfigConstants.VERSION_1_2_0)
+ .stringConf()
+ .createWithDefault("^(.*)$");
}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authentication/StaticSignKeyValidator.java
b/server-common/src/main/java/org/apache/gravitino/server/authentication/StaticSignKeyValidator.java
index 0fd59b7353..a6e94693f6 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authentication/StaticSignKeyValidator.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authentication/StaticSignKeyValidator.java
@@ -38,7 +38,8 @@ import java.util.Base64;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.Config;
-import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.auth.PrincipalMapper;
+import org.apache.gravitino.auth.PrincipalMapperFactory;
import org.apache.gravitino.auth.SignatureAlgorithmFamilyType;
import org.apache.gravitino.exceptions.UnauthorizedException;
@@ -52,6 +53,8 @@ import org.apache.gravitino.exceptions.UnauthorizedException;
public class StaticSignKeyValidator implements OAuthTokenValidator {
private long allowSkewSeconds;
private Key defaultSigningKey;
+ private PrincipalMapper principalMapper;
+ private List<String> principalFields;
@Override
public void initialize(Config config) {
@@ -65,6 +68,11 @@ public class StaticSignKeyValidator implements
OAuthTokenValidator {
"The uri of the default OAuth server can't be blank");
String algType = config.get(OAuthConfig.SIGNATURE_ALGORITHM_TYPE);
this.defaultSigningKey =
decodeSignKey(Base64.getDecoder().decode(configuredSignKey), algType);
+ this.principalFields = config.get(OAuthConfig.PRINCIPAL_FIELDS);
+ // Create principal mapper based on configuration
+ String mapperType = config.get(OAuthConfig.PRINCIPAL_MAPPER);
+ String regexPattern =
config.get(OAuthConfig.PRINCIPAL_MAPPER_REGEX_PATTERN);
+ this.principalMapper = PrincipalMapperFactory.create(mapperType,
regexPattern);
}
@Override
@@ -97,7 +105,12 @@ public class StaticSignKeyValidator implements
OAuthTokenValidator {
throw new UnauthorizedException(
"Audiences in token is not in expected format: %s",
audienceObject);
}
- return new UserPrincipal(jwt.getBody().getSubject());
+
+ // Extract principal from JWT claims using configured field(s)
+ String principal = extractPrincipal(jwt.getBody());
+
+ // Use principal mapper to extract username
+ return principalMapper.map(principal);
} catch (ExpiredJwtException
| UnsupportedJwtException
| MalformedJwtException
@@ -107,6 +120,24 @@ public class StaticSignKeyValidator implements
OAuthTokenValidator {
}
}
+ /** Extracts the principal from the JWT claims using configured field(s). */
+ private String extractPrincipal(Claims claims) {
+ // Try the principal field(s) one by one in order
+ if (principalFields != null && !principalFields.isEmpty()) {
+ for (String field : principalFields) {
+ if (StringUtils.isNotBlank(field)) {
+ Object claimValue = claims.get(field);
+ if (claimValue != null) {
+ return claimValue.toString();
+ }
+ }
+ }
+ }
+
+ throw new UnauthorizedException(
+ "No valid principal found in token. Checked fields: %s",
principalFields);
+ }
+
private static Key decodeSignKey(byte[] key, String algType) {
try {
SignatureAlgorithmFamilyType algFamilyType =
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestJwksTokenValidator.java
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestJwksTokenValidator.java
index a2f2362d87..3068b5aee5 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestJwksTokenValidator.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestJwksTokenValidator.java
@@ -389,4 +389,163 @@ public class TestJwksTokenValidator {
// The message should not contain "not initialized" or similar
assertTrue(exception.getMessage().toLowerCase().contains("jwt"));
}
+
+ @Test
+ public void testPrincipalMapperWithDefaultPattern() throws Exception {
+ // Generate a test RSA key pair
+ RSAKey rsaKey =
+ new
RSAKeyGenerator(2048).keyID("test-key-id").algorithm(JWSAlgorithm.RS256).generate();
+
+ // Create a signed JWT token with email as subject
+ JWTClaimsSet claimsSet =
+ new JWTClaimsSet.Builder()
+ .subject("[email protected]")
+ .audience("test-service")
+ .issuer("https://test-issuer.com")
+ .expirationTime(Date.from(Instant.now().plusSeconds(3600)))
+ .issueTime(Date.from(Instant.now()))
+ .build();
+
+ SignedJWT signedJWT =
+ new SignedJWT(
+ new
JWSHeader.Builder(JWSAlgorithm.RS256).keyID("test-key-id").build(), claimsSet);
+
+ JWSSigner signer = new RSASSASigner(rsaKey);
+ signedJWT.sign(signer);
+
+ String tokenString = signedJWT.serialize();
+
+ // Mock the JWKSourceBuilder to return our test key
+ try (MockedStatic<JWKSourceBuilder> mockedBuilder =
mockStatic(JWKSourceBuilder.class)) {
+ @SuppressWarnings("unchecked")
+ JWKSource<SecurityContext> mockJwkSource = mock(JWKSource.class);
+ @SuppressWarnings("unchecked")
+ JWKSourceBuilder<SecurityContext> mockBuilder =
mock(JWKSourceBuilder.class);
+
+ mockedBuilder.when(() ->
JWKSourceBuilder.create(any(URL.class))).thenReturn(mockBuilder);
+ when(mockBuilder.build()).thenReturn(mockJwkSource);
+ when(mockJwkSource.get(any(), any())).thenReturn(Arrays.asList(rsaKey));
+
+ // Initialize validator with default user mapping pattern
+ Map<String, String> config = new HashMap<>();
+ config.put(
+ "gravitino.authenticator.oauth.jwksUri",
"https://test-jwks.com/.well-known/jwks.json");
+ config.put("gravitino.authenticator.oauth.authority",
"https://test-issuer.com");
+ config.put("gravitino.authenticator.oauth.principalMapper", "regex");
+
config.put("gravitino.authenticator.oauth.principalMapper.regex.pattern",
"(.*)");
+ config.put("gravitino.authenticator.oauth.allowSkewSecs", "120");
+
+ validator.initialize(createConfig(config));
+ Principal result = validator.validateToken(tokenString, "test-service");
+
+ assertNotNull(result);
+ assertEquals("[email protected]", result.getName());
+ }
+ }
+
+ @Test
+ public void testPrincipalMapperExtractEmailLocalPart() throws Exception {
+ // Generate a test RSA key pair
+ RSAKey rsaKey =
+ new
RSAKeyGenerator(2048).keyID("test-key-id").algorithm(JWSAlgorithm.RS256).generate();
+
+ // Create a signed JWT token with email as subject
+ JWTClaimsSet claimsSet =
+ new JWTClaimsSet.Builder()
+ .subject("[email protected]")
+ .audience("test-service")
+ .issuer("https://test-issuer.com")
+ .expirationTime(Date.from(Instant.now().plusSeconds(3600)))
+ .issueTime(Date.from(Instant.now()))
+ .build();
+
+ SignedJWT signedJWT =
+ new SignedJWT(
+ new
JWSHeader.Builder(JWSAlgorithm.RS256).keyID("test-key-id").build(), claimsSet);
+
+ JWSSigner signer = new RSASSASigner(rsaKey);
+ signedJWT.sign(signer);
+
+ String tokenString = signedJWT.serialize();
+
+ // Mock the JWKSourceBuilder to return our test key
+ try (MockedStatic<JWKSourceBuilder> mockedBuilder =
mockStatic(JWKSourceBuilder.class)) {
+ @SuppressWarnings("unchecked")
+ JWKSource<SecurityContext> mockJwkSource = mock(JWKSource.class);
+ @SuppressWarnings("unchecked")
+ JWKSourceBuilder<SecurityContext> mockBuilder =
mock(JWKSourceBuilder.class);
+
+ mockedBuilder.when(() ->
JWKSourceBuilder.create(any(URL.class))).thenReturn(mockBuilder);
+ when(mockBuilder.build()).thenReturn(mockJwkSource);
+ when(mockJwkSource.get(any(), any())).thenReturn(Arrays.asList(rsaKey));
+
+ // Initialize validator with pattern to extract email local part
+ Map<String, String> config = new HashMap<>();
+ config.put(
+ "gravitino.authenticator.oauth.jwksUri",
"https://test-jwks.com/.well-known/jwks.json");
+ config.put("gravitino.authenticator.oauth.authority",
"https://test-issuer.com");
+ config.put("gravitino.authenticator.oauth.principalMapper", "regex");
+
config.put("gravitino.authenticator.oauth.principalMapper.regex.pattern",
"([^@]+)@.*");
+ config.put("gravitino.authenticator.oauth.allowSkewSecs", "120");
+
+ validator.initialize(createConfig(config));
+ Principal result = validator.validateToken(tokenString, "test-service");
+
+ assertNotNull(result);
+ assertEquals("john.doe", result.getName());
+ }
+ }
+
+ @Test
+ public void testPrincipalMapperNoMatch() throws Exception {
+ // Generate a test RSA key pair
+ RSAKey rsaKey =
+ new
RSAKeyGenerator(2048).keyID("test-key-id").algorithm(JWSAlgorithm.RS256).generate();
+
+ // Create a signed JWT token with simple username (no @)
+ JWTClaimsSet claimsSet =
+ new JWTClaimsSet.Builder()
+ .subject("plainuser")
+ .audience("test-service")
+ .issuer("https://test-issuer.com")
+ .expirationTime(Date.from(Instant.now().plusSeconds(3600)))
+ .issueTime(Date.from(Instant.now()))
+ .build();
+
+ SignedJWT signedJWT =
+ new SignedJWT(
+ new
JWSHeader.Builder(JWSAlgorithm.RS256).keyID("test-key-id").build(), claimsSet);
+
+ JWSSigner signer = new RSASSASigner(rsaKey);
+ signedJWT.sign(signer);
+
+ String tokenString = signedJWT.serialize();
+
+ // Mock the JWKSourceBuilder to return our test key
+ try (MockedStatic<JWKSourceBuilder> mockedBuilder =
mockStatic(JWKSourceBuilder.class)) {
+ @SuppressWarnings("unchecked")
+ JWKSource<SecurityContext> mockJwkSource = mock(JWKSource.class);
+ @SuppressWarnings("unchecked")
+ JWKSourceBuilder<SecurityContext> mockBuilder =
mock(JWKSourceBuilder.class);
+
+ mockedBuilder.when(() ->
JWKSourceBuilder.create(any(URL.class))).thenReturn(mockBuilder);
+ when(mockBuilder.build()).thenReturn(mockJwkSource);
+ when(mockJwkSource.get(any(), any())).thenReturn(Arrays.asList(rsaKey));
+
+ // Initialize validator with pattern that requires @ (won't match)
+ Map<String, String> config = new HashMap<>();
+ config.put(
+ "gravitino.authenticator.oauth.jwksUri",
"https://test-jwks.com/.well-known/jwks.json");
+ config.put("gravitino.authenticator.oauth.authority",
"https://test-issuer.com");
+ config.put("gravitino.authenticator.oauth.principalMapper", "regex");
+
config.put("gravitino.authenticator.oauth.principalMapper.regex.pattern",
"([^@]+)@.*");
+ config.put("gravitino.authenticator.oauth.allowSkewSecs", "120");
+
+ validator.initialize(createConfig(config));
+ Principal result = validator.validateToken(tokenString, "test-service");
+
+ assertNotNull(result);
+ assertEquals("plainuser", result.getName());
+ }
+ }
}
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestStaticSignKeyValidator.java
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestStaticSignKeyValidator.java
index 4ef5cda368..4512c93681 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestStaticSignKeyValidator.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestStaticSignKeyValidator.java
@@ -253,4 +253,236 @@ public class TestStaticSignKeyValidator {
assertNotNull(principal);
assertEquals("test-user", principal.getName());
}
+
+ @Test
+ public void testPrincipalMapperWithDefaultPattern() {
+ Map<String, String> config = createBaseConfig();
+ config.put("gravitino.authenticator.oauth.principalMapper", "regex");
+ config.put("gravitino.authenticator.oauth.principalMapper.regex.pattern",
"(.*)");
+ validator.initialize(createConfig(config));
+
+ String token =
+ Jwts.builder()
+ .setSubject("[email protected]")
+ .setAudience(serviceAudience)
+ .setIssuedAt(Date.from(Instant.now()))
+ .setExpiration(Date.from(Instant.now().plusSeconds(3600)))
+ .signWith(hmacKey, SignatureAlgorithm.HS256)
+ .compact();
+
+ Principal principal = validator.validateToken(token, serviceAudience);
+ assertNotNull(principal);
+ assertEquals("[email protected]", principal.getName());
+ }
+
+ @Test
+ public void testPrincipalMapperExtractEmailLocalPart() {
+ Map<String, String> config = createBaseConfig();
+ config.put("gravitino.authenticator.oauth.principalMapper", "regex");
+ config.put("gravitino.authenticator.oauth.principalMapper.regex.pattern",
"([^@]+)@.*");
+ validator.initialize(createConfig(config));
+
+ String token =
+ Jwts.builder()
+ .setSubject("[email protected]")
+ .setAudience(serviceAudience)
+ .setIssuedAt(Date.from(Instant.now()))
+ .setExpiration(Date.from(Instant.now().plusSeconds(3600)))
+ .signWith(hmacKey, SignatureAlgorithm.HS256)
+ .compact();
+
+ Principal principal = validator.validateToken(token, serviceAudience);
+ assertNotNull(principal);
+ assertEquals("john.doe", principal.getName());
+ }
+
+ @Test
+ public void testPrincipalMapperNoMatch() {
+ Map<String, String> config = createBaseConfig();
+ config.put("gravitino.authenticator.oauth.principalMapper", "regex");
+ config.put("gravitino.authenticator.oauth.principalMapper.regex.pattern",
"([^@]+)@.*");
+ validator.initialize(createConfig(config));
+
+ String token =
+ Jwts.builder()
+ .setSubject("plainuser")
+ .setAudience(serviceAudience)
+ .setIssuedAt(Date.from(Instant.now()))
+ .setExpiration(Date.from(Instant.now().plusSeconds(3600)))
+ .signWith(hmacKey, SignatureAlgorithm.HS256)
+ .compact();
+
+ Principal principal = validator.validateToken(token, serviceAudience);
+ assertNotNull(principal);
+ assertEquals("plainuser", principal.getName());
+ }
+
+ @Test
+ public void testPrincipalMapperKerberosPrincipal() {
+ Map<String, String> config = createBaseConfig();
+ config.put("gravitino.authenticator.oauth.principalMapper", "regex");
+ config.put("gravitino.authenticator.oauth.principalMapper.regex.pattern",
"([^/@]+).*");
+ validator.initialize(createConfig(config));
+
+ String token =
+ Jwts.builder()
+ .setSubject("admin/[email protected]")
+ .setAudience(serviceAudience)
+ .setIssuedAt(Date.from(Instant.now()))
+ .setExpiration(Date.from(Instant.now().plusSeconds(3600)))
+ .signWith(hmacKey, SignatureAlgorithm.HS256)
+ .compact();
+
+ Principal principal = validator.validateToken(token, serviceAudience);
+ assertNotNull(principal);
+ assertEquals("admin", principal.getName());
+ }
+
+ @Test
+ public void testCustomPrincipalFieldsDefault() {
+ // Test default behavior: uses "sub" claim
+ Map<String, String> config = createBaseConfig();
+ // Don't set principalFields, should default to ["sub"]
+ validator.initialize(createConfig(config));
+
+ String token =
+ Jwts.builder()
+ .setSubject("default-subject")
+ .claim("client_id", "custom-client-123")
+ .claim("email", "[email protected]")
+ .setAudience(serviceAudience)
+ .setIssuedAt(Date.from(Instant.now()))
+ .setExpiration(Date.from(Instant.now().plusSeconds(3600)))
+ .signWith(hmacKey, SignatureAlgorithm.HS256)
+ .compact();
+
+ Principal principal = validator.validateToken(token, serviceAudience);
+ assertNotNull(principal);
+ assertEquals("default-subject", principal.getName());
+ }
+
+ @Test
+ public void testCustomPrincipalFieldsSingleCustomField() {
+ // Test using a single custom field
+ Map<String, String> config = createBaseConfig();
+ config.put("gravitino.authenticator.oauth.principalFields", "email");
+ validator.initialize(createConfig(config));
+
+ String token =
+ Jwts.builder()
+ .setSubject("default-subject")
+ .claim("email", "[email protected]")
+ .claim("client_id", "custom-client-123")
+ .setAudience(serviceAudience)
+ .setIssuedAt(Date.from(Instant.now()))
+ .setExpiration(Date.from(Instant.now().plusSeconds(3600)))
+ .signWith(hmacKey, SignatureAlgorithm.HS256)
+ .compact();
+
+ Principal principal = validator.validateToken(token, serviceAudience);
+ assertNotNull(principal);
+ assertEquals("[email protected]", principal.getName());
+ }
+
+ @Test
+ public void testCustomPrincipalFieldsFallback() {
+ // Test fallback: tries preferred_username first, then email, then sub
+ Map<String, String> config = createBaseConfig();
+ config.put("gravitino.authenticator.oauth.principalFields",
"preferred_username,email,sub");
+ validator.initialize(createConfig(config));
+
+ // Token without preferred_username, should fall back to email
+ String token1 =
+ Jwts.builder()
+ .setSubject("default-subject")
+ .claim("email", "[email protected]")
+ .setAudience(serviceAudience)
+ .setIssuedAt(Date.from(Instant.now()))
+ .setExpiration(Date.from(Instant.now().plusSeconds(3600)))
+ .signWith(hmacKey, SignatureAlgorithm.HS256)
+ .compact();
+
+ Principal principal1 = validator.validateToken(token1, serviceAudience);
+ assertNotNull(principal1);
+ assertEquals("[email protected]", principal1.getName());
+
+ // Token with preferred_username, should use it
+ String token2 =
+ Jwts.builder()
+ .setSubject("default-subject")
+ .claim("preferred_username", "john.doe")
+ .claim("email", "[email protected]")
+ .setAudience(serviceAudience)
+ .setIssuedAt(Date.from(Instant.now()))
+ .setExpiration(Date.from(Instant.now().plusSeconds(3600)))
+ .signWith(hmacKey, SignatureAlgorithm.HS256)
+ .compact();
+
+ Principal principal2 = validator.validateToken(token2, serviceAudience);
+ assertNotNull(principal2);
+ assertEquals("john.doe", principal2.getName());
+
+ // Token with only sub, should fall back to sub
+ String token3 =
+ Jwts.builder()
+ .setSubject("only-subject")
+ .setAudience(serviceAudience)
+ .setIssuedAt(Date.from(Instant.now()))
+ .setExpiration(Date.from(Instant.now().plusSeconds(3600)))
+ .signWith(hmacKey, SignatureAlgorithm.HS256)
+ .compact();
+
+ Principal principal3 = validator.validateToken(token3, serviceAudience);
+ assertNotNull(principal3);
+ assertEquals("only-subject", principal3.getName());
+ }
+
+ @Test
+ public void testCustomPrincipalFieldsNoMatch() {
+ // Test when no configured field is present in token
+ Map<String, String> config = createBaseConfig();
+ config.put("gravitino.authenticator.oauth.principalFields",
"preferred_username,email");
+ validator.initialize(createConfig(config));
+
+ // Token without any of the configured fields
+ String token =
+ Jwts.builder()
+ .setSubject("default-subject")
+ .claim("client_id", "custom-client-123")
+ .setAudience(serviceAudience)
+ .setIssuedAt(Date.from(Instant.now()))
+ .setExpiration(Date.from(Instant.now().plusSeconds(3600)))
+ .signWith(hmacKey, SignatureAlgorithm.HS256)
+ .compact();
+
+ assertThrows(
+ UnauthorizedException.class, () -> validator.validateToken(token,
serviceAudience));
+ }
+
+ @Test
+ public void testPrincipalFieldsWithMapper() {
+ // Test that principal fields and mapper work together
+ Map<String, String> config = createBaseConfig();
+ config.put("gravitino.authenticator.oauth.principalFields", "email");
+ config.put("gravitino.authenticator.oauth.principalMapper", "regex");
+ config.put(
+ "gravitino.authenticator.oauth.principalMapper.regex.pattern",
+ "([^@]+)@.*"); // Extract user ID part
+
+ validator.initialize(createConfig(config));
+
+ String token =
+ Jwts.builder()
+ .setSubject("default-subject")
+ .claim("email", "[email protected]")
+ .setAudience(serviceAudience)
+ .setIssuedAt(Date.from(Instant.now()))
+ .setExpiration(Date.from(Instant.now().plusSeconds(3600)))
+ .signWith(hmacKey, SignatureAlgorithm.HS256)
+ .compact();
+
+ Principal principal = validator.validateToken(token, serviceAudience);
+ assertNotNull(principal);
+ assertEquals("john.doe", principal.getName()); // Mapper extracted local
part
+ }
}