From 8c628d93d012557af0a49b96a7acd9cab386c7a1 Mon Sep 17 00:00:00 2001 From: yanchuan Date: Mon, 9 Nov 2026 14:13:26 +0800 Subject: [PATCH 1/2] feat: support OIDC provider login parameter --- core/grantforge-web/src/lib/authorize.test.ts | 12 +++- core/grantforge-web/src/lib/authorize.ts | 9 +++ .../grantforge-web/src/views/AuthView.test.ts | 64 +++++++++++++++++++ core/grantforge-web/src/views/AuthView.vue | 15 ++++- docs/content/en/guide/identity-sources.md | 2 + docs/content/zh/guide/identity-sources.md | 4 ++ 6 files changed, 102 insertions(+), 4 deletions(-) diff --git a/core/grantforge-web/src/lib/authorize.test.ts b/core/grantforge-web/src/lib/authorize.test.ts index 7ea985eda..3e4dce112 100644 --- a/core/grantforge-web/src/lib/authorize.test.ts +++ b/core/grantforge-web/src/lib/authorize.test.ts @@ -4,7 +4,7 @@ // project root for full license text. import { describe, expect, it } from 'vitest' -import { authorizeTarget, federatedSignIn } from './authorize' +import { authorizeProvider, authorizeTarget, federatedSignIn } from './authorize' describe('authorize target', () => { it('follows only the authorization endpoint of this server', () => { @@ -19,6 +19,16 @@ describe('authorize target', () => { }) }) +describe('authorize provider', () => { + it('reads one provider only from a local authorization query', () => { + expect(authorizeProvider('/oauth2/authorize?client_id=a&provider=sso&state=x')).toBe('sso') + expect(authorizeProvider('/oauth2/authorize?provider=sso&provider=other')).toBeNull() + expect(authorizeProvider('/oauth2/authorize?client_id=a#provider=sso')).toBeNull() + expect(authorizeProvider('https://evil.example/oauth2/authorize?provider=sso')).toBeNull() + expect(authorizeProvider(['/oauth2/authorize?provider=sso'])).toBeNull() + }) +}) + describe('federated sign-in', () => { it('starts at the source and comes back to the authorization or the page asked for', () => { expect(federatedSignIn('okta', null, null)).toBe('/api/v1/auth/federated/okta') diff --git a/core/grantforge-web/src/lib/authorize.ts b/core/grantforge-web/src/lib/authorize.ts index 464647b8b..4369eb7f2 100644 --- a/core/grantforge-web/src/lib/authorize.ts +++ b/core/grantforge-web/src/lib/authorize.ts @@ -16,6 +16,15 @@ export function authorizeTarget(value: LocationQueryValue | LocationQueryValue[] return value } +/** The provider requested by a valid local authorization request, if specified exactly once. */ +export function authorizeProvider(value: LocationQueryValue | LocationQueryValue[] | undefined): string | null { + const target = authorizeTarget(value) + if (!target) return null + const params = new URLSearchParams(target.slice(ENDPOINT.length).split('#', 1)[0]) + const providers = params.getAll('provider') + return providers.length === 1 ? providers[0] ?? null : null +} + /** Continues an application's sign-in at the authorization server, which is not a page of the console. */ export function continueAuthorization(target: string): void { window.location.assign(target) diff --git a/core/grantforge-web/src/views/AuthView.test.ts b/core/grantforge-web/src/views/AuthView.test.ts index 228da1dc6..f5ab03174 100644 --- a/core/grantforge-web/src/views/AuthView.test.ts +++ b/core/grantforge-web/src/views/AuthView.test.ts @@ -161,6 +161,70 @@ describe('auth view', () => { second.wrapper.unmount() }) + it('starts a requested provider without displaying the password form and preserves the destination', async () => { + navigation.continueAuthorization.mockReset() + const authorize = encodeURIComponent('/oauth2/authorize?response_type=code&client_id=app') + const first = await mountView(AuthView, { props: { mode: 'login' } }, `/auth/login?provider=casdoor&authorize=${authorize}`) + expect(navigation.continueAuthorization).toHaveBeenCalledWith(`/api/v1/auth/federated/casdoor?authorize=${authorize}`) + expect(first.wrapper.find('form').exists()).toBe(false) + first.wrapper.unmount() + + const second = await mountView(AuthView, { props: { mode: 'login' } }, '/auth/login?provider=casdoor&redirect=/admin/users') + expect(navigation.continueAuthorization).toHaveBeenLastCalledWith('/api/v1/auth/federated/casdoor?redirect=%2Fadmin%2Fusers') + expect(second.wrapper.find('form').exists()).toBe(false) + second.wrapper.unmount() + }) + + it('does not redirect for malformed providers, errors, MFA, or other authentication pages', async () => { + navigation.continueAuthorization.mockReset() + for (const [mode, path] of [ + ['login', '/auth/login?provider=bad%2Fpath'], + ['login', '/auth/login?provider=one&provider=two'], + ['login', '/auth/login?provider=casdoor&federatedError=GF-OTHER'], + ['login', '/auth/login?provider=casdoor&mfa=1'], + ['register', '/auth/register?provider=casdoor'], + ] as const) { + const { wrapper } = await mountView(AuthView, { props: { mode } }, path) + expect(wrapper.find('form').exists()).toBe(true) + wrapper.unmount() + } + expect(navigation.continueAuthorization).not.toHaveBeenCalled() + }) + + it('uses the provider in an OAuth request and preserves the full request', async () => { + navigation.continueAuthorization.mockReset() + const target = '/oauth2/authorize?response_type=code&client_id=app&redirect_uri=http%3A%2F%2Flocalhost%3A3333%2F&scope=openid+profile&state=state1&nonce=nonce1&code_challenge=challenge1&code_challenge_method=S256&provider=sso' + const query = encodeURIComponent(target) + const { wrapper } = await mountView(AuthView, { props: { mode: 'login' } }, `/auth/login?authorize=${query}`) + expect(navigation.continueAuthorization).toHaveBeenCalledTimes(1) + const destination = navigation.continueAuthorization.mock.calls[0]?.[0] as string + expect(destination.startsWith('/api/v1/auth/federated/sso?')).toBe(true) + expect(new URLSearchParams(destination.split('?')[1]).get('authorize')).toBe(target) + expect(wrapper.find('form').exists()).toBe(false) + wrapper.unmount() + + const explicit = await mountView(AuthView, { props: { mode: 'login' } }, `/auth/login?provider=other&authorize=${query}`) + expect(navigation.continueAuthorization).toHaveBeenLastCalledWith(`/api/v1/auth/federated/other?authorize=${query}`) + explicit.wrapper.unmount() + }) + + it('does not automatically redirect for unsafe or ambiguous OAuth providers or MFA', async () => { + navigation.continueAuthorization.mockReset() + for (const [target, extra] of [ + ['https://evil.example/oauth2/authorize?provider=sso', ''], + ['/oauth2/authorize?provider=sso&provider=other', ''], + ['/oauth2/authorize?provider=bad%2Fpath', ''], + ['/oauth2/authorize?provider=sso', '&provider=bad%2Fpath'], + ['/oauth2/authorize?provider=sso', '&mfa=1'], + ['/oauth2/authorize?provider=sso', '&federatedError=GF-OTHER'], + ] as const) { + const { wrapper } = await mountView(AuthView, { props: { mode: 'login' } }, `/auth/login?authorize=${encodeURIComponent(target)}${extra}`) + expect(wrapper.find('form').exists()).toBe(true) + wrapper.unmount() + } + expect(navigation.continueAuthorization).not.toHaveBeenCalled() + }) + it('says which directories sign in on the form, asking the server again each time it opens', async () => { api.request.mockImplementation((path: string) => Promise.resolve(path === '/api/v1/bootstrap' ? { setupRequired: false, registrationEnabled: false, signInSources: [], directories: [{ code: 'corp', name: 'Corporate LDAP' }] } : null)) diff --git a/core/grantforge-web/src/views/AuthView.vue b/core/grantforge-web/src/views/AuthView.vue index 3a83464e3..b765ab555 100644 --- a/core/grantforge-web/src/views/AuthView.vue +++ b/core/grantforge-web/src/views/AuthView.vue @@ -15,7 +15,7 @@ import LocalePicker from '@/components/LocalePicker.vue' import { useI18n } from 'vue-i18n' import { useAuth } from '@/stores/auth' import { useBootstrap } from '@/stores/bootstrap' -import { authorizeTarget, continueAuthorization, federatedSignIn } from '@/lib/authorize' +import { authorizeProvider, authorizeTarget, continueAuthorization, federatedSignIn } from '@/lib/authorize' import { ApiError, errorMessage, request } from '@/lib/api' import { useFieldErrors, type FieldErrors } from '@/lib/fieldErrors' import type { components } from '@/api/schema' @@ -32,6 +32,13 @@ const codeInput = useTemplateRef('codeInput') const id = useId(), register = computed(() => mode === 'register'), setup = computed(() => mode === 'setup') // Register and setup both create an account, so both ask for the password twice. const newAccount = computed(() => mode !== 'login') +// Only identity-source codes are accepted; the server checks that the source is enabled and supports OIDC. +const autoProvider = computed(() => { + // An explicit login-page provider takes precedence over the OAuth request's provider. + const provider = route.query.provider === undefined ? authorizeProvider(route.query.authorize) : route.query.provider + return mode === 'login' && route.query.mfa !== '1' && !route.query.federatedError && + typeof provider === 'string' && /^[a-z][a-z0-9-]{1,63}$/.test(provider) ? provider : null +}) // A provider's sign-in comes back here for the second step, or with the code of its refusal. const federatedErrors: Record = { 'GF-IDENTITY-116': 'auth.federatedConflict', 'GF-IDENTITY-117': 'auth.federatedUnknown', 'GF-IDENTITY-021': 'auth.federatedLocked', @@ -42,12 +49,13 @@ onMounted(() => { const refused = route.query.federatedError if (typeof refused === 'string') formError.value = t(federatedErrors[refused] ?? 'auth.federatedFailed') if (mode === 'login' && route.query.mfa === '1') { secondStep.value = true; void nextTick(() => codeInput.value?.focus()); return } - ;(setup.value ? tokenInput : usernameInput).value?.focus() + if (!autoProvider.value) (setup.value ? tokenInput : usernameInput).value?.focus() }) function signInWith(code: string) { const redirect = route.query.redirect continueAuthorization(federatedSignIn(code, authorizeTarget(route.query.authorize), typeof redirect === 'string' ? redirect : null)) } +watch(autoProvider, provider => { if (provider) signInWith(provider) }, { immediate: true }) watch(() => mode, () => { formError.value = ''; fieldErrors.value = {}; password.value = ''; confirmation.value = ''; registered.value = false; secondStep.value = false }) /** Where a completed sign-in goes: back to an application's authorization, or into the console. */ async function proceed() { @@ -131,7 +139,8 @@ async function submit() {
GrantForge
- +

{{ t('auth.signInWith', { name: autoProvider }) }}

+