-
Notifications
You must be signed in to change notification settings - Fork 89
fix(auth): refresh expired access tokens and break the stale-session login loop #3581
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
07b64dd
fix(auth): refresh expired access tokens and break the stale-session …
baktun14 b6ccd2e
fix(auth): harden token refresh and session re-check paths
baktun14 5a478a1
fix(auth): only clear cookies on invalid_grant and guard refresh throws
baktun14 421afcb
fix(auth): gate login re-check on a cached user and a confirmed session
baktun14 cd0767b
refactor(auth): dedupe access-token-expiry predicate and proxy base URL
baktun14 a14bf3a
fix(auth): log real re-check failures and redirect on a stale-user error
baktun14 8a0893f
fix(auth): scope SESSION_RECHECK_FAILED to a notifier-triggered re-check
baktun14 ae1bcbf
fix(auth): clear the re-check outcome flag on every settle
baktun14 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
120 changes: 120 additions & 0 deletions
120
apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| import { useState } from "react"; | ||
| import type { LoggerService } from "@akashnetwork/logging"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { mock } from "vitest-mock-extended"; | ||
|
|
||
| import { SessionExpiryNotifier } from "@src/services/session-expiry-notifier/session-expiry-notifier.service"; | ||
| import type { DEPENDENCIES } from "./SessionExpirySync"; | ||
| import { SessionExpirySync } from "./SessionExpirySync"; | ||
|
|
||
| import { act, render } from "@testing-library/react"; | ||
| import { TestContainerProvider } from "@tests/unit/TestContainerProvider"; | ||
|
|
||
| describe(SessionExpirySync.name, () => { | ||
| it("re-checks the session when notified of an expiry", async () => { | ||
| const { notifier, checkSession } = setup(); | ||
|
|
||
| await act(async () => notifier.notify()); | ||
|
|
||
| expect(checkSession).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("collapses a burst of notifications into a single in-flight re-check", async () => { | ||
| const { notifier, checkSession } = setup({ checkSessionOutcome: "pending" }); | ||
|
|
||
| await act(async () => { | ||
| notifier.notify(); | ||
| notifier.notify(); | ||
| notifier.notify(); | ||
| }); | ||
|
|
||
| expect(checkSession).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("re-checks again once the previous re-check has settled", async () => { | ||
| const { notifier, checkSession } = setup(); | ||
|
|
||
| await act(async () => notifier.notify()); | ||
| await act(async () => notifier.notify()); | ||
|
|
||
| expect(checkSession).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| it("unsubscribes on unmount", async () => { | ||
| const { notifier, checkSession, unmount } = setup(); | ||
|
|
||
| unmount(); | ||
| await act(async () => notifier.notify()); | ||
|
|
||
| expect(checkSession).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("logs when a triggered re-check surfaces a session error", async () => { | ||
| const { notifier, logger } = setup({ checkSessionOutcome: "error" }); | ||
|
|
||
| await act(async () => notifier.notify()); | ||
|
|
||
| expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "SESSION_RECHECK_FAILED" })); | ||
| }); | ||
|
|
||
| it("does not log an auth error that no re-check triggered", () => { | ||
| const { logger } = setup({ initialError: new Error("boot profile fetch failed") }); | ||
|
|
||
| expect(logger.error).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("does not log when a triggered re-check reports no error", async () => { | ||
| const { notifier, logger } = setup(); | ||
|
|
||
| await act(async () => notifier.notify()); | ||
|
|
||
| expect(logger.error).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("swallows and logs an unexpected rejection from the re-check", async () => { | ||
| const { notifier, logger } = setup({ checkSessionOutcome: "rejected" }); | ||
|
|
||
| await act(async () => notifier.notify()); | ||
|
|
||
| expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "SESSION_RECHECK_FAILED" })); | ||
| }); | ||
|
|
||
| it("does not log an unrelated error that lands after a clean re-check", async () => { | ||
| const { notifier, logger, surfaceError } = setup(); | ||
|
|
||
| await act(async () => notifier.notify()); | ||
| await act(async () => surfaceError()); | ||
|
|
||
| expect(logger.error).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| function setup(input: { checkSessionOutcome?: "success" | "pending" | "rejected" | "error"; initialError?: Error } = {}) { | ||
| const notifier = new SessionExpiryNotifier(); | ||
| const logger = mock<LoggerService>(); | ||
| let surfaceError: () => void = () => undefined; | ||
| const checkSession = vi.fn(async () => { | ||
| if (input.checkSessionOutcome === "pending") return new Promise<void>(() => undefined); | ||
| if (input.checkSessionOutcome === "rejected") throw new Error("network down"); | ||
| if (input.checkSessionOutcome === "error") surfaceError(); | ||
| return undefined; | ||
| }); | ||
| const useUser: typeof DEPENDENCIES.useUser = () => { | ||
| const [error, setError] = useState<Error | undefined>(input.initialError); | ||
| surfaceError = () => setError(new Error("network down")); | ||
| return mock<ReturnType<typeof DEPENDENCIES.useUser>>({ | ||
| checkSession, | ||
| isLoading: false, | ||
| user: undefined, | ||
| error | ||
| }); | ||
| }; | ||
|
|
||
| const { unmount } = render( | ||
| <TestContainerProvider services={{ sessionExpiryNotifier: () => notifier, logger: () => logger }}> | ||
| <SessionExpirySync dependencies={{ useUser }} /> | ||
| </TestContainerProvider> | ||
| ); | ||
|
|
||
| return { notifier, checkSession, logger, unmount, surfaceError: () => surfaceError() }; | ||
| } | ||
| }); |
62 changes: 62 additions & 0 deletions
62
apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| "use client"; | ||
| import { useEffect, useRef, useState } from "react"; | ||
|
|
||
| import { useServices } from "@src/context/ServicesProvider"; | ||
| import { useUser } from "@src/hooks/useUser"; | ||
|
|
||
| export const DEPENDENCIES = { useUser }; | ||
|
|
||
| interface Props { | ||
| dependencies?: typeof DEPENDENCIES; | ||
| } | ||
|
|
||
| /** | ||
| * Converges client auth state with the server. A proxied API call that 401s means the server-side | ||
| * session is dead while the Auth0 client context may still hold a cached user; re-fetching the | ||
| * profile drops that user so `RequireAuth` routes to /login cleanly, instead of the app silently | ||
| * firing more requests with an expired token (DEPLOY-WEB-2C4). The in-flight guard collapses a | ||
| * burst of parallel 401s into a single re-check. | ||
| */ | ||
| export function SessionExpirySync({ dependencies: d = DEPENDENCIES }: Props = {}) { | ||
| const { checkSession, error } = d.useUser(); | ||
| const { sessionExpiryNotifier, logger } = useServices(); | ||
| const isReCheckingRef = useRef(false); | ||
| const [reCheckSettleCount, setReCheckSettleCount] = useState(0); | ||
| const reportedSettleCountRef = useRef(0); | ||
|
|
||
| useEffect( | ||
| function reCheckSessionOnExpiryNotice() { | ||
| return sessionExpiryNotifier.subscribe(async () => { | ||
| if (isReCheckingRef.current) return; | ||
| isReCheckingRef.current = true; | ||
| try { | ||
| await checkSession(); | ||
| } catch (thrown) { | ||
| logger.error({ event: "SESSION_RECHECK_FAILED", error: thrown }); | ||
| } finally { | ||
| isReCheckingRef.current = false; | ||
| setReCheckSettleCount(count => count + 1); | ||
| } | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }, | ||
| [sessionExpiryNotifier, checkSession, logger] | ||
| ); | ||
|
baktun14 marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Auth0's `checkSession` swallows a failed profile fetch (network error or 5xx) into the shared | ||
| * `error` state and resolves rather than rejecting, so the catch above never fires for that case. | ||
| * `reCheckSettleCount` ticks once per re-check this component triggered; only acting when it moves | ||
| * past the last reported tick reports that re-check's error alone, so an unrelated auth error (the | ||
| * app-boot profile fetch, the /login re-check) that also lands in `error` isn't mislabeled here. | ||
| */ | ||
| useEffect( | ||
| function reportReCheckOutcome() { | ||
| if (reCheckSettleCount === reportedSettleCountRef.current) return; | ||
| reportedSettleCountRef.current = reCheckSettleCount; | ||
| if (error) logger.error({ event: "SESSION_RECHECK_FAILED", error }); | ||
|
baktun14 marked this conversation as resolved.
|
||
| }, | ||
| [reCheckSettleCount, error, logger] | ||
| ); | ||
|
baktun14 marked this conversation as resolved.
|
||
|
|
||
| return null; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.