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',