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 5da4a40d4f [#10436] fix(web-v2,server): resolve identity mismatch
between UI and server (#10437)
5da4a40d4f is described below
commit 5da4a40d4f4264bc5061224911107e66285b12b9
Author: Nguyễn Vĩnh Hải <[email protected]>
AuthorDate: Thu Mar 19 09:25:54 2026 +0700
[#10436] fix(web-v2,server): resolve identity mismatch between UI and
server (#10437)
### What changes were proposed in this pull request?
Add a server-side `GET /api/authn/me` endpoint that returns the fully
resolved principal name, and update the web-v2 UI to call this endpoint
after OIDC login instead of relying on client-side token parsing.
**Server changes:**
- New `AuthMeResponse` DTO in `common/` module
- New `AuthnOperations` REST resource with `GET /api/authn/me` endpoint
that uses `PrincipalUtils.getCurrentUserName()` to return the
server-resolved principal
- Unit tests for `AuthnOperations` and `AuthMeResponse` DTO
- OpenAPI spec in `docs/open-api/authn.yaml`
**Web-v2 changes:**
- New `getAuthMeApi()` in `lib/api/auth/index.js`
- Session provider calls `/api/authn/me` after OIDC login, overrides
`authUser.name` with server-resolved principal
- OIDC provider prefers `access_token` over `id_token` per OAuth2 spec
(with updated tests)
- Logout action performs RP-initiated logout via `signoutRedirect()`
before clearing OIDC data
### Why are the changes needed?
When Gravitino is configured with `principalFields:
"preferred_username,sub"`, the server resolves the caller identity from
JWT using the first matching field (e.g., `preferred_username` →
`"admin"`). However, the web-v2 UI uses the OIDC `profile.name` claim
(e.g., `"admin admin"` — the full display name), causing an identity
mismatch. This breaks the `serviceAdmins` check since the UI-displayed
username doesn't match what the server considers the authenticated
principal.
Fix: #10436
### Does this PR introduce _any_ user-facing change?
- New REST API endpoint: `GET /api/authn/me` → returns `{"code": 0,
"principal": "<resolved-name>"}`
- Web-v2 UI now displays the server-resolved principal name instead of
the OIDC profile display name
### How was this patch tested?
1. Unit test `TestAuthnOperations` verifies the endpoint returns the
correct principal from the authenticated request context
2. Unit tests for `AuthMeResponse` DTO (serialization/deserialization +
default constructor)
3. Updated OIDC provider tests to validate `access_token` preference
over `id_token`
4. OpenAPI spec passes `./gradlew :docs:lintOpenAPI`
5. Manual testing on a K8s cluster with Keycloak OIDC: confirmed
`/api/authn/me` returns `"admin"` (from `preferred_username`) while OIDC
profile returns `"admin admin"`, and the UI correctly displays the
server-resolved principal
---
.../gravitino/dto/responses/AuthMeResponse.java | 55 ++++++++++++++
.../gravitino/dto/responses/TestResponses.java | 21 ++++++
docs/open-api/authn.yaml | 71 ++++++++++++++++++
docs/open-api/openapi.yaml | 3 +
.../gravitino/server/web/rest/AuthnOperations.java | 57 ++++++++++++++
.../server/web/rest/TestAuthnOperations.java | 87 ++++++++++++++++++++++
web-v2/web/src/lib/api/auth/index.js | 6 ++
web-v2/web/src/lib/auth/providers/oidc.js | 8 +-
web-v2/web/src/lib/auth/providers/oidc.test.js | 13 ++--
web-v2/web/src/lib/provider/session.js | 16 +++-
web-v2/web/src/lib/store/auth/index.js | 28 ++++++-
11 files changed, 353 insertions(+), 12 deletions(-)
diff --git
a/common/src/main/java/org/apache/gravitino/dto/responses/AuthMeResponse.java
b/common/src/main/java/org/apache/gravitino/dto/responses/AuthMeResponse.java
new file mode 100644
index 0000000000..ac9dd1ce5d
--- /dev/null
+++
b/common/src/main/java/org/apache/gravitino/dto/responses/AuthMeResponse.java
@@ -0,0 +1,55 @@
+/*
+ * 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.dto.responses;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+
+/** Response for the authenticated principal information. */
+@Getter
+@EqualsAndHashCode(callSuper = true)
+@ToString
+public class AuthMeResponse extends BaseResponse {
+
+ @JsonProperty("principal")
+ private final String principal;
+
+ /**
+ * Constructor for AuthMeResponse.
+ *
+ * @param principal The server-resolved principal name of the authenticated
user.
+ */
+ public AuthMeResponse(String principal) {
+ super(0);
+ this.principal = principal;
+ }
+
+ /** Default constructor for AuthMeResponse. (Used for Jackson
deserialization.) */
+ public AuthMeResponse() {
+ super();
+ this.principal = null;
+ }
+
+ @Override
+ public void validate() throws IllegalArgumentException {
+ super.validate();
+ }
+}
diff --git
a/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java
b/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java
index 0c66109fca..f1c5977eda 100644
--- a/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java
+++ b/common/src/test/java/org/apache/gravitino/dto/responses/TestResponses.java
@@ -514,6 +514,27 @@ public class TestResponses {
assertThrows(IllegalArgumentException.class, response1::validate);
}
+ @Test
+ void testAuthMeResponse() throws JsonProcessingException {
+ AuthMeResponse response = new AuthMeResponse("test-user");
+ response.validate();
+ assertEquals(0, response.getCode());
+ assertEquals("test-user", response.getPrincipal());
+
+ String serJson = JsonUtils.objectMapper().writeValueAsString(response);
+ AuthMeResponse deserResponse =
+ JsonUtils.objectMapper().readValue(serJson, AuthMeResponse.class);
+ assertEquals(response.getCode(), deserResponse.getCode());
+ assertEquals(response.getPrincipal(), deserResponse.getPrincipal());
+ }
+
+ @Test
+ void testAuthMeResponseDefault() {
+ AuthMeResponse response = new AuthMeResponse();
+ assertDoesNotThrow(response::validate);
+ assertNull(response.getPrincipal());
+ }
+
@Test
void testPartitionStatisticsListResponseNullElement() {
AuditDTO audit =
AuditDTO.builder().withCreator("user1").withCreateTime(Instant.now()).build();
diff --git a/docs/open-api/authn.yaml b/docs/open-api/authn.yaml
new file mode 100644
index 0000000000..05c3918771
--- /dev/null
+++ b/docs/open-api/authn.yaml
@@ -0,0 +1,71 @@
+# 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.
+
+---
+
+paths:
+
+ /authn/me:
+ get:
+ tags:
+ - authentication
+ summary: Get the authenticated principal
+ operationId: getAuthenticatedPrincipal
+ description: >
+ Returns the server-resolved principal name for the current
authenticated user.
+ The principal is derived from the JWT token using the configured
`principalFields`
+ and `principalMapper`, ensuring consistency between server-side
identity and UI display.
+ responses:
+ "200":
+ description: Returns the authenticated principal information
+ content:
+ application/vnd.gravitino.v1+json:
+ schema:
+ $ref: "#/components/schemas/AuthMeResponse"
+ examples:
+ AuthMeResponse:
+ $ref: "#/components/examples/AuthMeResponse"
+ "401":
+ description: Unauthorized - Authentication credentials are missing
or invalid
+ content:
+ application/vnd.gravitino.v1+json:
+ schema:
+ $ref: "./openapi.yaml#/components/schemas/ErrorModel"
+ "5xx":
+ $ref: "./openapi.yaml#/components/responses/ServerErrorResponse"
+
+components:
+ schemas:
+ AuthMeResponse:
+ type: object
+ properties:
+ code:
+ type: integer
+ format: int32
+ description: Status code of the response
+ enum:
+ - 0
+ principal:
+ type: string
+ description: The server-resolved principal name of the authenticated
user
+
+ examples:
+ AuthMeResponse:
+ value: {
+ "code": 0,
+ "principal": "admin"
+ }
diff --git a/docs/open-api/openapi.yaml b/docs/open-api/openapi.yaml
index 73ac3789e1..7e1253eebf 100644
--- a/docs/open-api/openapi.yaml
+++ b/docs/open-api/openapi.yaml
@@ -224,6 +224,9 @@ paths:
/metalakes/{metalake}/jobs/runs/{jobId}:
$ref:
"./jobs.yaml#/paths/~1metalakes~1%7Bmetalake%7D~1jobs~1runs~1%7BjobId%7D"
+ /authn/me:
+ $ref: "./authn.yaml#/paths/~1authn~1me"
+
components:
schemas:
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/rest/AuthnOperations.java
b/server/src/main/java/org/apache/gravitino/server/web/rest/AuthnOperations.java
new file mode 100644
index 0000000000..4656b5e683
--- /dev/null
+++
b/server/src/main/java/org/apache/gravitino/server/web/rest/AuthnOperations.java
@@ -0,0 +1,57 @@
+/*
+ * 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.server.web.rest;
+
+import com.codahale.metrics.annotation.ResponseMetered;
+import com.codahale.metrics.annotation.Timed;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.Response;
+import org.apache.gravitino.dto.responses.AuthMeResponse;
+import org.apache.gravitino.metrics.MetricNames;
+import org.apache.gravitino.server.web.Utils;
+import org.apache.gravitino.utils.PrincipalUtils;
+
+/**
+ * Provides the authenticated principal information. This endpoint returns the
server-resolved
+ * principal name, ensuring the UI identity matches the server-side identity
derived from the
+ * configured {@code principalFields} and {@code principalMapper}.
+ */
+@Path("/authn")
+public class AuthnOperations {
+
+ @Context private HttpServletRequest httpRequest;
+
+ @GET
+ @Path("me")
+ @Produces("application/vnd.gravitino.v1+json")
+ @Timed(name = "authn-me." + MetricNames.HTTP_PROCESS_DURATION, absolute =
true)
+ @ResponseMetered(name = "authn-me", absolute = true)
+ public Response me() {
+ try {
+ return Utils.doAs(
+ httpRequest, () -> Utils.ok(new
AuthMeResponse(PrincipalUtils.getCurrentUserName())));
+ } catch (Exception e) {
+ return Utils.internalError(e.getMessage(), e);
+ }
+ }
+}
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestAuthnOperations.java
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestAuthnOperations.java
new file mode 100644
index 0000000000..f55edd7167
--- /dev/null
+++
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestAuthnOperations.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.gravitino.server.web.rest;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.core.Application;
+import javax.ws.rs.core.Response;
+import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.auth.AuthConstants;
+import org.apache.gravitino.dto.responses.AuthMeResponse;
+import org.apache.gravitino.rest.RESTUtils;
+import org.apache.gravitino.server.web.ObjectMapperProvider;
+import org.glassfish.hk2.utilities.binding.AbstractBinder;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.TestProperties;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestAuthnOperations extends BaseOperationsTest {
+
+ private static final String TEST_PRINCIPAL = "test-user";
+
+ private static class MockServletRequestFactory extends
ServletRequestFactoryBase {
+ @Override
+ public HttpServletRequest get() {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getRemoteUser()).thenReturn(null);
+
when(request.getAttribute(AuthConstants.AUTHENTICATED_PRINCIPAL_ATTRIBUTE_NAME))
+ .thenReturn(new UserPrincipal(TEST_PRINCIPAL));
+ return request;
+ }
+ }
+
+ @Override
+ protected Application configure() {
+ try {
+ forceSet(
+ TestProperties.CONTAINER_PORT,
String.valueOf(RESTUtils.findAvailablePort(2000, 3000)));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+
+ ResourceConfig resourceConfig = new ResourceConfig();
+ resourceConfig.register(AuthnOperations.class);
+ resourceConfig.register(ObjectMapperProvider.class);
+ resourceConfig.register(
+ new AbstractBinder() {
+ @Override
+ protected void configure() {
+
bindFactory(MockServletRequestFactory.class).to(HttpServletRequest.class).ranked(2);
+ }
+ });
+
+ return resourceConfig;
+ }
+
+ @Test
+ public void testGetAuthnMe() {
+ Response resp =
target("/authn/me").request().accept("application/vnd.gravitino.v1+json").get();
+
+ Assertions.assertEquals(Response.Status.OK.getStatusCode(),
resp.getStatus());
+
+ AuthMeResponse authMeResponse = resp.readEntity(AuthMeResponse.class);
+ Assertions.assertEquals(0, authMeResponse.getCode());
+ Assertions.assertEquals(TEST_PRINCIPAL, authMeResponse.getPrincipal());
+ }
+}
diff --git a/web-v2/web/src/lib/api/auth/index.js
b/web-v2/web/src/lib/api/auth/index.js
index fe9e596ce6..bace731fb9 100644
--- a/web-v2/web/src/lib/api/auth/index.js
+++ b/web-v2/web/src/lib/api/auth/index.js
@@ -26,6 +26,12 @@ export const getAuthConfigsApi = () => {
})
}
+export const getAuthMeApi = () => {
+ return defHttp.get({
+ url: '/api/authn/me'
+ })
+}
+
export const loginApi = (url, params) => {
return defHttp.post(
{
diff --git a/web-v2/web/src/lib/auth/providers/oidc.js
b/web-v2/web/src/lib/auth/providers/oidc.js
index 42eb6d67e0..d61ecf3f2f 100644
--- a/web-v2/web/src/lib/auth/providers/oidc.js
+++ b/web-v2/web/src/lib/auth/providers/oidc.js
@@ -67,8 +67,8 @@ export class OidcOAuthProvider extends BaseOAuthProvider {
let user = await this.userManager.getUser()
if (user && !user.expired) {
- // For JWKS validation, we need the ID token (JWT format), not the
access token
- return user.id_token || user.access_token
+ // Use access token for API requests per OAuth2 spec
+ return user.access_token || user.id_token
}
if (user && user.expired) {
@@ -76,8 +76,8 @@ export class OidcOAuthProvider extends BaseOAuthProvider {
// Attempt silent refresh
const refreshedUser = await this.userManager.signinSilent()
- // Return ID token for JWKS validation
- return refreshedUser.id_token || refreshedUser.access_token
+ // Use access token for API requests per OAuth2 spec
+ return refreshedUser.access_token || refreshedUser.id_token
} catch (refreshError) {
// Clear expired tokens
await this.userManager.removeUser()
diff --git a/web-v2/web/src/lib/auth/providers/oidc.test.js
b/web-v2/web/src/lib/auth/providers/oidc.test.js
index cffe8bcbc2..fc22a927d2 100644
--- a/web-v2/web/src/lib/auth/providers/oidc.test.js
+++ b/web-v2/web/src/lib/auth/providers/oidc.test.js
@@ -128,7 +128,7 @@ describe('OidcOAuthProvider', () => {
expect(token).toBeNull()
})
- it('should return id_token for valid user', async () => {
+ it('should return access_token for valid user', async () => {
const mockUser = {
id_token: 'test-id-token',
access_token: 'test-access-token',
@@ -138,25 +138,26 @@ describe('OidcOAuthProvider', () => {
const token = await provider.getAccessToken()
- expect(token).toBe('test-id-token')
+ expect(token).toBe('test-access-token')
})
- it('should return access_token when id_token is not available', async ()
=> {
+ it('should return id_token when access_token is not available', async ()
=> {
const mockUser = {
- access_token: 'test-access-token',
+ id_token: 'test-id-token',
expired: false
}
mockUserManager.getUser.mockResolvedValue(mockUser)
const token = await provider.getAccessToken()
- expect(token).toBe('test-access-token')
+ expect(token).toBe('test-id-token')
})
it('should attempt silent refresh for expired user', async () => {
const expiredUser = { expired: true }
const refreshedUser = {
+ access_token: 'new-access-token',
id_token: 'new-id-token',
expired: false
}
@@ -167,7 +168,7 @@ describe('OidcOAuthProvider', () => {
const token = await provider.getAccessToken()
expect(mockUserManager.signinSilent).toHaveBeenCalled()
- expect(token).toBe('new-id-token')
+ expect(token).toBe('new-access-token')
})
it('should handle silent refresh failure', async () => {
diff --git a/web-v2/web/src/lib/provider/session.js
b/web-v2/web/src/lib/provider/session.js
index 7c6e442e94..3abbc4f618 100644
--- a/web-v2/web/src/lib/provider/session.js
+++ b/web-v2/web/src/lib/provider/session.js
@@ -27,6 +27,7 @@ import { oauthProviderFactory } from
'@/lib/auth/providers/factory'
import { to } from '../utils'
import { getAuthConfigs, setAuthToken, setAuthUser } from '../store/auth'
+import { getAuthMeApi } from '../api/auth'
import { useIdle } from 'react-use'
@@ -121,7 +122,20 @@ const AuthProvider = ({ children }) => {
if (tokenToUse) {
dispatch(setAuthToken(tokenToUse))
- user && dispatch(setAuthUser(user))
+
+ // Fetch server-resolved principal to ensure UI identity matches
server-side
+ // identity derived from principalFields + principalMapper config
+ let authUser = user
+ try {
+ const [meErr, meRes] = await to(getAuthMeApi())
+ if (!meErr && meRes && meRes.principal) {
+ authUser = { ...user, name: meRes.principal }
+ }
+ } catch (e) {
+ // Fallback to OIDC profile if /api/authn/me is unavailable
+ }
+
+ authUser && dispatch(setAuthUser(authUser))
dispatch(initialVersion())
goToMetalakeListPage()
} else {
diff --git a/web-v2/web/src/lib/store/auth/index.js
b/web-v2/web/src/lib/store/auth/index.js
index ca11e64d22..d5342cb110 100644
--- a/web-v2/web/src/lib/store/auth/index.js
+++ b/web-v2/web/src/lib/store/auth/index.js
@@ -102,8 +102,34 @@ export const logoutAction =
createAsyncThunk('auth/logoutAction', async ({ route
try {
const provider = await oauthProviderFactory.getProvider()
if (provider) {
+ // For OIDC providers, use signoutRedirect to end IdP session
+ if (provider.getUserManager) {
+ const userManager = provider.getUserManager()
+ if (userManager) {
+ // Read id_token before clearing — needed for id_token_hint
+ const user = await userManager.getUser()
+
+ // Clear OIDC user data from store
+ await provider.clearAuthData()
+
+ // Clear legacy auth tokens
+ localStorage.removeItem('accessToken')
+ localStorage.removeItem('authParams')
+ localStorage.removeItem('expiredIn')
+ localStorage.removeItem('isIdle')
+ localStorage.removeItem('version')
+
+ dispatch(clearIntervalId())
+ dispatch(setAuthToken(''))
+
+ // Redirect to IdP logout endpoint — browser navigates away, must
be last
+ await userManager.signoutRedirect({ id_token_hint: user?.id_token
})
+
+ return { token: null } // unreachable — browser navigates away
+ }
+ }
+
await provider.clearAuthData()
- console.log('[Logout Action] Provider cleanup completed')
}
} catch (error) {
console.warn('[Logout Action] Provider cleanup failed:', error)