Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion core/grantforge-web/src/lib/authorize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// project root for full license text.

import { describe, expect, it } from 'vitest'
import { authorizeTarget, federatedSignIn } from './authorize'
import { providerFromRequest, authorizeTarget, federatedSignIn } from './authorize'

describe('authorize target', () => {
it('follows only the authorization endpoint of this server', () => {
Expand All @@ -19,6 +19,16 @@ describe('authorize target', () => {
})
})

describe('authorize provider', () => {
it('reads one provider only from a local authorization query', () => {
expect(providerFromRequest('/oauth2/authorize?client_id=a&provider=sso&state=x')).toBe('sso')
expect(providerFromRequest('/oauth2/authorize?provider=sso&provider=other')).toBeNull()
expect(providerFromRequest('/oauth2/authorize?client_id=a#provider=sso')).toBeNull()
expect(providerFromRequest('https://evil.example/oauth2/authorize?provider=sso')).toBeNull()
expect(providerFromRequest(['/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')
Expand Down
9 changes: 9 additions & 0 deletions core/grantforge-web/src/lib/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 providerFromRequest(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)
Expand Down
64 changes: 64 additions & 0 deletions core/grantforge-web/src/views/AuthView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
16 changes: 13 additions & 3 deletions core/grantforge-web/src/views/AuthView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 { providerFromRequest, 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'
Expand All @@ -32,6 +32,14 @@ const codeInput = useTemplateRef<HTMLInputElement>('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 requestedProvider = providerFromRequest(route.query.authorize)
const provider = route.query.provider === undefined ? requestedProvider : 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<string, 'auth.federatedConflict' | 'auth.federatedUnknown' | 'auth.federatedLocked' | 'auth.federatedDisabled'> = {
'GF-IDENTITY-116': 'auth.federatedConflict', 'GF-IDENTITY-117': 'auth.federatedUnknown', 'GF-IDENTITY-021': 'auth.federatedLocked',
Expand All @@ -42,12 +50,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() {
Expand Down Expand Up @@ -131,7 +140,8 @@ async function submit() {
<div class="absolute right-5 top-5 xl:right-8 xl:top-8"><LocalePicker /></div>
<div class="mb-10 flex items-center gap-3 lg:hidden"><img src="/static/images/grantforge-logo.png" alt="" class="size-9" /><span class="text-xl font-semibold">GrantForge</span></div>
<div class="w-full max-w-[380px]">
<template v-if="registered"><span class="mb-6 flex size-14 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600"><Check :size="28" /></span><h2 class="text-2xl font-semibold">{{ setup ? t('auth.setupDone') : t('auth.registered') }}</h2><p class="mt-3 text-sm leading-6 text-muted">{{ setup ? t('auth.setupDoneText') : t('auth.registeredText') }}</p><RouterLink to="/auth/login" class="mt-8 block"><UiButton class="w-full">{{ t('auth.goToLogin') }} <ArrowRight :size="16" /></UiButton></RouterLink></template>
<p v-if="autoProvider" class="text-sm text-muted" role="status">{{ t('auth.signInWith', { name: autoProvider }) }}</p>
<template v-else-if="registered"><span class="mb-6 flex size-14 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600"><Check :size="28" /></span><h2 class="text-2xl font-semibold">{{ setup ? t('auth.setupDone') : t('auth.registered') }}</h2><p class="mt-3 text-sm leading-6 text-muted">{{ setup ? t('auth.setupDoneText') : t('auth.registeredText') }}</p><RouterLink to="/auth/login" class="mt-8 block"><UiButton class="w-full">{{ t('auth.goToLogin') }} <ArrowRight :size="16" /></UiButton></RouterLink></template>
<template v-else>
<p class="eyebrow mb-3 text-brand">WELCOME TO GRANTFORGE</p><h2 class="text-[28px] font-semibold tracking-tight">{{ secondStep ? t('mfa.signInTitle') : setup ? t('auth.setupTitle') : register ? t('auth.registerTitle') : t('auth.loginTitle') }}</h2><p class="mb-9 mt-3 text-[13px] text-muted">{{ secondStep ? t('mfa.signInSubtitle') : setup ? t('auth.setupSubtitle') : register ? t('auth.registerSubtitle') : t('auth.loginSubtitle') }}</p>
<div v-if="formError" class="mb-5 flex items-start gap-2 rounded-xl bg-rose-50 p-3 text-xs leading-5 text-rose-700" role="alert"><AlertCircle :size="16" class="mt-0.5 shrink-0" />{{ formError }}</div>
Expand Down
4 changes: 4 additions & 0 deletions docs/content/de/guide/identity-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ Die tragenden Protokollparameter (darunter `state`, `nonce`, `redirect_uri` und

Nach dem Aktivieren erscheint auf der Anmeldeseite die Schaltfläche „Mit <Name> anmelden“. Meldet sich der Benutzer beim Provider an, kehrt er zu GrantForge zurück; Konten mit aktiver Zwei-Faktor-Authentifizierung müssen zusätzlich einen Code eingeben.

Mit `provider=dingtalk` in der Adresse `https://auth.example.com/#/auth/login?provider=dingtalk` wird direkt die Anmeldung der aktivierten OIDC-Identitätsquelle geöffnet. Der Wert ist deren Code in GrantForge. Mit `redirect` lässt sich ein Konsolenpfad wie `#/auth/login?provider=dingtalk&redirect=%2Fadmin%2Fusers` festlegen. Bei Fehlern erscheint die normale Anmeldung; bestehende Sitzungen und die Zwei-Faktor-Anmeldung bleiben unverändert.

OAuth-Clients können `provider=dingtalk` auch an `/oauth2/authorize` übergeben. Die Anmeldung übernimmt den gesamten Auftrag einschließlich `state`, `nonce` und PKCE. Ein `provider` direkt an der Anmeldeseite hat Vorrang.

## Kontoregeln

- Die eindeutige Kennung eines Benutzers aus der Identitätsquelle (`entryUUID`/`objectGUID` bei LDAP, `sub` bei OIDC) entspricht genau einem GrantForge-Konto; eine Umbenennung ändert an dieser Zuordnung nichts.
Expand Down
4 changes: 4 additions & 0 deletions docs/content/en/guide/identity-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ GrantForge manages the protocol's own parameters (including `state`, `nonce`, `r

Once enabled, a "Sign in with <name>" button appears on the sign-in page. After signing in at the provider, the user returns to GrantForge; accounts with two-step verification enabled must also enter a verification code.

To go straight to an enabled OIDC identity source, add its GrantForge source code as `provider`, for example `https://auth.example.com/#/auth/login?provider=dingtalk`.

An OAuth client can also add `provider=dingtalk` to its `/oauth2/authorize` request; the sign-in page reads it and preserves the authorization request, including state, nonce, and PKCE. A `provider` specified directly on the login page takes precedence. Failed sign-ins return to the normal sign-in page, and two-step verification still applies.

## Account rules

- An identity source user's unique identifier (`entryUUID`/`objectGUID` for LDAP, `sub` for OIDC) maps to one GrantForge account; renaming does not affect the mapping.
Expand Down
4 changes: 4 additions & 0 deletions docs/content/es/guide/identity-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ GrantForge gestiona los parámetros esenciales del protocolo (incluidos `state`,

Una vez activada, en la página de inicio de sesión aparece el botón "Iniciar sesión con <nombre>". El usuario inicia sesión en el proveedor y vuelve a GrantForge; las cuentas con la verificación en dos pasos activada deben introducir además un código de verificación.

Con `provider=dingtalk` en `https://auth.example.com/#/auth/login?provider=dingtalk` se abre directamente el acceso de la fuente OIDC habilitada. El valor es el código de la fuente en GrantForge. `redirect` permite indicar una ruta de la consola, por ejemplo `#/auth/login?provider=dingtalk&redirect=%2Fadmin%2Fusers`. Si falla, aparece el acceso normal; se mantienen las sesiones existentes y la verificación en dos pasos.

Un cliente OAuth también puede incluir `provider=dingtalk` en `/oauth2/authorize`. Se conserva toda la solicitud, incluidos `state`, `nonce` y PKCE. El `provider` de la página de acceso tiene prioridad.

## Reglas de las cuentas

- El identificador único del usuario de la fuente de identidad (`entryUUID`/`objectGUID` en LDAP, `sub` en OIDC) se corresponde con una cuenta de GrantForge; cambiar de nombre no afecta a esa correspondencia.
Expand Down
4 changes: 4 additions & 0 deletions docs/content/fr/guide/identity-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ Les paramètres essentiels du protocole (dont `state`, `nonce`, `redirect_uri` e

Une fois l’option activée, la page de connexion affiche un bouton « Se connecter avec <nom> ». L’utilisateur se connecte chez le fournisseur puis revient sur GrantForge ; les comptes ayant activé l’authentification à deux facteurs doivent en plus saisir un code.

Avec `provider=dingtalk` dans `https://auth.example.com/#/auth/login?provider=dingtalk`, la connexion de la source OIDC activée s’ouvre directement. La valeur est le code de la source dans GrantForge. `redirect` indique une page de la console, par exemple `#/auth/login?provider=dingtalk&redirect=%2Fadmin%2Fusers`. En cas d’échec, la connexion normale réapparaît ; les sessions et la vérification en deux étapes restent inchangées.

Un client OAuth peut aussi ajouter `provider=dingtalk` à `/oauth2/authorize`. La requête complète, avec `state`, `nonce` et PKCE, est conservée. Le `provider` de la page de connexion est prioritaire.

## Règles relatives aux comptes

- L’identifiant unique d’un utilisateur issu d’une source d’identité (`entryUUID`/`objectGUID` pour LDAP, `sub` pour OIDC) correspond à un compte GrantForge ; un changement de nom ne modifie pas cette correspondance.
Expand Down
4 changes: 4 additions & 0 deletions docs/content/it/guide/identity-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ GrantForge gestisce i parametri essenziali del protocollo (compresi `state`, `no

Una volta abilitata, nella pagina di accesso compare il pulsante “Accedi con <nome>”. L’utente accede presso il fornitore e torna in GrantForge; gli account con l’autenticazione a due fattori attivata devono inserire anche un codice di verifica.

Con `provider=dingtalk` in `https://auth.example.com/#/auth/login?provider=dingtalk` si apre direttamente l’accesso della sorgente OIDC abilitata. Il valore è il codice della sorgente in GrantForge. Con `redirect` si indica un percorso della console, per esempio `#/auth/login?provider=dingtalk&redirect=%2Fadmin%2Fusers`. In caso di errore riappare l’accesso normale; sessioni e verifica in due passaggi restano invariati.

Un client OAuth può aggiungere `provider=dingtalk` a `/oauth2/authorize`. La richiesta completa, con `state`, `nonce` e PKCE, viene mantenuta. Il `provider` della pagina di accesso ha precedenza.

## Regole sugli account

- L’identificatore univoco dell’utente della fonte di identità (`entryUUID`/`objectGUID` in LDAP, `sub` in OIDC) corrisponde a un account GrantForge; il cambio di nome non altera questa corrispondenza.
Expand Down
4 changes: 4 additions & 0 deletions docs/content/ja/guide/identity-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ description: 会社のディレクトリ(LDAP/AD)や OpenID Connect プロ

有効にすると、ログインページに「<名前> でログイン」ボタンが表示されます。ユーザーはプロバイダーでログインしたあと GrantForge に戻り、二段階認証を有効にしたアカウントは認証コードの入力も必要です。

`https://auth.example.com/#/auth/login?provider=dingtalk` の `provider=dingtalk` を指定すると、有効な OIDC 身元ソースのログイン画面に直接移動します。値は GrantForge のソースコードです。`redirect` で `#/auth/login?provider=dingtalk&redirect=%2Fadmin%2Fusers` のようにコンソールの移動先を指定できます。失敗時は通常のログイン画面に戻り、既存のセッションと二段階認証は従来どおりです。

OAuth クライアントは `/oauth2/authorize` に `provider=dingtalk` を追加することもできます。`state`、`nonce`、PKCE を含む認可要求はそのまま保持されます。ログイン画面の `provider` が優先されます。

## アカウントのルール

- アイデンティティソースのユーザーの一意識別子(LDAP は `entryUUID`/`objectGUID`、OIDC は `sub`)は 1 つの GrantForge アカウントに対応し、名前が変わっても対応関係は変わりません。
Expand Down
4 changes: 4 additions & 0 deletions docs/content/ko/guide/identity-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ description: 회사 디렉터리(LDAP/AD)나 OpenID Connect 제공자로 사용

활성화하면 로그인 페이지에 "<이름>으로 로그인" 버튼이 나타납니다. 사용자는 제공자에서 로그인한 뒤 GrantForge로 돌아오며, 2단계 인증을 켠 계정은 인증 코드도 입력해야 합니다.

`https://auth.example.com/#/auth/login?provider=dingtalk`에서 `provider=dingtalk`를 지정하면 활성화된 OIDC 인증 소스의 로그인 페이지로 바로 이동합니다. 값은 GrantForge에 등록된 소스 코드입니다. `redirect`로 `#/auth/login?provider=dingtalk&redirect=%2Fadmin%2Fusers`처럼 콘솔 경로를 지정할 수 있습니다. 실패하면 일반 로그인 화면으로 돌아가며 기존 세션과 2단계 인증은 그대로 유지됩니다.

OAuth 클라이언트는 `/oauth2/authorize`에 `provider=dingtalk`를 추가할 수도 있습니다. `state`, `nonce`, PKCE를 포함한 인증 요청은 그대로 유지됩니다. 로그인 페이지의 `provider`가 우선합니다.

## 계정 규칙

- 신원 소스 사용자의 고유 식별자(LDAP는 `entryUUID`/`objectGUID`, OIDC는 `sub`)는 GrantForge 계정 하나에 대응되며, 이름이 바뀌어도 대응 관계는 그대로입니다.
Expand Down
Loading
Loading