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)

Reply via email to