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
27 changes: 27 additions & 0 deletions docs/content/auth-flows/logout-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,33 @@ With redirect disabled, the ordinary endpoint returns:

With redirect enabled, it returns an HTTP redirect to the resolved target instead. Applications should not expect the JSON success body in that case.

### Client-controlled navigation

The UltimateAuth Client API allows applications to control navigation after a successful logout through the optional `navigate` parameter.

By default, logout follows the server-configured redirect behavior:

```csharp
await UAuthClient.Flows.LogoutAsync();
```

To complete logout without automatic navigation, set `navigate` to `false`:

```csharp
await UAuthClient.Flows.LogoutAsync(navigate: false);

// Perform application-specific cleanup after logout.
await YourApplicationCleanupAsync();

Navigation.NavigateTo("/goodbye");
```

When `navigate` is `false`, UltimateAuth still performs the server-side logout, clears applicable authentication cookies, and publishes the logout event so the client authentication state can be cleared. Only automatic navigation is suppressed.

This is useful when an application needs to clear its own state, perform additional cleanup, display a confirmation, or choose a destination after logout.

**Important:** The `navigate` parameter controls client-side navigation, not session termination or server-side redirect configuration. If logout fails, including with HTTP 429, the operation does not publish a successful logout event. Applications should perform post-logout actions only after `LogoutAsync` completes successfully.

## Why redirect matters

A redirect is **not required to invalidate authentication state**. It is a navigation concern that becomes important in browser-based authentication flows.
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
Continue
</MudButton>

<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@UAuthClient.Flows.LogoutAsync">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(() => UAuthClient.Flows.LogoutAsync())">
Switch account
</MudButton>
</MudStack>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
Continue
</MudButton>

<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@UAuthClient.Flows.LogoutAsync">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(() => UAuthClient.Flows.LogoutAsync())">
Switch account
</MudButton>
</MudStack>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,26 @@ public async Task<UAuthTransportResult> SendFormAsync(string endpoint, IDictiona

public async Task<UAuthTransportResult> SendFormAsync(string endpoint, IDictionary<string, string>? form, bool navigateOnSuccess, CancellationToken ct = default)
{
if (!navigateOnSuccess) return await SendFormAsync(endpoint, form, ct);
ct.ThrowIfCancellationRequested();

await _bootstrapper.EnsureStartedAsync();
var result = await _js.InvokeAsync<UAuthTransportResult>("uauth.post", ct, new
{
url = endpoint,
mode = "logout",
data = form,
clientProfile = _options.ClientProfile.ToString(),
trustedOrigin = ResolveTrustedOrigin()
});
if (result is null) throw new UAuthProtocolException("Invalid logout transport response.");
if (result.Status == 0) throw new UAuthTransportException("Could not confirm server logout.");

var result = await _js.InvokeAsync<UAuthTransportResult>("uauth.post", ct,
new
{
url = endpoint,
mode = navigateOnSuccess ? "logout" : "logout-observe",
data = form,
clientProfile = _options.ClientProfile.ToString(),
trustedOrigin = ResolveTrustedOrigin()
});

if (result is null)
throw new UAuthProtocolException("Invalid logout transport response.");

if (result.Status == 0)
throw new UAuthTransportException("Could not confirm server logout.");

return result;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ window.uauth.post = async function (options) {
return null;
}

const isLogout =
mode === "logout" ||
mode === "logout-observe";

return window.uauth.request({
url: validatedUrl,
method: "POST",
Expand All @@ -219,7 +223,9 @@ window.uauth.post = async function (options) {
credentials: "include",
allowCrossOriginCredentials: true,
navigateOnSuccess: mode === "logout",
headers: mode === "logout" ? { "X-UAuth-Logout-Mode": "observe" } : {}
headers: isLogout
? { "X-UAuth-Logout-Mode": "observe" }
: {}
});
};

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@ public interface IFlowClient
/// <summary>
/// Logs out the current user.
/// </summary>
/// <remarks>
/// This clears the current session and may trigger redirects.
/// </remarks>
Task LogoutAsync();
/// <param name="navigate">
/// Whether to navigate to the server-provided logout destination after a successful logout. Defaults to true.
/// </param>
Task LogoutAsync(bool navigate = true);

/// <summary>
/// Refreshes the current session or tokens.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,17 +118,19 @@ public async Task<TryLoginResult> TryLoginAsync(LoginRequest request, UAuthSubmi
}
}

public async Task LogoutAsync()
public async Task LogoutAsync(bool navigate = true)
{
var url = Url(_options.Endpoints.Logout);
var raw = await _post.SendFormAsync(url, null, navigateOnSuccess: true);
var raw = await _post.SendFormAsync(url, null, navigateOnSuccess: navigate);

EnsureValidHttpStatus(raw);

if (raw.Status is < 200 or >= 300)
throw new UAuthTransportException("Server logout did not succeed.", (HttpStatusCode)raw.Status);

await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.LogoutVariant, _options.StateEvents.HandlingMode));
}


public async Task<RefreshResult> RefreshAsync(bool isAuto = false)
{
if (!isAuto)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,41 @@ public async Task Logout_Should_Observe_Server_Response()
mock.Verify(x => x.SendFormAsync("/auth/logout", null, true, It.IsAny<CancellationToken>()), Times.Once);
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Logout_Should_Respect_Navigate_Option_And_Publish_Event(bool navigate)
{
var request = new Mock<IUAuthRequestClient>();
var events = new Mock<IUAuthClientEvents>();

request.Setup(x => x.SendFormAsync(
"/auth/logout",
null,
navigate,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new UAuthTransportResult
{
Status = 200,
Ok = true
});

var client = CreateFlowClient(request, events);

await client.LogoutAsync(navigate: navigate);

request.Verify(x => x.SendFormAsync(
"/auth/logout",
null,
navigate,
It.IsAny<CancellationToken>()), Times.Once);

events.Verify(x => x.PublishAsync(
It.Is<UAuthStateEventArgs>(e =>
e.Type == UAuthStateEvent.LogoutVariant)),
Times.Once);
}

[Fact]
public async Task Refresh_Should_Not_Mark_Manual_When_Auto()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public Task<UAuthResult> LogoutAllUserDevicesAsync(UserKey userKey)
throw new NotImplementedException();
}

public Task LogoutAsync()
public Task LogoutAsync(bool navigation = true)
{
throw new NotImplementedException();
}
Expand Down
Loading