This is an automated email from the ASF dual-hosted git repository.

mchades 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 62e135a78f [#11141] improvement(ui):Change the OIDC callback address 
based on NEXT_PUBLIC_BASE and catch error (#11142)
62e135a78f is described below

commit 62e135a78f0a2452ee11f8a67efc6ee2352e9194
Author: Qian Xia <[email protected]>
AuthorDate: Wed Jun 3 15:08:07 2026 +0800

    [#11141] improvement(ui):Change the OIDC callback address based on 
NEXT_PUBLIC_BASE and catch error (#11142)
    
    ### What changes were proposed in this pull request?
    
    1. Change the OIDC callback address from the hardcoded /ui to
    dynamically concatenate based on NEXT_PUBLIC_BASE_PATH, so that in local
    dev it will use /oauth/callback, while in a static /ui deployment it
    will still use /ui/oauth/callback, consistent with the actual route.
    2. Catch redirect error
    
    ### Why are the changes needed?
    
    Fix: #11141
    
    ### Does this PR introduce _any_ user-facing change?
    N/A
    
    ### How was this patch tested?
    N/A
---
 web-v2/web/src/app/login/components/OidcLogin.js |  19 +-
 web-v2/web/src/lib/auth/providers/oidc.js        |  77 ++++++-
 web-v2/web/src/lib/auth/providers/oidc.test.js   | 262 ++++++++++++++++++++++-
 3 files changed, 344 insertions(+), 14 deletions(-)

diff --git a/web-v2/web/src/app/login/components/OidcLogin.js 
b/web-v2/web/src/app/login/components/OidcLogin.js
index 635bbbe747..5066c2a722 100644
--- a/web-v2/web/src/app/login/components/OidcLogin.js
+++ b/web-v2/web/src/app/login/components/OidcLogin.js
@@ -28,7 +28,8 @@ const { Text } = Typography
 function OidcLogin() {
   const [isLoading, setIsLoading] = useState(true)
   const [userManager, setUserManager] = useState(null)
-  const [error, setError] = useState(null)
+  const [initError, setInitError] = useState(null)
+  const [redirectError, setRedirectError] = useState(null)
 
   useEffect(() => {
     const initializeOidc = async () => {
@@ -37,7 +38,7 @@ function OidcLogin() {
         const provider = await oauthProviderFactory.getProvider()
 
         if (provider.getType() !== 'oidc') {
-          setError('OIDC provider not configured')
+          setInitError('OIDC provider not configured')
           setIsLoading(false)
 
           return
@@ -49,9 +50,10 @@ function OidcLogin() {
         }
 
         setUserManager(sharedUserManager)
+        setInitError(null)
         setIsLoading(false)
       } catch (error) {
-        setError(error.message || 'Failed to initialize OIDC')
+        setInitError(error.message || 'Failed to initialize OIDC')
         setIsLoading(false)
       }
     }
@@ -67,22 +69,23 @@ function OidcLogin() {
     )
   }
 
-  if (error) {
+  if (initError) {
     return (
       <Flex justify='center' className='my-4'>
-        <Alert message={error} type='error' showIcon />
+        <Alert message={initError} type='error' showIcon />
       </Flex>
     )
   }
 
   return (
     <Flex vertical align='center' gap={24} className='mt-4'>
-      <OidcLoginButton userManager={userManager} />
+      {redirectError && <Alert message={redirectError} type='error' showIcon 
style={{ width: '100%' }} />}
+      <OidcLoginButton userManager={userManager} onError={setRedirectError} />
     </Flex>
   )
 }
 
-function OidcLoginButton({ userManager }) {
+function OidcLoginButton({ userManager, onError }) {
   const [isLoggingIn, setIsLoggingIn] = useState(false)
 
   const handleLogin = async () => {
@@ -91,9 +94,11 @@ function OidcLoginButton({ userManager }) {
     }
 
     try {
+      onError(null)
       setIsLoggingIn(true)
       await userManager.signinRedirect()
     } catch (error) {
+      onError(error.message || 'Failed to redirect to the identity provider')
       setIsLoggingIn(false)
     }
   }
diff --git a/web-v2/web/src/lib/auth/providers/oidc.js 
b/web-v2/web/src/lib/auth/providers/oidc.js
index d61ecf3f2f..dca8b5dc65 100644
--- a/web-v2/web/src/lib/auth/providers/oidc.js
+++ b/web-v2/web/src/lib/auth/providers/oidc.js
@@ -20,6 +20,67 @@
 import { BaseOAuthProvider } from './base'
 import { UserManager, WebStorageStateStore } from 'oidc-client-ts'
 
+function getOidcAppBasePath() {
+  const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''
+
+  if (!basePath || basePath === '/') {
+    return ''
+  }
+
+  return basePath.replace(/\/$/, '')
+}
+
+function buildOidcUrl(pathname) {
+  return `${window.location.origin}${getOidcAppBasePath()}${pathname}`
+}
+
+function parseLocationFromUrl(rawUrl) {
+  if (!rawUrl) {
+    return null
+  }
+
+  try {
+    const parsed = new URL(rawUrl)
+
+    return {
+      protocol: parsed.protocol,
+      hostname: parsed.hostname
+    }
+  } catch (error) {
+    return null
+  }
+}
+
+function getLocationProtocolAndHostname() {
+  if (typeof window === 'undefined') {
+    return null
+  }
+
+  const { protocol, hostname, origin, href } = window.location
+
+  if (protocol && hostname) {
+    return { protocol, hostname }
+  }
+
+  return parseLocationFromUrl(origin) || parseLocationFromUrl(href)
+}
+
+function isOidcSecureOrigin() {
+  if (typeof window === 'undefined') {
+    return true
+  }
+
+  const locationInfo = getLocationProtocolAndHostname()
+  if (!locationInfo) {
+    return false
+  }
+
+  const { protocol, hostname } = locationInfo
+  const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || 
hostname === '::1'
+
+  return protocol === 'https:' || isLocalhost
+}
+
 export class OidcOAuthProvider extends BaseOAuthProvider {
   constructor() {
     super()
@@ -31,6 +92,16 @@ export class OidcOAuthProvider extends BaseOAuthProvider {
   async initialize(config) {
     this.config = config
 
+    if (typeof window === 'undefined') {
+      throw new Error('OIDC provider requires a browser environment')
+    }
+
+    if (!isOidcSecureOrigin()) {
+      throw new Error(
+        `OIDC login requires the UI to run on HTTPS or localhost. Current 
origin: ${window.location.origin}`
+      )
+    }
+
     const authority = config['gravitino.authenticator.oauth.authority']
     const clientId = config['gravitino.authenticator.oauth.clientId']
     const scope = config['gravitino.authenticator.oauth.scope'] || 'openid 
profile email'
@@ -45,9 +116,9 @@ export class OidcOAuthProvider extends BaseOAuthProvider {
       client_id: clientId,
       response_type: 'code', // Use Authorization Code flow with PKCE
       scope: scope,
-      redirect_uri: `${window.location.origin}/ui/oauth/callback`,
-      post_logout_redirect_uri: `${window.location.origin}/ui/oauth/logout`,
-      silent_redirect_uri: 
`${window.location.origin}/ui/oauth/silent-callback`,
+      redirect_uri: buildOidcUrl('/oauth/callback'),
+      post_logout_redirect_uri: buildOidcUrl('/oauth/logout'),
+      silent_redirect_uri: buildOidcUrl('/oauth/silent-callback'),
       automaticSilentRenew: true,
       silentRequestTimeout: 10000,
       userStore: new WebStorageStateStore({ store: window.localStorage })
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 fc22a927d2..5986955f9a 100644
--- a/web-v2/web/src/lib/auth/providers/oidc.test.js
+++ b/web-v2/web/src/lib/auth/providers/oidc.test.js
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll 
} from 'vitest'
 import { OidcOAuthProvider } from '@/lib/auth/providers/oidc'
 import { UserManager } from 'oidc-client-ts'
 
@@ -30,11 +30,88 @@ vi.mock('oidc-client-ts', () => ({
 describe('OidcOAuthProvider', () => {
   let provider
   let mockUserManager
+  let originalBasePath
+  let originalLocation
+  let originalLocationDescriptor
+
+  const DEFAULT_SECURE_LOCATION = {
+    origin: 'https://localhost:3000',
+    protocol: 'https:',
+    hostname: 'localhost'
+  }
+
+  const canAssignWindowLocation = () =>
+    originalLocationDescriptor && !originalLocationDescriptor.configurable && 
originalLocationDescriptor.writable
+
+  const updateWindowLocation = newLocation => {
+    if (canAssignWindowLocation()) {
+      window.location = newLocation
+
+      return
+    }
+
+    Object.defineProperty(window, 'location', {
+      configurable: true,
+      value: newLocation
+    })
+  }
+
+  const restoreWindowLocation = () => {
+    if (canAssignWindowLocation()) {
+      window.location = originalLocation
+
+      return
+    }
+
+    if (originalLocationDescriptor) {
+      Object.defineProperty(window, 'location', originalLocationDescriptor)
+
+      return
+    }
+
+    Object.defineProperty(window, 'location', {
+      configurable: true,
+      value: originalLocation
+    })
+  }
+
+  const setWindowLocation = ({ origin, protocol, hostname }) => {
+    const mockedLocation = Object.create(originalLocation)
+
+    Object.defineProperties(mockedLocation, {
+      origin: {
+        configurable: true,
+        enumerable: true,
+        value: origin
+      },
+      protocol: {
+        configurable: true,
+        enumerable: true,
+        value: protocol
+      },
+      hostname: {
+        configurable: true,
+        enumerable: true,
+        value: hostname
+      }
+    })
+
+    updateWindowLocation(mockedLocation)
+  }
+
+  beforeAll(() => {
+    originalLocation = window.location
+    originalLocationDescriptor = Object.getOwnPropertyDescriptor(window, 
'location')
+  })
 
   beforeEach(() => {
     // Reset all mocks
     vi.clearAllMocks()
 
+    originalBasePath = process.env.NEXT_PUBLIC_BASE_PATH
+    process.env.NEXT_PUBLIC_BASE_PATH = ''
+    setWindowLocation(DEFAULT_SECURE_LOCATION)
+
     // Create mock UserManager
     mockUserManager = {
       getUser: vi.fn(),
@@ -49,6 +126,15 @@ describe('OidcOAuthProvider', () => {
     provider = new OidcOAuthProvider()
   })
 
+  afterEach(() => {
+    process.env.NEXT_PUBLIC_BASE_PATH = originalBasePath
+    restoreWindowLocation()
+  })
+
+  afterAll(() => {
+    restoreWindowLocation()
+  })
+
   describe('initialization', () => {
     it('should initialize with correct provider type', () => {
       expect(provider.providerType).toBe('oidc')
@@ -76,6 +162,22 @@ describe('OidcOAuthProvider', () => {
       )
     })
 
+    it('should reject initialization in non-browser environments', async () => 
{
+      const originalWindow = global.window
+      delete global.window
+
+      const config = {
+        'gravitino.authenticator.oauth.authority': 'https://test.example.com',
+        'gravitino.authenticator.oauth.clientId': 'test-client'
+      }
+
+      try {
+        await expect(provider.initialize(config)).rejects.toThrow('OIDC 
provider requires a browser environment')
+      } finally {
+        global.window = originalWindow
+      }
+    })
+
     it('should initialize successfully with valid config', async () => {
       const config = {
         'gravitino.authenticator.oauth.authority': 'https://test.example.com',
@@ -91,9 +193,9 @@ describe('OidcOAuthProvider', () => {
         client_id: 'test-client',
         response_type: 'code',
         scope: 'openid profile',
-        redirect_uri: `${window.location.origin}/ui/oauth/callback`,
-        post_logout_redirect_uri: `${window.location.origin}/ui/oauth/logout`,
-        silent_redirect_uri: 
`${window.location.origin}/ui/oauth/silent-callback`,
+        redirect_uri: `${window.location.origin}/oauth/callback`,
+        post_logout_redirect_uri: `${window.location.origin}/oauth/logout`,
+        silent_redirect_uri: `${window.location.origin}/oauth/silent-callback`,
         automaticSilentRenew: true,
         silentRequestTimeout: 10000,
         userStore: expect.any(Object)
@@ -101,6 +203,158 @@ describe('OidcOAuthProvider', () => {
       expect(UserManager).toHaveBeenCalledWith(provider.oidcConfig)
     })
 
+    it('should include configured base path in callback urls', async () => {
+      process.env.NEXT_PUBLIC_BASE_PATH = '/ui'
+
+      const config = {
+        'gravitino.authenticator.oauth.authority': 'https://test.example.com',
+        'gravitino.authenticator.oauth.clientId': 'test-client',
+        'gravitino.authenticator.oauth.scope': 'openid profile'
+      }
+
+      await provider.initialize(config)
+
+      
expect(provider.oidcConfig.redirect_uri).toBe(`${window.location.origin}/ui/oauth/callback`)
+      
expect(provider.oidcConfig.post_logout_redirect_uri).toBe(`${window.location.origin}/ui/oauth/logout`)
+      
expect(provider.oidcConfig.silent_redirect_uri).toBe(`${window.location.origin}/ui/oauth/silent-callback`)
+    })
+
+    it('should trim trailing slash for configured base path', async () => {
+      process.env.NEXT_PUBLIC_BASE_PATH = '/ui/'
+
+      const config = {
+        'gravitino.authenticator.oauth.authority': 'https://test.example.com',
+        'gravitino.authenticator.oauth.clientId': 'test-client'
+      }
+
+      await provider.initialize(config)
+
+      
expect(provider.oidcConfig.redirect_uri).toBe(`${window.location.origin}/ui/oauth/callback`)
+      
expect(provider.oidcConfig.post_logout_redirect_uri).toBe(`${window.location.origin}/ui/oauth/logout`)
+      
expect(provider.oidcConfig.silent_redirect_uri).toBe(`${window.location.origin}/ui/oauth/silent-callback`)
+    })
+
+    it('should reject insecure non-localhost origins', async () => {
+      setWindowLocation({
+        origin: 'http://example.com:3000',
+        protocol: 'http:',
+        hostname: 'example.com'
+      })
+
+      const config = {
+        'gravitino.authenticator.oauth.authority': 
'https://id.example.com/realms/myrealm',
+        'gravitino.authenticator.oauth.clientId': 'postman-client'
+      }
+
+      await expect(provider.initialize(config)).rejects.toThrow(
+        'OIDC login requires the UI to run on HTTPS or localhost. Current 
origin: http://example.com:3000'
+      )
+    })
+
+    it('should allow localhost over http', async () => {
+      setWindowLocation({
+        origin: 'http://localhost:3001',
+        protocol: 'http:',
+        hostname: 'localhost'
+      })
+
+      const config = {
+        'gravitino.authenticator.oauth.authority': 
'https://id.example.com/realms/myrealm',
+        'gravitino.authenticator.oauth.clientId': 'postman-client'
+      }
+
+      await expect(provider.initialize(config)).resolves.toBeUndefined()
+    })
+
+    it('should allow https for non-localhost origins', async () => {
+      setWindowLocation({
+        origin: 'https://example.com:3000',
+        protocol: 'https:',
+        hostname: 'example.com'
+      })
+
+      const config = {
+        'gravitino.authenticator.oauth.authority': 
'https://id.example.com/realms/myrealm',
+        'gravitino.authenticator.oauth.clientId': 'postman-client'
+      }
+
+      await expect(provider.initialize(config)).resolves.toBeUndefined()
+    })
+
+    it('should allow secure origin when protocol and hostname are missing', 
async () => {
+      const mockedLocation = Object.create(originalLocation)
+
+      Object.defineProperties(mockedLocation, {
+        origin: {
+          configurable: true,
+          enumerable: true,
+          value: 'https://example.com:3000'
+        },
+        href: {
+          configurable: true,
+          enumerable: true,
+          value: 'https://example.com:3000/login'
+        },
+        protocol: {
+          configurable: true,
+          enumerable: true,
+          value: undefined
+        },
+        hostname: {
+          configurable: true,
+          enumerable: true,
+          value: undefined
+        }
+      })
+
+      updateWindowLocation(mockedLocation)
+
+      const config = {
+        'gravitino.authenticator.oauth.authority': 
'https://id.example.com/realms/myrealm',
+        'gravitino.authenticator.oauth.clientId': 'postman-client'
+      }
+
+      await expect(provider.initialize(config)).resolves.toBeUndefined()
+    })
+
+    it('should reject insecure origin when protocol and hostname are missing', 
async () => {
+      const mockedLocation = Object.create(originalLocation)
+
+      Object.defineProperties(mockedLocation, {
+        origin: {
+          configurable: true,
+          enumerable: true,
+          value: 'http://example.com:3000'
+        },
+        href: {
+          configurable: true,
+          enumerable: true,
+          value: 'http://example.com:3000/login'
+        },
+        protocol: {
+          configurable: true,
+          enumerable: true,
+          value: undefined
+        },
+        hostname: {
+          configurable: true,
+          enumerable: true,
+          value: undefined
+        }
+      })
+
+      updateWindowLocation(mockedLocation)
+
+      const config = {
+        'gravitino.authenticator.oauth.authority': 
'https://id.example.com/realms/myrealm',
+        'gravitino.authenticator.oauth.clientId': 'postman-client'
+      }
+
+      await expect(provider.initialize(config)).rejects.toThrow(
+        'OIDC login requires the UI to run on HTTPS or localhost. Current 
origin: http://example.com:3000'
+      )
+    })
+
     it('should use default scope when not provided', async () => {
       const config = {
         'gravitino.authenticator.oauth.authority': 'https://test.example.com',

Reply via email to