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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to `dskripchenko/laravel-admin` will be documented in this f
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added

- **`Admin::logoutRedirect()` — where the browser goes after a sign-out**
(`Auth\LogoutRedirectRegistry`), per panel: an identity provider's own
sign-out page, so that leaving the panel ends the session the person
signed in with (RP-initiated logout). The resolver runs before the session
is cleared; its address — absolute http(s) or a path, anything else is
ignored — comes back from `auth/logout` as `payload.redirect`, and the SPA
navigates there instead of opening its own login page. The auth store's
`logout()` now returns that address or null.

## 1.50.2

### Fixed
Expand Down
29 changes: 29 additions & 0 deletions docs/de/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,35 @@ return redirect('/admin/login');
Die Meldung liegt in der Session, bis der nächste Bootstrap sie abholt; sie
erscheint daher in beiden Bootstrap-Strategien genau einmal.

### Auch beim Anbieter abmelden

Ein Panel, in das man sich über einen Identitätsanbieter anmeldet, kann auch
diese Sitzung beenden („RP-initiated logout“): Registrieren Sie, wohin der
Browser nach der Abmeldung geht.

```php
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;

$admin->logoutRedirect(function (Request $request, string $panel, ?Authenticatable $user): ?string {
$idToken = $request->session()->get('sso.id_token');

return $idToken === null ? null : 'https://sso.example/logout?'.http_build_query([
'id_token_hint' => $idToken,
'post_logout_redirect_uri' => url('/admin/login'),
]);
});
```

Der Resolver läuft, bevor die Session des Panels geleert wird — was die
Anmeldung dort abgelegt hat, ist noch lesbar. Er gibt eine absolute
http(s)-Adresse oder einen Pfad zurück, oder null, wenn er nicht zuständig
ist; die erste Adresse gewinnt, die Antwort der Abmeldung trägt sie als
`payload.redirect`. Die SPA navigiert dann dorthin statt zur eigenen
Anmeldeseite. Andere Adressen werden ignoriert, und ein Resolver, der eine
Ausnahme wirft, wird protokolliert und übersprungen — die Abmeldung selbst ist
bereits erfolgt.

## 3. Pfad, Domain, API, Sessions, Proxys

| Einstellung | Standard | Wirkung |
Expand Down
28 changes: 28 additions & 0 deletions docs/en/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,34 @@ return redirect('/admin/login');
The notice lives in the session until the next bootstrap takes it, so it is
shown once in both bootstrap strategies.

### Signing out at the provider too

A panel whose people sign in through an identity provider can end that
session as well ("RP-initiated logout"): register where the browser goes after
the sign-out.

```php
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;

$admin->logoutRedirect(function (Request $request, string $panel, ?Authenticatable $user): ?string {
$idToken = $request->session()->get('sso.id_token');

return $idToken === null ? null : 'https://sso.example/logout?'.http_build_query([
'id_token_hint' => $idToken,
'post_logout_redirect_uri' => url('/admin/login'),
]);
});
```

The resolver runs before the panel's session is cleared, so what the sign-in
kept there is still readable. It returns an absolute http(s) address or a
path, or null to stay out of it; the first address wins and the logout's
answer carries it as `payload.redirect`. The SPA then navigates there instead
of opening its own login page. Any other address is ignored, and a resolver
that throws is reported and skipped — the sign-out itself has already
happened.

## 3. Path, domain, API, sessions, proxies

| Setting | Default | Effect |
Expand Down
27 changes: 27 additions & 0 deletions docs/ru/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,33 @@ return redirect('/admin/login');
Сообщение лежит в сессии, пока его не заберёт следующий bootstrap, поэтому
показывается один раз при обеих стратегиях bootstrap.

### Выход и у провайдера

Панель, в которую входят через провайдера идентификации, может завершать и
эту сессию (RP-initiated logout): зарегистрируйте, куда браузер уходит после
выхода.

```php
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;

$admin->logoutRedirect(function (Request $request, string $panel, ?Authenticatable $user): ?string {
$idToken = $request->session()->get('sso.id_token');

return $idToken === null ? null : 'https://sso.example/logout?'.http_build_query([
'id_token_hint' => $idToken,
'post_logout_redirect_uri' => url('/admin/login'),
]);
});
```

Резолвер вызывается до очистки сессии панели — то, что сохранил вход, ещё
можно прочитать. Он возвращает абсолютный http(s)-адрес или путь либо null,
если его это не касается; побеждает первый адрес, ответ выхода несёт его в
`payload.redirect`. SPA переходит туда вместо своего экрана входа. Другие
адреса игнорируются, а резолвер, бросивший исключение, логируется и
пропускается — сам выход уже состоялся.

## 3. Путь, домен, API, сессии, прокси

| Настройка | По умолчанию | Что делает |
Expand Down
20 changes: 20 additions & 0 deletions docs/zh/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,26 @@ return redirect('/admin/login');

该提示保存在会话中,直到下一次 bootstrap 将其取走,因此在两种 bootstrap 策略下都只显示一次。

### 同时在身份提供方处退出

通过身份提供方登录的面板也可以结束那边的会话(RP-initiated logout):注册退出后浏览器跳转的地址。

```php
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;

$admin->logoutRedirect(function (Request $request, string $panel, ?Authenticatable $user): ?string {
$idToken = $request->session()->get('sso.id_token');

return $idToken === null ? null : 'https://sso.example/logout?'.http_build_query([
'id_token_hint' => $idToken,
'post_logout_redirect_uri' => url('/admin/login'),
]);
});
```

解析器在清除面板会话之前运行,因此登录时保存在会话中的内容仍可读取。它返回绝对的 http(s) 地址或路径,不相关时返回 null;第一个地址生效,退出接口的响应以 `payload.redirect` 返回它。SPA 随后跳转到该地址,而不是打开自己的登录页。其他地址会被忽略;抛出异常的解析器会被记录并跳过——退出本身已经完成。

## 3. 路径、域名、API、会话、代理

| 设置 | 默认 | 作用 |
Expand Down
2 changes: 1 addition & 1 deletion public/.vite/manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"resources/ts/app.ts": {
"file": "assets/app-CpTQowWs.js",
"file": "assets/app-Dfg_kc-5.js",
"name": "app",
"src": "resources/ts/app.ts",
"isEntry": true,
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/source-hash.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
baf3db5770d65a53e651d694d55a148796b1c4d728acfd4a961476c8a02b5f90
a313403c96666a9a9c634b4152d81adf6b709326637842a9aba69c2b7a6123f8
27 changes: 27 additions & 0 deletions resources/ts/components/shell/widgets/UserMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,31 @@ describe('UserMenu logout integration', () => {
// the component; here we check only the main effects on the store.
wrapper.unmount()
})

it('follows the redirect of the logout, or opens the login page', async () => {
const assign = vi.fn()
vi.stubGlobal('location', { ...window.location, assign })
useAuthStore().hydrate(mkBootstrap({ user: mkUser() }))
const wrapper = await mountMenu()
await wrapper.find('.admin-user-menu__trigger').trigger('click')
await flushPromises()
const item = () => wrapper.findAllComponents({ name: 'UidMenuItem' }).find((c) => c.props('variant') === 'danger')
expect(item()).toBeTruthy()

mock.onPost('/auth/logout').replyOnce(200, { success: true, payload: { redirect: 'https://sso.example/logout' } })
item()!.vm.$emit('click')
await flushPromises()
expect(assign).toHaveBeenCalledWith('https://sso.example/logout')
expect(pushSpy).not.toHaveBeenCalled()

useAuthStore().hydrate(mkBootstrap({ user: mkUser() }))
mock.onPost('/auth/logout').replyOnce(200, { success: true, payload: {} })
item()!.vm.$emit('click')
await flushPromises()
expect(pushSpy).toHaveBeenCalledWith({ name: 'admin.login' })
expect(assign).toHaveBeenCalledTimes(1)

vi.unstubAllGlobals()
wrapper.unmount()
})
})
11 changes: 9 additions & 2 deletions resources/ts/components/shell/widgets/UserMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,17 @@ const auth = useAuthStore()
const router = useRouter()

async function logout(): Promise<void> {
let redirect: string | null = null
try {
await auth.logout()
redirect = await auth.logout()
} finally {
await router.push({ name: 'admin.login' })
if (redirect !== null) {
// The identity provider's own sign-out: a full navigation, it comes
// back to the login page by itself.
window.location.assign(redirect)
} else {
await router.push({ name: 'admin.login' })
}
}
}

Expand Down
18 changes: 18 additions & 0 deletions resources/ts/stores/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,24 @@ describe('auth store', () => {
expect(auth.permissions).toEqual([])
})

it('returns where to go next when the panel signs out at the provider too', async () => {
const auth = useAuthStore()
mock.onPost('/auth/logout').reply(200, { success: true, payload: { redirect: 'https://sso.example/logout?x=1' } })

expect(await auth.logout()).toBe('https://sso.example/logout?x=1')
expect(auth.user).toBeNull()
})

it('returns null without a redirect, and on an API error', async () => {
const auth = useAuthStore()
mock.onPost('/auth/logout').replyOnce(200, { success: true, payload: { redirect: null } })
expect(await auth.logout()).toBeNull()

mock.onPost('/auth/logout').replyOnce(500)
await expect(auth.logout()).rejects.toBeTruthy()
expect(auth.user).toBeNull()
})

it('clears state even on API error', async () => {
const auth = useAuthStore()
auth.hydrate(mkBootstrap({
Expand Down
11 changes: 8 additions & 3 deletions resources/ts/stores/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,16 @@ export const useAuthStore = defineStore('admin-auth', () => {
pendingChallenge.value = null
}

/** POST /auth/logout. Clears the store. */
async function logout(): Promise<void> {
/**
* POST /auth/logout. Clears the store. Returns where the browser goes next
* when the panel signs people out at their identity provider too, or null —
* the panel's own login page.
*/
async function logout(): Promise<string | null> {
const client = getAdminClient()
try {
await client.post('/auth/logout')
const result = await client.post<{ redirect?: string | null }>('/auth/logout')
return typeof result?.redirect === 'string' && result.redirect !== '' ? result.redirect : null
} finally {
user.value = null
permissions.value = []
Expand Down
17 changes: 17 additions & 0 deletions src/Admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,23 @@ public function externalLogins(callable $resolver): self
return $this;
}

/**
* Registers a resolver of where the browser goes after a sign-out from the
* panel being registered — an identity provider's own sign-out page, for
* one (see {@see Auth\LogoutRedirectRegistry}).
*
* $admin->logoutRedirect(fn (Request $request, string $panel, ?Authenticatable $user) =>
* $request->session()->has('sso.id_token') ? 'https://sso.example/logout?...' : null);
*
* @param callable(\Illuminate\Http\Request, string, \Illuminate\Contracts\Auth\Authenticatable|null): (string|null) $resolver
*/
public function logoutRedirect(callable $resolver): self
{
$this->app->make(Auth\LogoutRedirectRegistry::class)->add($this->registrationPanel, $resolver);

return $this;
}

public function version(): string
{
return '0.1.0-dev';
Expand Down
1 change: 1 addition & 0 deletions src/AdminServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public function register(): void
$this->app->singleton(Plugin\PluginRegistry::class);
$this->app->singleton(Panel\PanelRegistry::class);
$this->app->singleton(Auth\ExternalLoginRegistry::class);
$this->app->singleton(Auth\LogoutRedirectRegistry::class);

// Tenancy is bound as `scoped()` — per request — rather than as a
// singleton: in long-running runtimes (Octane, queue workers) the
Expand Down
14 changes: 11 additions & 3 deletions src/Auth/Controllers/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,11 @@ public function twoFactorRecovery(Request $request): JsonResponse
/**
* Ends the session.
*
* @output object $payload Empty: the action answers with the envelope alone
* When the panel signs people out at their identity provider too
* (Admin::logoutRedirect), the payload says where the browser goes next.
*
* @output object $payload
* @output string ?$payload.redirect Where to send the browser after the sign-out; null — the panel's own login page
*
* @security AdminSession
* @security AdminBearer
Expand All @@ -304,17 +308,21 @@ public function twoFactorRecovery(Request $request): JsonResponse
*/
public function logout(Request $request): JsonResponse
{
$guard = \Dskripchenko\LaravelAdmin\Panel\Panels::currentGuard();
$panel = \Dskripchenko\LaravelAdmin\Panel\Panels::current();
$guard = $panel->guard;
$user = Auth::guard($guard)->user();

// Before the session goes: a resolver reads what the sign-in kept there.
$redirect = app(\Dskripchenko\LaravelAdmin\Auth\LogoutRedirectRegistry::class)->for($request, $panel->id, $user);

Auth::guard($guard)->logout();

if ($request->hasSession()) {
$request->session()->invalidate();
$request->session()->regenerateToken();
}

return $this->success([]);
return $this->success(['redirect' => $redirect]);
}

/**
Expand Down
65 changes: 65 additions & 0 deletions src/Auth/LogoutRedirectRegistry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace Dskripchenko\LaravelAdmin\Auth;

use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;

/**
* Where the browser goes after a sign-out, per panel: an identity provider's
* own sign-out ("RP-initiated logout"), so that leaving the panel ends the
* session the person signed in with, not just the panel's.
*
* A resolver runs before the panel's session is cleared — what it needs (an
* ID token kept at sign-in) is still there — and returns an absolute http(s)
* address or a path, or null to stay out of it. The first address wins. The
* SPA follows it instead of opening its own login page.
*
* A resolver that throws is reported and skipped: the sign-out itself has
* already happened and must not fail because a provider is down.
*/
final class LogoutRedirectRegistry
{
/** @var array<string, list<callable(Request, string, Authenticatable|null): (string|null)>> */
private array $resolvers = [];

/**
* @param callable(Request, string, Authenticatable|null): (string|null) $resolver
* Gets the request, the panel's id and the user signing out.
*/
public function add(string $panel, callable $resolver): void
{
$this->resolvers[$panel][] = $resolver;
}

public function for(Request $request, string $panel, ?Authenticatable $user): ?string
{
foreach ($this->resolvers[$panel] ?? [] as $resolver) {
try {
$url = $resolver($request, $panel, $user);
} catch (\Throwable $e) {
report($e);

continue;
}
if (is_string($url) && self::followable($url)) {
return $url;
}
}

return null;
}

/** An absolute http(s) address or a path — never a script or a data URL. */
private static function followable(string $url): bool
{
if (str_starts_with($url, '/')) {
return ! str_starts_with($url, '//');
}
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));

return in_array($scheme, ['http', 'https'], true) && is_string(parse_url($url, PHP_URL_HOST));
}
}
Loading
Loading