diff --git a/docs/content/auth-flows/logout-flow.md b/docs/content/auth-flows/logout-flow.md index 1b9baaa9..34385342 100644 --- a/docs/content/auth-flows/logout-flow.md +++ b/docs/content/auth-flows/logout-flow.md @@ -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. diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/auth-flows/logout-flow.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/auth-flows/logout-flow.json index 2dad947d..3559b544 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/auth-flows/logout-flow.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/auth-flows/logout-flow.json @@ -1,7 +1,7 @@ { "Slug": "auth-flows/logout-flow", "Title": "Logout", - "Html": "\n\u003Cp\u003EAuthentication does not end when a client deletes a cookie or forgets a token. In UltimateAuth, ending access is a \u003Cstrong\u003Eserver-side lifecycle decision\u003C/strong\u003E: the system determines which authentication state must stop being usable, at what scope, and whether the associated device relationship should survive a future login.\u003C/p\u003E\n\u003Cp\u003EThat distinction is central to UltimateAuth\u0027s design. \u003Cstrong\u003ELogout ends access; revoke permanently invalidates the selected trust boundary.\u003C/strong\u003E Both can prevent a session from being used, but they express different intentions and have different consequences for the session hierarchy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-is-more-than-clearing-credentials\u0022\u003ELogout is more than clearing credentials\u003C/h2\u003E\n\u003Cp\u003EA traditional application may implement logout by removing a browser cookie or deleting a locally stored token. That is useful client cleanup, but by itself it does not necessarily invalidate a credential already issued by the server. A copied token could remain usable until its expiration if the server has no corresponding invalidation mechanism.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth instead models authentication around a hierarchy:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUser\n\u2514\u2500\u2500 Root Global authentication lineage\n \u251C\u2500\u2500 Chain Device-associated authentication lineage\n \u2502 \u251C\u2500\u2500 Session Individual authentication instance\n \u2502 \u2514\u2500\u2500 Session Another instance in the same chain\n \u2514\u2500\u2500 Chain Another device\n \u2514\u2500\u2500 Session\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis structure gives termination a meaningful scope. An application can end access on the current device, sign out other devices, sign out all devices, or explicitly revoke a session, chain, or root. The correct choice depends on the intent: ordinary sign-out, account security, device compromise, or global trust reset.\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\u003Cstrong\u003EMental model:\u003C/strong\u003E Logout is a controlled exit from authentication. Revoke is a permanent invalidation of the selected authentication state. A later login may establish new state, but it does not undo a revocation.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-versus-revoke\u0022\u003ELogout versus revoke\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-end-access-without-necessarily-destroying-the-device-lineage\u0022\u003ELogout: end access without necessarily destroying the device lineage\u003C/h3\u003E\n\u003Cp\u003EAn ordinary logout is designed for situations such as a user selecting \u003Cstrong\u003ESign out\u003C/strong\u003E in an application. It ends the relevant authenticated access while allowing the device-associated chain to remain available for a subsequent login, subject to its state and lifetime rules.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EBefore logout\nRoot (active)\n\u2514\u2500\u2500 Chain (active)\n \u2514\u2500\u2500 Session (authenticated)\n\nAfter logout\nRoot (active)\n\u2514\u2500\u2500 Chain (retained)\n \u2514\u2500\u2500 Session (no longer usable)\n\nAfter a later successful login\nRoot (active)\n\u2514\u2500\u2500 Chain (reusable if still eligible)\n \u2514\u2500\u2500 New Session\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003ERetaining a chain does not mean retaining an authenticated session.\u003C/strong\u003E The user must authenticate again. Reuse is conditional: an expired, revoked, or otherwise ineligible chain must not be treated as active merely because it still exists in storage.\u003C/p\u003E\n\u003Cp\u003EA subtle but important implementation detail: the current HTTP logout flow resolves the authenticated session and invokes the flow service, whose logout command identifies and logs out the \u003Cstrong\u003Eassociated chain\u003C/strong\u003E. Consequently, the default logout endpoint should not be described as a guarantee that \u003Cem\u003Eonly one session record\u003C/em\u003E changes. Its practical boundary is the current device chain.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-permanently-invalidate-the-selected-trust-boundary\u0022\u003ERevoke: permanently invalidate the selected trust boundary\u003C/h3\u003E\n\u003Cp\u003ERevocation is a stronger security operation. A revoked session, chain, or root is not restored by a subsequent login, refresh, or cookie update.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EChain (active)\n\u2514\u2500\u2500 Session (active)\n\nRevoke chain\n\nChain (revoked \u2014 terminal state)\n\u2514\u2500\u2500 Sessions (invalidated)\n\nFuture authentication \u2192 new eligible chain, not restoration of the old one\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003ERevocation is irreversible for the revoked entity.\u003C/strong\u003E This does not mean the user is permanently locked out of the application. A legitimate user may authenticate again and establish a new session lineage if account policy allows it. What cannot happen is bringing the \u003Cem\u003Erevoked\u003C/em\u003E session or chain back to life.\u003C/p\u003E\n\u003Cp\u003EThis distinction is useful in security-sensitive scenarios: a lost device, suspected credential theft, administrator intervention, or a decision to invalidate all existing authentication relationships.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-the-termination-scope\u0022\u003EChoosing the termination scope\u003C/h2\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EIntended scope\u003C/th\u003E\n\u003Cth\u003EWhat it means\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout current device\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ECurrent session\u0027s chain\u003C/td\u003E\n\u003Ctd\u003EEnds authenticated access for the current device while preserving the possibility of later eligible chain reuse.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout a device\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ESelected chain\u003C/td\u003E\n\u003Ctd\u003EEnds access on the chosen device; can be initiated by the user or an authorized administrator.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout other devices\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EOther device sessions\u003C/td\u003E\n\u003Ctd\u003EEnds access elsewhere while preserving the specified current device context.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout all devices\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAll user sessions\u003C/td\u003E\n\u003Ctd\u003ESigns the user out across devices without treating this as a root revocation.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke session\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EOne session\u003C/td\u003E\n\u003Ctd\u003EPermanently invalidates the selected session.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke chain\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EOne device lineage\u003C/td\u003E\n\u003Ctd\u003EPermanently invalidates that chain and its sessions.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke other/all chains\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EMultiple device lineages\u003C/td\u003E\n\u003Ctd\u003EPermanently invalidates selected device relationships.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke root\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EGlobal authentication lineage\u003C/td\u003E\n\u003Ctd\u003EInvalidates the root and cascades to its chains and sessions.\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese are not interchangeable aliases. In particular, \u003Cstrong\u003Elogout all devices\u003C/strong\u003E and \u003Cstrong\u003Erevoke root\u003C/strong\u003E both disrupt broad access, but they do not express the same lifecycle intent.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-session-level-revoke-is-separate\u0022\u003EWhy session-level revoke is separate\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth exposes a session revocation operation distinct from the ordinary logout endpoint. A single session can be revoked without necessarily revoking its parent chain. This is useful when an application needs to invalidate a particular authentication instance rather than the entire device relationship.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-root-revoke-is-exceptional\u0022\u003EWhy root revoke is exceptional\u003C/h3\u003E\n\u003Cp\u003EThe root represents the user\u0027s active authentication lineage, not the user\u0027s existence in the user directory. Root revocation cascades through the associated authentication hierarchy. A later permitted login may establish a \u003Cstrong\u003Enew\u003C/strong\u003E active root; it does not reverse the old root\u0027s revocation.\u003C/p\u003E\n\u003Cp\u003ERoot revocation should therefore be reserved for an intentional global trust reset rather than ordinary sign-out.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022how-the-current-logout-flow-executes\u0022\u003EHow the current logout flow executes\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022resolve-the-flow-context\u0022\u003E1. Resolve the flow context\u003C/h3\u003E\n\u003Cp\u003EThe HTTP endpoint obtains the current \u003Ccode\u003EAuthFlowContext\u003C/code\u003E. This carries the relevant authentication state and response configuration, including the resolved session when one is available.\u003C/p\u003E\n\u003Cp\u003EThe ordinary logout endpoint does not accept an arbitrary session ID from the browser and blindly invalidate it. It uses the session already resolved for the current flow.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022request-server-side-termination\u0022\u003E2. Request server-side termination\u003C/h3\u003E\n\u003Cp\u003EWhen a \u003Ccode\u003ESessionSecurityContext\u003C/code\u003E is present, the endpoint constructs a \u003Ccode\u003ELogoutRequest\u003C/code\u003E using that session ID and calls the flow service:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar request = new LogoutRequest\n{\n SessionId = session.SessionId\n};\n\nawait _client.Flows.LogoutAsync(request);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe logout command resolves the session\u0027s chain and invokes the session issuer\u0027s \u003Ccode\u003ELogoutChainAsync\u003C/code\u003E operation. The session-store implementation performs the chain logout transition.\u003C/p\u003E\n\u003Cp\u003EThis is the authoritative part of logout. Removing a client cookie alone is not a substitute for this state transition.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022clear-cookies-configured-for-this-flow\u0022\u003E3. Clear cookies configured for this flow\u003C/h3\u003E\n\u003Cp\u003EAfter the server-side operation, the HTTP handler attempts to delete credentials whose \u003Cstrong\u003Eeffective delivery mode is Cookie\u003C/strong\u003E:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ESession ID cookie\u003C/li\u003E\n\u003Cli\u003ERefresh token cookie\u003C/li\u003E\n\u003Cli\u003EAccess token cookie\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe handler uses the configured cookie names through \u003Ccode\u003EIUAuthCookieManager\u003C/code\u003E. It does not assume that all three credentials are cookies, and it does not attempt to delete header-delivered credentials from a remote client\u0027s storage.\u003C/p\u003E\n\u003Cp\u003EWhen the flow has no resolved session, the endpoint can still perform cookie cleanup and return a successful logout response. This makes sign-out useful even when a browser holds stale credentials.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022resolve-the-response-and-optional-redirect\u0022\u003E4. Resolve the response and optional redirect\u003C/h3\u003E\n\u003Cp\u003EOnce termination and cookie cleanup have been handled, UltimateAuth evaluates the logout response configuration. The result is either a redirect to the configured destination or a normal HTTP success response.\u003C/p\u003E\n\u003Cp\u003EWith redirect disabled, the ordinary endpoint returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;isSuccess\u0026quot;: true\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWith redirect enabled, it returns an HTTP redirect to the resolved target instead. Applications should not expect the JSON success body in that case.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-redirect-matters\u0022\u003EWhy redirect matters\u003C/h2\u003E\n\u003Cp\u003EA redirect is \u003Cstrong\u003Enot required to invalidate authentication state\u003C/strong\u003E. It is a navigation concern that becomes important in browser-based authentication flows.\u003C/p\u003E\n\u003Cp\u003EConsider a server-rendered application with a dedicated sign-out page. Once the session has ended and cookies are cleared, the browser should often return to a public page, a login screen, or the page originally requested by the user. Redirecting completes that user journey without requiring the application to parse a logout response and navigate manually.\u003C/p\u003E\n\u003Cp\u003ERedirects can also help when the authentication endpoint and the client application are on different origins or when a centralized authentication service serves multiple clients. The server can perform the security operation and then send the browser back to the appropriate application destination.\u003C/p\u003E\n\u003Cp\u003EIn contrast, a mobile client or API consumer commonly prefers an ordinary HTTP result and controls its own navigation. \u003Cstrong\u003ERedirect is a response behavior, not a security primitive.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022redirect-resolution-in-ultimateauth\u0022\u003ERedirect resolution in UltimateAuth\u003C/h3\u003E\n\u003Cp\u003EThe response resolver selects the effective redirect settings for the logout flow. The redirect resolver then:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EChecks whether redirect is enabled.\u003C/li\u003E\n\u003Cli\u003EOn success, considers an allowed return URL override when configured.\u003C/li\u003E\n\u003Cli\u003EOtherwise uses the configured success path, resolved against the client base address.\u003C/li\u003E\n\u003Cli\u003EIf no valid destination is configured, leaves redirect disabled and allows a normal success response.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EFor absolute return URL overrides, the resolver restricts schemes to HTTP or HTTPS and validates the origin against configured allowed client origins when that allowlist is populated. Relative paths are resolved against the selected client base address.\u003C/p\u003E\n\u003Cp\u003EThis is why a return URL should be treated as \u003Cstrong\u003Evalidated navigation input\u003C/strong\u003E, never as an unrestricted arbitrary destination. Applications should configure allowed origins carefully, especially in multi-client or hosted scenarios.\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E Do not use a redirect as proof that logout succeeded, or assume that clearing browser cookies invalidates every credential issued to every device. The server-side termination scope determines what access has actually ended.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022device-aware-logout\u0022\u003EDevice-aware logout\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth treats the device-associated chain as a first-class authentication boundary. That allows applications to build features such as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003ESign out of this device\u003C/strong\u003E \u2014 end the current device\u0027s authenticated access.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESign out of another device\u003C/strong\u003E \u2014 target a specific chain after the appropriate access checks.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESign out of other devices\u003C/strong\u003E \u2014 preserve the current device while ending other sessions.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESign out everywhere\u003C/strong\u003E \u2014 end all device sessions for the user.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERevoke a lost device\u003C/strong\u003E \u2014 permanently invalidate the compromised chain instead of merely signing it out.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese operations are exposed through distinct application-service methods, rather than overloading a single logout call with ambiguous behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022self-service-and-administrative-actions\u0022\u003ESelf-service and administrative actions\u003C/h3\u003E\n\u003Cp\u003EDevice and all-device HTTP endpoints use access contexts carrying explicit action identifiers. They are executed through the access orchestrator, which enforces the applicable authorization decision before the requested state mutation.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003ELogoutDeviceSelfAsync\u003C/code\u003E targets the authenticated user\u0027s device, whereas \u003Ccode\u003ELogoutDeviceAdminAsync\u003C/code\u003E accepts a target user and executes under an administrative action context. Both ultimately call the device logout application service; authorization and target ownership are not left to the client.\u003C/p\u003E\n\u003Cp\u003EThe default current-device logout endpoint follows its own flow-based path. Do not assume every termination operation shares the same exact handler or authorization pipeline.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-properties-and-boundaries\u0022\u003ESecurity properties and boundaries\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-side-invalidation-comes-first\u0022\u003EServer-side invalidation comes first\u003C/h3\u003E\n\u003Cp\u003EThe server must stop recognizing the affected authentication state. Browser cleanup improves user experience and reduces accidental credential reuse, but it is not the source of truth.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoked-state-is-terminal\u0022\u003ERevoked state is terminal\u003C/h3\u003E\n\u003Cp\u003ERevocation cannot be undone by setting \u003Ccode\u003EIsRevoked\u003C/code\u003E back to false, presenting an old refresh token, or logging in again with the same device. A new authentication path must establish new eligible state.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022scope-must-be-explicit\u0022\u003EScope must be explicit\u003C/h3\u003E\n\u003Cp\u003EA session revoke should not be casually described as a chain revoke. A logout-all operation should not be described as a root revoke. These distinctions determine whether existing device lineages are retained, ended, or permanently invalidated.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authorization-and-ownership-matter\u0022\u003EAuthorization and ownership matter\u003C/h3\u003E\n\u003Cp\u003ESelf-service operations must stay within the authenticated user\u0027s permitted scope. Administrative operations must be evaluated under their corresponding action and target context. A client-provided chain identifier is not sufficient authorization to terminate someone else\u0027s session.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022token-delivery-affects-cleanup-not-the-termination-model\u0022\u003EToken delivery affects cleanup, not the termination model\u003C/h3\u003E\n\u003Cp\u003EWhether a client receives credentials through cookies or headers, the server-side hierarchy still governs session-backed authentication. Clients using non-cookie delivery must also clear their own local credentials after successful termination.\u003C/p\u003E\n\u003Cp\u003EThe precise behavior of already-issued, independently validated tokens depends on the active authentication mode and token validation strategy. Avoid assuming every token format provides instantaneous server-side revocation without checking its validation model.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022practical-examples\u0022\u003EPractical examples\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022a-user-signs-out-at-the-end-of-the-day\u0022\u003EA user signs out at the end of the day\u003C/h3\u003E\n\u003Cp\u003EUse the ordinary logout flow. The current device\u0027s authenticated access ends, configured cookies are cleared, and the browser may be redirected to a public page. A later successful login can reuse the retained device chain if it remains eligible.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022a-user-loses-a-phone\u0022\u003EA user loses a phone\u003C/h3\u003E\n\u003Cp\u003EUse \u003Cstrong\u003Echain revoke\u003C/strong\u003E, not merely a soft device logout, when the goal is to permanently invalidate that device\u0027s existing trust lineage. The user may later authenticate on a replacement device, but the revoked chain stays revoked.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022a-user-wants-to-keep-this-device-but-sign-out-elsewhere\u0022\u003EA user wants to keep this device but sign out elsewhere\u003C/h3\u003E\n\u003Cp\u003EUse \u003Cstrong\u003Elogout other devices\u003C/strong\u003E. The current device\u0027s chain is excluded while other device sessions are terminated. This is different from permanently revoking every other chain.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022an-account-is-believed-to-be-compromised\u0022\u003EAn account is believed to be compromised\u003C/h3\u003E\n\u003Cp\u003EDepending on the security policy, revoke all chains or the root. This is a stronger response than ordinary logout because it deliberately invalidates existing authentication lineages. Credential rotation and additional account recovery steps may also be required; revocation alone does not prove that compromised credentials have been changed.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022common-misconceptions\u0022\u003ECommon misconceptions\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CLogout just deletes the cookie.\u201D\u003C/strong\u003E No. The ordinary endpoint performs server-side termination when a session is resolved, then clears applicable cookies.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CLogout and revoke are the same operation.\u201D\u003C/strong\u003E No. Both can stop access, but revocation is permanent for the selected entity; soft logout can retain eligible device lineage.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CLogout always means exactly one session.\u201D\u003C/strong\u003E Not in the current default HTTP flow. The resolved session identifies a chain, and the logout command operates on that chain.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CRevocation permanently bans the user.\u201D\u003C/strong\u003E No. It permanently invalidates existing authentication state at the selected scope. A permitted future login may create new state.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CRedirect makes logout secure.\u201D\u003C/strong\u003E No. Redirect controls where the browser goes after logout; the session-store transition is what ends server-recognized access.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CLogout all devices is the same as revoke root.\u201D\u003C/strong\u003E No. They are different lifecycle operations with different implications for the authentication hierarchy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022key-takeaways\u0022\u003EKey takeaways\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003ELogout is a lifecycle operation\u003C/strong\u003E, not merely client-side credential deletion.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERevoke is irreversible\u003C/strong\u003E for the revoked session, chain, or root; a future login never restores that entity.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERoot \u2192 Chain \u2192 Session\u003C/strong\u003E makes termination scope explicit and device-aware.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ECurrent-device logout operates through the associated chain\u003C/strong\u003E in the current server implementation.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EOther-device and all-device logout\u003C/strong\u003E are separate from hard chain/root revocation.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERedirect is optional navigation behavior\u003C/strong\u003E, especially useful for browser-based flows; it does not perform revocation.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EServer-side state is authoritative\u003C/strong\u003E for session-backed access; client credential cleanup remains important.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-step\u0022\u003E\u27A1\uFE0F Next step\u003C/h2\u003E\n\u003Cp\u003EContinue to \u003Cstrong\u003ESession Lifecycle\u003C/strong\u003E to understand how roots, chains, and sessions are created, rotated, expired, logged out, and revoked over time.\u003C/p\u003E\n", + "Html": "\n\u003Cp\u003EAuthentication does not end when a client deletes a cookie or forgets a token. In UltimateAuth, ending access is a \u003Cstrong\u003Eserver-side lifecycle decision\u003C/strong\u003E: the system determines which authentication state must stop being usable, at what scope, and whether the associated device relationship should survive a future login.\u003C/p\u003E\n\u003Cp\u003EThat distinction is central to UltimateAuth\u0027s design. \u003Cstrong\u003ELogout ends access; revoke permanently invalidates the selected trust boundary.\u003C/strong\u003E Both can prevent a session from being used, but they express different intentions and have different consequences for the session hierarchy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-is-more-than-clearing-credentials\u0022\u003ELogout is more than clearing credentials\u003C/h2\u003E\n\u003Cp\u003EA traditional application may implement logout by removing a browser cookie or deleting a locally stored token. That is useful client cleanup, but by itself it does not necessarily invalidate a credential already issued by the server. A copied token could remain usable until its expiration if the server has no corresponding invalidation mechanism.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth instead models authentication around a hierarchy:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUser\n\u2514\u2500\u2500 Root Global authentication lineage\n \u251C\u2500\u2500 Chain Device-associated authentication lineage\n \u2502 \u251C\u2500\u2500 Session Individual authentication instance\n \u2502 \u2514\u2500\u2500 Session Another instance in the same chain\n \u2514\u2500\u2500 Chain Another device\n \u2514\u2500\u2500 Session\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis structure gives termination a meaningful scope. An application can end access on the current device, sign out other devices, sign out all devices, or explicitly revoke a session, chain, or root. The correct choice depends on the intent: ordinary sign-out, account security, device compromise, or global trust reset.\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\u003Cstrong\u003EMental model:\u003C/strong\u003E Logout is a controlled exit from authentication. Revoke is a permanent invalidation of the selected authentication state. A later login may establish new state, but it does not undo a revocation.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-versus-revoke\u0022\u003ELogout versus revoke\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-end-access-without-necessarily-destroying-the-device-lineage\u0022\u003ELogout: end access without necessarily destroying the device lineage\u003C/h3\u003E\n\u003Cp\u003EAn ordinary logout is designed for situations such as a user selecting \u003Cstrong\u003ESign out\u003C/strong\u003E in an application. It ends the relevant authenticated access while allowing the device-associated chain to remain available for a subsequent login, subject to its state and lifetime rules.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EBefore logout\nRoot (active)\n\u2514\u2500\u2500 Chain (active)\n \u2514\u2500\u2500 Session (authenticated)\n\nAfter logout\nRoot (active)\n\u2514\u2500\u2500 Chain (retained)\n \u2514\u2500\u2500 Session (no longer usable)\n\nAfter a later successful login\nRoot (active)\n\u2514\u2500\u2500 Chain (reusable if still eligible)\n \u2514\u2500\u2500 New Session\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003ERetaining a chain does not mean retaining an authenticated session.\u003C/strong\u003E The user must authenticate again. Reuse is conditional: an expired, revoked, or otherwise ineligible chain must not be treated as active merely because it still exists in storage.\u003C/p\u003E\n\u003Cp\u003EA subtle but important implementation detail: the current HTTP logout flow resolves the authenticated session and invokes the flow service, whose logout command identifies and logs out the \u003Cstrong\u003Eassociated chain\u003C/strong\u003E. Consequently, the default logout endpoint should not be described as a guarantee that \u003Cem\u003Eonly one session record\u003C/em\u003E changes. Its practical boundary is the current device chain.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-permanently-invalidate-the-selected-trust-boundary\u0022\u003ERevoke: permanently invalidate the selected trust boundary\u003C/h3\u003E\n\u003Cp\u003ERevocation is a stronger security operation. A revoked session, chain, or root is not restored by a subsequent login, refresh, or cookie update.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EChain (active)\n\u2514\u2500\u2500 Session (active)\n\nRevoke chain\n\nChain (revoked \u2014 terminal state)\n\u2514\u2500\u2500 Sessions (invalidated)\n\nFuture authentication \u2192 new eligible chain, not restoration of the old one\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003ERevocation is irreversible for the revoked entity.\u003C/strong\u003E This does not mean the user is permanently locked out of the application. A legitimate user may authenticate again and establish a new session lineage if account policy allows it. What cannot happen is bringing the \u003Cem\u003Erevoked\u003C/em\u003E session or chain back to life.\u003C/p\u003E\n\u003Cp\u003EThis distinction is useful in security-sensitive scenarios: a lost device, suspected credential theft, administrator intervention, or a decision to invalidate all existing authentication relationships.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-the-termination-scope\u0022\u003EChoosing the termination scope\u003C/h2\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EIntended scope\u003C/th\u003E\n\u003Cth\u003EWhat it means\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout current device\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ECurrent session\u0027s chain\u003C/td\u003E\n\u003Ctd\u003EEnds authenticated access for the current device while preserving the possibility of later eligible chain reuse.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout a device\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003ESelected chain\u003C/td\u003E\n\u003Ctd\u003EEnds access on the chosen device; can be initiated by the user or an authorized administrator.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout other devices\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EOther device sessions\u003C/td\u003E\n\u003Ctd\u003EEnds access elsewhere while preserving the specified current device context.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ELogout all devices\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAll user sessions\u003C/td\u003E\n\u003Ctd\u003ESigns the user out across devices without treating this as a root revocation.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke session\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EOne session\u003C/td\u003E\n\u003Ctd\u003EPermanently invalidates the selected session.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke chain\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EOne device lineage\u003C/td\u003E\n\u003Ctd\u003EPermanently invalidates that chain and its sessions.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke other/all chains\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EMultiple device lineages\u003C/td\u003E\n\u003Ctd\u003EPermanently invalidates selected device relationships.\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERevoke root\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EGlobal authentication lineage\u003C/td\u003E\n\u003Ctd\u003EInvalidates the root and cascades to its chains and sessions.\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese are not interchangeable aliases. In particular, \u003Cstrong\u003Elogout all devices\u003C/strong\u003E and \u003Cstrong\u003Erevoke root\u003C/strong\u003E both disrupt broad access, but they do not express the same lifecycle intent.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-session-level-revoke-is-separate\u0022\u003EWhy session-level revoke is separate\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth exposes a session revocation operation distinct from the ordinary logout endpoint. A single session can be revoked without necessarily revoking its parent chain. This is useful when an application needs to invalidate a particular authentication instance rather than the entire device relationship.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-root-revoke-is-exceptional\u0022\u003EWhy root revoke is exceptional\u003C/h3\u003E\n\u003Cp\u003EThe root represents the user\u0027s active authentication lineage, not the user\u0027s existence in the user directory. Root revocation cascades through the associated authentication hierarchy. A later permitted login may establish a \u003Cstrong\u003Enew\u003C/strong\u003E active root; it does not reverse the old root\u0027s revocation.\u003C/p\u003E\n\u003Cp\u003ERoot revocation should therefore be reserved for an intentional global trust reset rather than ordinary sign-out.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022how-the-current-logout-flow-executes\u0022\u003EHow the current logout flow executes\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022resolve-the-flow-context\u0022\u003E1. Resolve the flow context\u003C/h3\u003E\n\u003Cp\u003EThe HTTP endpoint obtains the current \u003Ccode\u003EAuthFlowContext\u003C/code\u003E. This carries the relevant authentication state and response configuration, including the resolved session when one is available.\u003C/p\u003E\n\u003Cp\u003EThe ordinary logout endpoint does not accept an arbitrary session ID from the browser and blindly invalidate it. It uses the session already resolved for the current flow.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022request-server-side-termination\u0022\u003E2. Request server-side termination\u003C/h3\u003E\n\u003Cp\u003EWhen a \u003Ccode\u003ESessionSecurityContext\u003C/code\u003E is present, the endpoint constructs a \u003Ccode\u003ELogoutRequest\u003C/code\u003E using that session ID and calls the flow service:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar request = new LogoutRequest\n{\n SessionId = session.SessionId\n};\n\nawait _client.Flows.LogoutAsync(request);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe logout command resolves the session\u0027s chain and invokes the session issuer\u0027s \u003Ccode\u003ELogoutChainAsync\u003C/code\u003E operation. The session-store implementation performs the chain logout transition.\u003C/p\u003E\n\u003Cp\u003EThis is the authoritative part of logout. Removing a client cookie alone is not a substitute for this state transition.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022clear-cookies-configured-for-this-flow\u0022\u003E3. Clear cookies configured for this flow\u003C/h3\u003E\n\u003Cp\u003EAfter the server-side operation, the HTTP handler attempts to delete credentials whose \u003Cstrong\u003Eeffective delivery mode is Cookie\u003C/strong\u003E:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ESession ID cookie\u003C/li\u003E\n\u003Cli\u003ERefresh token cookie\u003C/li\u003E\n\u003Cli\u003EAccess token cookie\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe handler uses the configured cookie names through \u003Ccode\u003EIUAuthCookieManager\u003C/code\u003E. It does not assume that all three credentials are cookies, and it does not attempt to delete header-delivered credentials from a remote client\u0027s storage.\u003C/p\u003E\n\u003Cp\u003EWhen the flow has no resolved session, the endpoint can still perform cookie cleanup and return a successful logout response. This makes sign-out useful even when a browser holds stale credentials.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022resolve-the-response-and-optional-redirect\u0022\u003E4. Resolve the response and optional redirect\u003C/h3\u003E\n\u003Cp\u003EOnce termination and cookie cleanup have been handled, UltimateAuth evaluates the logout response configuration. The result is either a redirect to the configured destination or a normal HTTP success response.\u003C/p\u003E\n\u003Cp\u003EWith redirect disabled, the ordinary endpoint returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;isSuccess\u0026quot;: true\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWith redirect enabled, it returns an HTTP redirect to the resolved target instead. Applications should not expect the JSON success body in that case.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-controlled-navigation\u0022\u003EClient-controlled navigation\u003C/h3\u003E\n\u003Cp\u003EThe UltimateAuth Client API allows applications to control navigation after a successful logout through the optional \u003Ccode\u003Enavigate\u003C/code\u003E parameter.\u003C/p\u003E\n\u003Cp\u003EBy default, logout follows the server-configured redirect behavior:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.LogoutAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ETo complete logout without automatic navigation, set \u003Ccode\u003Enavigate\u003C/code\u003E to \u003Ccode\u003Efalse\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.LogoutAsync(navigate: false);\n\n// Perform application-specific cleanup after logout.\nawait YourApplicationCleanupAsync();\n\nNavigation.NavigateTo(\u0026quot;/goodbye\u0026quot;);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhen \u003Ccode\u003Enavigate\u003C/code\u003E is \u003Ccode\u003Efalse\u003C/code\u003E, 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.\u003C/p\u003E\n\u003Cp\u003EThis is useful when an application needs to clear its own state, perform additional cleanup, display a confirmation, or choose a destination after logout.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E The \u003Ccode\u003Enavigate\u003C/code\u003E 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 \u003Ccode\u003ELogoutAsync\u003C/code\u003E completes successfully.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-redirect-matters\u0022\u003EWhy redirect matters\u003C/h2\u003E\n\u003Cp\u003EA redirect is \u003Cstrong\u003Enot required to invalidate authentication state\u003C/strong\u003E. It is a navigation concern that becomes important in browser-based authentication flows.\u003C/p\u003E\n\u003Cp\u003EConsider a server-rendered application with a dedicated sign-out page. Once the session has ended and cookies are cleared, the browser should often return to a public page, a login screen, or the page originally requested by the user. Redirecting completes that user journey without requiring the application to parse a logout response and navigate manually.\u003C/p\u003E\n\u003Cp\u003ERedirects can also help when the authentication endpoint and the client application are on different origins or when a centralized authentication service serves multiple clients. The server can perform the security operation and then send the browser back to the appropriate application destination.\u003C/p\u003E\n\u003Cp\u003EIn contrast, a mobile client or API consumer commonly prefers an ordinary HTTP result and controls its own navigation. \u003Cstrong\u003ERedirect is a response behavior, not a security primitive.\u003C/strong\u003E\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022redirect-resolution-in-ultimateauth\u0022\u003ERedirect resolution in UltimateAuth\u003C/h3\u003E\n\u003Cp\u003EThe response resolver selects the effective redirect settings for the logout flow. The redirect resolver then:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EChecks whether redirect is enabled.\u003C/li\u003E\n\u003Cli\u003EOn success, considers an allowed return URL override when configured.\u003C/li\u003E\n\u003Cli\u003EOtherwise uses the configured success path, resolved against the client base address.\u003C/li\u003E\n\u003Cli\u003EIf no valid destination is configured, leaves redirect disabled and allows a normal success response.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EFor absolute return URL overrides, the resolver restricts schemes to HTTP or HTTPS and validates the origin against configured allowed client origins when that allowlist is populated. Relative paths are resolved against the selected client base address.\u003C/p\u003E\n\u003Cp\u003EThis is why a return URL should be treated as \u003Cstrong\u003Evalidated navigation input\u003C/strong\u003E, never as an unrestricted arbitrary destination. Applications should configure allowed origins carefully, especially in multi-client or hosted scenarios.\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E Do not use a redirect as proof that logout succeeded, or assume that clearing browser cookies invalidates every credential issued to every device. The server-side termination scope determines what access has actually ended.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022device-aware-logout\u0022\u003EDevice-aware logout\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth treats the device-associated chain as a first-class authentication boundary. That allows applications to build features such as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003ESign out of this device\u003C/strong\u003E \u2014 end the current device\u0027s authenticated access.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESign out of another device\u003C/strong\u003E \u2014 target a specific chain after the appropriate access checks.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESign out of other devices\u003C/strong\u003E \u2014 preserve the current device while ending other sessions.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESign out everywhere\u003C/strong\u003E \u2014 end all device sessions for the user.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERevoke a lost device\u003C/strong\u003E \u2014 permanently invalidate the compromised chain instead of merely signing it out.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese operations are exposed through distinct application-service methods, rather than overloading a single logout call with ambiguous behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022self-service-and-administrative-actions\u0022\u003ESelf-service and administrative actions\u003C/h3\u003E\n\u003Cp\u003EDevice and all-device HTTP endpoints use access contexts carrying explicit action identifiers. They are executed through the access orchestrator, which enforces the applicable authorization decision before the requested state mutation.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003ELogoutDeviceSelfAsync\u003C/code\u003E targets the authenticated user\u0027s device, whereas \u003Ccode\u003ELogoutDeviceAdminAsync\u003C/code\u003E accepts a target user and executes under an administrative action context. Both ultimately call the device logout application service; authorization and target ownership are not left to the client.\u003C/p\u003E\n\u003Cp\u003EThe default current-device logout endpoint follows its own flow-based path. Do not assume every termination operation shares the same exact handler or authorization pipeline.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-properties-and-boundaries\u0022\u003ESecurity properties and boundaries\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-side-invalidation-comes-first\u0022\u003EServer-side invalidation comes first\u003C/h3\u003E\n\u003Cp\u003EThe server must stop recognizing the affected authentication state. Browser cleanup improves user experience and reduces accidental credential reuse, but it is not the source of truth.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoked-state-is-terminal\u0022\u003ERevoked state is terminal\u003C/h3\u003E\n\u003Cp\u003ERevocation cannot be undone by setting \u003Ccode\u003EIsRevoked\u003C/code\u003E back to false, presenting an old refresh token, or logging in again with the same device. A new authentication path must establish new eligible state.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022scope-must-be-explicit\u0022\u003EScope must be explicit\u003C/h3\u003E\n\u003Cp\u003EA session revoke should not be casually described as a chain revoke. A logout-all operation should not be described as a root revoke. These distinctions determine whether existing device lineages are retained, ended, or permanently invalidated.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authorization-and-ownership-matter\u0022\u003EAuthorization and ownership matter\u003C/h3\u003E\n\u003Cp\u003ESelf-service operations must stay within the authenticated user\u0027s permitted scope. Administrative operations must be evaluated under their corresponding action and target context. A client-provided chain identifier is not sufficient authorization to terminate someone else\u0027s session.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022token-delivery-affects-cleanup-not-the-termination-model\u0022\u003EToken delivery affects cleanup, not the termination model\u003C/h3\u003E\n\u003Cp\u003EWhether a client receives credentials through cookies or headers, the server-side hierarchy still governs session-backed authentication. Clients using non-cookie delivery must also clear their own local credentials after successful termination.\u003C/p\u003E\n\u003Cp\u003EThe precise behavior of already-issued, independently validated tokens depends on the active authentication mode and token validation strategy. Avoid assuming every token format provides instantaneous server-side revocation without checking its validation model.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022practical-examples\u0022\u003EPractical examples\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022a-user-signs-out-at-the-end-of-the-day\u0022\u003EA user signs out at the end of the day\u003C/h3\u003E\n\u003Cp\u003EUse the ordinary logout flow. The current device\u0027s authenticated access ends, configured cookies are cleared, and the browser may be redirected to a public page. A later successful login can reuse the retained device chain if it remains eligible.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022a-user-loses-a-phone\u0022\u003EA user loses a phone\u003C/h3\u003E\n\u003Cp\u003EUse \u003Cstrong\u003Echain revoke\u003C/strong\u003E, not merely a soft device logout, when the goal is to permanently invalidate that device\u0027s existing trust lineage. The user may later authenticate on a replacement device, but the revoked chain stays revoked.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022a-user-wants-to-keep-this-device-but-sign-out-elsewhere\u0022\u003EA user wants to keep this device but sign out elsewhere\u003C/h3\u003E\n\u003Cp\u003EUse \u003Cstrong\u003Elogout other devices\u003C/strong\u003E. The current device\u0027s chain is excluded while other device sessions are terminated. This is different from permanently revoking every other chain.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022an-account-is-believed-to-be-compromised\u0022\u003EAn account is believed to be compromised\u003C/h3\u003E\n\u003Cp\u003EDepending on the security policy, revoke all chains or the root. This is a stronger response than ordinary logout because it deliberately invalidates existing authentication lineages. Credential rotation and additional account recovery steps may also be required; revocation alone does not prove that compromised credentials have been changed.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022common-misconceptions\u0022\u003ECommon misconceptions\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CLogout just deletes the cookie.\u201D\u003C/strong\u003E No. The ordinary endpoint performs server-side termination when a session is resolved, then clears applicable cookies.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CLogout and revoke are the same operation.\u201D\u003C/strong\u003E No. Both can stop access, but revocation is permanent for the selected entity; soft logout can retain eligible device lineage.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CLogout always means exactly one session.\u201D\u003C/strong\u003E Not in the current default HTTP flow. The resolved session identifies a chain, and the logout command operates on that chain.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CRevocation permanently bans the user.\u201D\u003C/strong\u003E No. It permanently invalidates existing authentication state at the selected scope. A permitted future login may create new state.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CRedirect makes logout secure.\u201D\u003C/strong\u003E No. Redirect controls where the browser goes after logout; the session-store transition is what ends server-recognized access.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u201CLogout all devices is the same as revoke root.\u201D\u003C/strong\u003E No. They are different lifecycle operations with different implications for the authentication hierarchy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022key-takeaways\u0022\u003EKey takeaways\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003ELogout is a lifecycle operation\u003C/strong\u003E, not merely client-side credential deletion.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERevoke is irreversible\u003C/strong\u003E for the revoked session, chain, or root; a future login never restores that entity.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERoot \u2192 Chain \u2192 Session\u003C/strong\u003E makes termination scope explicit and device-aware.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ECurrent-device logout operates through the associated chain\u003C/strong\u003E in the current server implementation.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EOther-device and all-device logout\u003C/strong\u003E are separate from hard chain/root revocation.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERedirect is optional navigation behavior\u003C/strong\u003E, especially useful for browser-based flows; it does not perform revocation.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EServer-side state is authoritative\u003C/strong\u003E for session-backed access; client credential cleanup remains important.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-step\u0022\u003E\u27A1\uFE0F Next step\u003C/h2\u003E\n\u003Cp\u003EContinue to \u003Cstrong\u003ESession Lifecycle\u003C/strong\u003E to understand how roots, chains, and sessions are created, rotated, expired, logged out, and revoked over time.\u003C/p\u003E\n", "Headings": [ { "Id": "logout-is-more-than-clearing-credentials", @@ -63,6 +63,11 @@ "Text": "4. Resolve the response and optional redirect", "Level": 1 }, + { + "Id": "client-controlled-navigation", + "Text": "Client-controlled navigation", + "Level": 1 + }, { "Id": "why-redirect-matters", "Text": "Why redirect matters", @@ -154,6 +159,6 @@ "Level": 0 } ], - "SourceHash": "16E601FE603117928E01E64029AC1BDF0F5AD93F8108808F07817D0A2EB608CE", + "SourceHash": "D47451D3FA67648678F48B2ECC2626D88F9E49C7B36DFAA9922FB7AE6372CE33", "BuilderVersion": "1" } \ No newline at end of file diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SampleLogin.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SampleLogin.razor index 34219171..6e81cc6e 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SampleLogin.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SampleLogin.razor @@ -36,7 +36,7 @@ Continue - + Switch account diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SamplePkceLogin.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SamplePkceLogin.razor index 3850685d..031b5196 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SamplePkceLogin.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SamplePkceLogin.razor @@ -36,7 +36,7 @@ Continue - + Switch account diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs index 5c7b3ea1..8d4aa3cb 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs @@ -69,19 +69,26 @@ public async Task SendFormAsync(string endpoint, IDictiona public async Task SendFormAsync(string endpoint, IDictionary? form, bool navigateOnSuccess, CancellationToken ct = default) { - if (!navigateOnSuccess) return await SendFormAsync(endpoint, form, ct); ct.ThrowIfCancellationRequested(); + await _bootstrapper.EnsureStartedAsync(); - var result = await _js.InvokeAsync("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("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; } diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js index 8c81dbab..f711c7fa 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js @@ -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", @@ -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" } + : {} }); }; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js index 85a8b886..a15a206d 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js @@ -1 +1 @@ -window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:c,commitUrl:l,data:f,clientProfile:o,trustedOrigin:e}=n,a=window.uauth.validateAuthUrl(c,e),v=window.uauth.validateAuthUrl(l,e),s=window.uauth.deviceId;if(!s)throw new Error("UAuth deviceId is not initialized.");const r=await window.uauth.postJson({url:a,payload:f,clientProfile:o,trustedOrigin:e});if(!r||!Number.isInteger(r.status))throw new Error("Invalid UAuth transport response.");const i=r.status;if(i===429)return{isSuccess:!1,status:429,errorCode:"rate_limited",retryAfter:r.retryAfter??null};const y=i>=200&&i<300,h=i>=400&&i<500;if(!y&&!h)throw new Error(`Unexpected UAuth try response: HTTP ${i}.`);const t=r.body;if(!t||typeof t!="object"||Array.isArray(t)||typeof t.isSuccess!="boolean")throw new Error("Invalid UAuth try response body.");if(h&&t.isSuccess)throw new Error("Successful authentication result returned with HTTP error.");const u={isSuccess:t.isSuccess,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1,previewReceipt:t.previewReceipt??null};if(u.isSuccess){const n=document.createElement("form");n.method="POST";n.action=v;for(const t of Object.keys(f)){const i=document.createElement("input");i.type="hidden";i.name=t;i.value=f[t]??"";n.appendChild(i)}if(u.previewReceipt){const t=document.createElement("input");t.type="hidden";t.name="PreviewReceipt";t.value=u.previewReceipt;n.appendChild(t)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=o??"";n.appendChild(t);const i=document.createElement("input");i.type="hidden";i.name="__uauth_device";i.value=s;n.appendChild(i);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:f,mode:i,data:t,clientProfile:r,trustedOrigin:e}=n,u=window.uauth.validateAuthUrl(f,e);if(i==="navigate"){const n=document.createElement("form");n.method="POST";n.action=u;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=r??"";n.appendChild(i);const f=document.createElement("input");if(f.type="hidden",f.name="__uauth_device",f.value=window.uauth.deviceId,n.appendChild(f),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}return window.uauth.request({url:u,method:"POST",payload:t,clientProfile:r,contentType:"form",credentials:"include",allowCrossOriginCredentials:!0,navigateOnSuccess:i==="logout",headers:i==="logout"?{"X-UAuth-Logout-Mode":"observe"}:{}})};window.uauth.postJson=function(n){const t=window.uauth.validateAuthUrl(n.url,n.trustedOrigin);return window.uauth.request({url:t,method:"POST",payload:n.payload,clientProfile:n.clientProfile,contentType:"json",credentials:"include",allowCrossOriginCredentials:!0})};window.uauth.request=async function(n){const{followRedirects:a=!1,navigateOnSuccess:v=!1,url:h,method:y="GET",payload:i=null,clientProfile:p="",contentType:c="json",credentials:e="same-origin",headers:w={}}=n;if(!h)throw new Error("Request URL is required.");const o=new URL(h,document.baseURI);if(!["http:","https:"].includes(o.protocol))throw new Error("Unsupported request URL protocol.");const r=y.toUpperCase();if(!["GET","POST","PUT","PATCH","DELETE"].includes(r))throw new Error(`Unsupported HTTP method: ${r}`);if(!["same-origin","include","omit"].includes(e))throw new Error("Invalid credentials policy.");if(e==="include"&&o.origin!==window.location.origin&&!n.allowCrossOriginCredentials)throw new Error("Cross-origin credentials require explicit authorization.");if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const s={...w,"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":p,"X-Requested-With":"UAuth"};let u=null;if(i!==null&&i!==undefined){if(r==="GET")throw new Error("GET requests cannot contain a body.");if(c==="json")s["Content-Type"]="application/json",u=JSON.stringify(i);else if(c==="form"){s["Content-Type"]="application/x-www-form-urlencoded";u=new URLSearchParams;for(const[n,t]of Object.entries(i))u.append(n,t??"")}else throw new Error("Unsupported content type.");}let t;try{t=await fetch(o.href,{method:r,credentials:e,redirect:a?"follow":"error",headers:s,body:u})}catch(l){if(l instanceof TypeError)return{ok:!1,status:0,refreshOutcome:null,body:null};throw l;}let f=null;if(t.status!==204&&t.status!==205){const n=await t.text();if(n)try{f=JSON.parse(n)}catch{f=n}}if(v&&t.ok){const n=f?.redirectUrl;if(typeof n=="string"&&n.length>0){const t=new URL(n,window.location.origin);if(!["http:","https:"].includes(t.protocol))throw new Error("Invalid logout redirect protocol.");window.location.assign(t.href)}}return{retryAfter:t.headers.get("Retry-After"),ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:f}};window.uauth.resourceRequest=async function(n){const{url:o,baseAddress:h,method:v="GET",payload:f=null,clientProfile:y="",credentials:p="Auto",requestId:u}=n;if(!o||typeof o!="string")throw new Error("Resource request URL is required.");const i=h?new URL(h):new URL(window.location.origin+"/");if(!["http:","https:"].includes(i.protocol))throw new Error("Invalid Resource API protocol.");if(i.username||i.password||i.search||i.hash)throw new Error("Invalid Resource API base address.");const t=new URL(o,i);if(t.origin!==i.origin)throw new Error("Resource request target differs from configured API origin.");if(t.username||t.password)throw new Error("Resource URL cannot contain credentials.");if(!["http:","https:"].includes(t.protocol))throw new Error("Unsupported Resource API protocol.");const s=v.toUpperCase();if(!["GET","POST","PUT","PATCH","DELETE"].includes(s))throw new Error("Unsupported Resource API method.");if(s==="GET"&&f!==null)throw new Error("GET requests cannot contain a body.");let r;switch(p.toLowerCase()){case"auto":r=t.origin===window.location.origin?"same-origin":"include";break;case"sameorigin":r="same-origin";break;case"include":r="include";break;case"omit":r="omit";break;default:throw new Error("Invalid Resource credentials mode.");}if(r==="include"&&t.protocol!=="https:"&&!["localhost","127.0.0.1","[::1]"].includes(t.hostname))throw new Error("Cross-origin credential requests require HTTPS.");if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const c={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":y,"X-Requested-With":"UAuth"};let l=null;f!==null&&f!==undefined&&(c["Content-Type"]="application/json",l=JSON.stringify(f));const a=new AbortController;if(u){if(window.uauth.resourceRequests.has(u))throw new Error("Duplicate Resource request ID.");window.uauth.resourceRequests.set(u,a)}try{const n=await fetch(t.href,{method:s,credentials:r,redirect:"error",headers:c,body:l,signal:a.signal});let i=null;if(n.status!==204&&n.status!==205){const t=await n.text();t.length>0&&(i=t)}return{retryAfter:n.headers.get("Retry-After"),ok:n.ok,status:n.status,body:i,contentType:n.headers.get("Content-Type")}}catch(e){if(e?.name==="AbortError")throw e;if(e instanceof TypeError)return{ok:!1,status:0,body:null,contentType:null};throw e;}finally{u&&window.uauth.resourceRequests.delete(u)}};window.uauth.resourceRequests=new Map;window.uauth.cancelResourceRequest=function(n){const t=window.uauth.resourceRequests.get(n);return t?(t.abort(),!0):!1};window.uauth.validateAuthUrl=function(n,t=null){if(!n||typeof n!="string")throw new Error("Authentication URL is required.");const r=t?new URL(t,window.location.origin):new URL(window.location.origin),i=new URL(n,document.baseURI);if(!["http:","https:"].includes(r.protocol))throw new Error("Unsupported trusted origin protocol.");if(!["http:","https:"].includes(i.protocol))throw new Error("Unsupported authentication URL protocol.");if(r.username||r.password)throw new Error("Trusted origin cannot contain credentials.");if(i.username||i.password)throw new Error("Authentication URL cannot contain credentials.");if(i.origin!==r.origin)throw new Error("Authentication request target differs from the trusted UAuthHub origin.");return i.href};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file +window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:c,commitUrl:l,data:f,clientProfile:o,trustedOrigin:e}=n,a=window.uauth.validateAuthUrl(c,e),v=window.uauth.validateAuthUrl(l,e),s=window.uauth.deviceId;if(!s)throw new Error("UAuth deviceId is not initialized.");const r=await window.uauth.postJson({url:a,payload:f,clientProfile:o,trustedOrigin:e});if(!r||!Number.isInteger(r.status))throw new Error("Invalid UAuth transport response.");const i=r.status;if(i===429)return{isSuccess:!1,status:429,errorCode:"rate_limited",retryAfter:r.retryAfter??null};const y=i>=200&&i<300,h=i>=400&&i<500;if(!y&&!h)throw new Error(`Unexpected UAuth try response: HTTP ${i}.`);const t=r.body;if(!t||typeof t!="object"||Array.isArray(t)||typeof t.isSuccess!="boolean")throw new Error("Invalid UAuth try response body.");if(h&&t.isSuccess)throw new Error("Successful authentication result returned with HTTP error.");const u={isSuccess:t.isSuccess,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1,previewReceipt:t.previewReceipt??null};if(u.isSuccess){const n=document.createElement("form");n.method="POST";n.action=v;for(const t of Object.keys(f)){const i=document.createElement("input");i.type="hidden";i.name=t;i.value=f[t]??"";n.appendChild(i)}if(u.previewReceipt){const t=document.createElement("input");t.type="hidden";t.name="PreviewReceipt";t.value=u.previewReceipt;n.appendChild(t)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=o??"";n.appendChild(t);const i=document.createElement("input");i.type="hidden";i.name="__uauth_device";i.value=s;n.appendChild(i);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:f,mode:t,data:i,clientProfile:r,trustedOrigin:e}=n,u=window.uauth.validateAuthUrl(f,e);if(t==="navigate"){const n=document.createElement("form");n.method="POST";n.action=u;const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=r??"";n.appendChild(t);const f=document.createElement("input");if(f.type="hidden",f.name="__uauth_device",f.value=window.uauth.deviceId,n.appendChild(f),i)for(const t in i){const r=document.createElement("input");r.type="hidden";r.name=t;r.value=i[t];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}const o=t==="logout"||t==="logout-observe";return window.uauth.request({url:u,method:"POST",payload:i,clientProfile:r,contentType:"form",credentials:"include",allowCrossOriginCredentials:!0,navigateOnSuccess:t==="logout",headers:o?{"X-UAuth-Logout-Mode":"observe"}:{}})};window.uauth.postJson=function(n){const t=window.uauth.validateAuthUrl(n.url,n.trustedOrigin);return window.uauth.request({url:t,method:"POST",payload:n.payload,clientProfile:n.clientProfile,contentType:"json",credentials:"include",allowCrossOriginCredentials:!0})};window.uauth.request=async function(n){const{followRedirects:a=!1,navigateOnSuccess:v=!1,url:h,method:y="GET",payload:i=null,clientProfile:p="",contentType:c="json",credentials:e="same-origin",headers:w={}}=n;if(!h)throw new Error("Request URL is required.");const o=new URL(h,document.baseURI);if(!["http:","https:"].includes(o.protocol))throw new Error("Unsupported request URL protocol.");const r=y.toUpperCase();if(!["GET","POST","PUT","PATCH","DELETE"].includes(r))throw new Error(`Unsupported HTTP method: ${r}`);if(!["same-origin","include","omit"].includes(e))throw new Error("Invalid credentials policy.");if(e==="include"&&o.origin!==window.location.origin&&!n.allowCrossOriginCredentials)throw new Error("Cross-origin credentials require explicit authorization.");if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const s={...w,"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":p,"X-Requested-With":"UAuth"};let u=null;if(i!==null&&i!==undefined){if(r==="GET")throw new Error("GET requests cannot contain a body.");if(c==="json")s["Content-Type"]="application/json",u=JSON.stringify(i);else if(c==="form"){s["Content-Type"]="application/x-www-form-urlencoded";u=new URLSearchParams;for(const[n,t]of Object.entries(i))u.append(n,t??"")}else throw new Error("Unsupported content type.");}let t;try{t=await fetch(o.href,{method:r,credentials:e,redirect:a?"follow":"error",headers:s,body:u})}catch(l){if(l instanceof TypeError)return{ok:!1,status:0,refreshOutcome:null,body:null};throw l;}let f=null;if(t.status!==204&&t.status!==205){const n=await t.text();if(n)try{f=JSON.parse(n)}catch{f=n}}if(v&&t.ok){const n=f?.redirectUrl;if(typeof n=="string"&&n.length>0){const t=new URL(n,window.location.origin);if(!["http:","https:"].includes(t.protocol))throw new Error("Invalid logout redirect protocol.");window.location.assign(t.href)}}return{retryAfter:t.headers.get("Retry-After"),ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:f}};window.uauth.resourceRequest=async function(n){const{url:o,baseAddress:h,method:v="GET",payload:f=null,clientProfile:y="",credentials:p="Auto",requestId:u}=n;if(!o||typeof o!="string")throw new Error("Resource request URL is required.");const i=h?new URL(h):new URL(window.location.origin+"/");if(!["http:","https:"].includes(i.protocol))throw new Error("Invalid Resource API protocol.");if(i.username||i.password||i.search||i.hash)throw new Error("Invalid Resource API base address.");const t=new URL(o,i);if(t.origin!==i.origin)throw new Error("Resource request target differs from configured API origin.");if(t.username||t.password)throw new Error("Resource URL cannot contain credentials.");if(!["http:","https:"].includes(t.protocol))throw new Error("Unsupported Resource API protocol.");const s=v.toUpperCase();if(!["GET","POST","PUT","PATCH","DELETE"].includes(s))throw new Error("Unsupported Resource API method.");if(s==="GET"&&f!==null)throw new Error("GET requests cannot contain a body.");let r;switch(p.toLowerCase()){case"auto":r=t.origin===window.location.origin?"same-origin":"include";break;case"sameorigin":r="same-origin";break;case"include":r="include";break;case"omit":r="omit";break;default:throw new Error("Invalid Resource credentials mode.");}if(r==="include"&&t.protocol!=="https:"&&!["localhost","127.0.0.1","[::1]"].includes(t.hostname))throw new Error("Cross-origin credential requests require HTTPS.");if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const c={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":y,"X-Requested-With":"UAuth"};let l=null;f!==null&&f!==undefined&&(c["Content-Type"]="application/json",l=JSON.stringify(f));const a=new AbortController;if(u){if(window.uauth.resourceRequests.has(u))throw new Error("Duplicate Resource request ID.");window.uauth.resourceRequests.set(u,a)}try{const n=await fetch(t.href,{method:s,credentials:r,redirect:"error",headers:c,body:l,signal:a.signal});let i=null;if(n.status!==204&&n.status!==205){const t=await n.text();t.length>0&&(i=t)}return{retryAfter:n.headers.get("Retry-After"),ok:n.ok,status:n.status,body:i,contentType:n.headers.get("Content-Type")}}catch(e){if(e?.name==="AbortError")throw e;if(e instanceof TypeError)return{ok:!1,status:0,body:null,contentType:null};throw e;}finally{u&&window.uauth.resourceRequests.delete(u)}};window.uauth.resourceRequests=new Map;window.uauth.cancelResourceRequest=function(n){const t=window.uauth.resourceRequests.get(n);return t?(t.abort(),!0):!1};window.uauth.validateAuthUrl=function(n,t=null){if(!n||typeof n!="string")throw new Error("Authentication URL is required.");const r=t?new URL(t,window.location.origin):new URL(window.location.origin),i=new URL(n,document.baseURI);if(!["http:","https:"].includes(r.protocol))throw new Error("Unsupported trusted origin protocol.");if(!["http:","https:"].includes(i.protocol))throw new Error("Unsupported authentication URL protocol.");if(r.username||r.password)throw new Error("Trusted origin cannot contain credentials.");if(i.username||i.password)throw new Error("Authentication URL cannot contain credentials.");if(i.origin!==r.origin)throw new Error("Authentication request target differs from the trusted UAuthHub origin.");return i.href};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs index 2387d852..f07be3c2 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs @@ -58,10 +58,10 @@ public interface IFlowClient /// /// Logs out the current user. /// - /// - /// This clears the current session and may trigger redirects. - /// - Task LogoutAsync(); + /// + /// Whether to navigate to the server-provided logout destination after a successful logout. Defaults to true. + /// + Task LogoutAsync(bool navigate = true); /// /// Refreshes the current session or tokens. diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index d61535df..ac2ba235 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -118,17 +118,19 @@ public async Task 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 RefreshAsync(bool isAuto = false) { if (!isAuto) diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs index 03992e17..dbae2494 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs @@ -330,6 +330,41 @@ public async Task Logout_Should_Observe_Server_Response() mock.Verify(x => x.SendFormAsync("/auth/logout", null, true, It.IsAny()), Times.Once); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Logout_Should_Respect_Navigate_Option_And_Publish_Event(bool navigate) + { + var request = new Mock(); + var events = new Mock(); + + request.Setup(x => x.SendFormAsync( + "/auth/logout", + null, + navigate, + It.IsAny())) + .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()), Times.Once); + + events.Verify(x => x.PublishAsync( + It.Is(e => + e.Type == UAuthStateEvent.LogoutVariant)), + Times.Once); + } + [Fact] public async Task Refresh_Should_Not_Mark_Manual_When_Auto() { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs index c09a2df2..8333db93 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs @@ -72,7 +72,7 @@ public Task LogoutAllUserDevicesAsync(UserKey userKey) throw new NotImplementedException(); } - public Task LogoutAsync() + public Task LogoutAsync(bool navigation = true) { throw new NotImplementedException(); }