mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 04:30:20 +08:00
469 lines
14 KiB
TypeScript
469 lines
14 KiB
TypeScript
import { NETWORK_CONFIG } from '@/config/constants'
|
|
|
|
const REFRESH_LOCK_KEY = 'aether_auth_refresh_lock'
|
|
const REFRESH_RESULT_KEY = 'aether_auth_refresh_result'
|
|
const REFRESH_CHANNEL_NAME = 'aether-auth-refresh'
|
|
const DEFAULT_WAIT_TIMEOUT_MS = NETWORK_CONFIG.API_TIMEOUT + 5000
|
|
const MAX_RETRIES = 2
|
|
|
|
type RefreshStatus = 'success' | 'failure'
|
|
|
|
type RefreshLock = {
|
|
owner: string
|
|
requestId: string
|
|
expiresAt: number
|
|
}
|
|
|
|
type RefreshResult = {
|
|
requestId: string
|
|
status: RefreshStatus
|
|
emittedAt: number
|
|
}
|
|
|
|
type RefreshEventMessage = {
|
|
type: 'refresh-result'
|
|
payload: RefreshResult
|
|
}
|
|
|
|
type Waiter = {
|
|
resolve: (result: RefreshResult) => void
|
|
reject: (error: Error) => void
|
|
timeoutId: ReturnType<typeof setTimeout>
|
|
}
|
|
|
|
type BroadcastMessageEvent = {
|
|
data: unknown
|
|
}
|
|
|
|
export type BroadcastChannelLike = {
|
|
postMessage(data: unknown): void
|
|
addEventListener(type: 'message', listener: (event: BroadcastMessageEvent) => void): void
|
|
removeEventListener(
|
|
type: 'message',
|
|
listener: (event: BroadcastMessageEvent) => void,
|
|
): void
|
|
close?(): void
|
|
}
|
|
|
|
type CoordinatorOptions = {
|
|
storage?: Storage | null
|
|
waitTimeoutMs?: number
|
|
channelFactory?: (name: string) => BroadcastChannelLike | null
|
|
}
|
|
|
|
class CrossTabRefreshTimeoutError extends Error {
|
|
constructor(requestId: string) {
|
|
super(`Timed out while waiting for refresh request ${requestId}`)
|
|
this.name = 'CrossTabRefreshTimeoutError'
|
|
}
|
|
}
|
|
|
|
function createId(): string {
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
return crypto.randomUUID()
|
|
}
|
|
return `refresh-${Math.random().toString(36).slice(2, 10)}-${Date.now()}`
|
|
}
|
|
|
|
function parseJson<T>(raw: string | null): T | null {
|
|
if (!raw) return null
|
|
try {
|
|
return JSON.parse(raw) as T
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function defaultChannelFactory(name: string): BroadcastChannelLike | null {
|
|
if (typeof BroadcastChannel === 'undefined') {
|
|
return null
|
|
}
|
|
return new BroadcastChannel(name)
|
|
}
|
|
|
|
function isDefinitiveRefreshRejection(error: unknown): boolean {
|
|
if (!error || typeof error !== 'object') {
|
|
return false
|
|
}
|
|
const response = (error as { response?: unknown }).response
|
|
if (!response || typeof response !== 'object') {
|
|
return false
|
|
}
|
|
const status = (response as { status?: unknown }).status
|
|
return status === 401 || status === 403
|
|
}
|
|
|
|
export class CrossTabRefreshCoordinator {
|
|
private readonly storage: Storage | null
|
|
private readonly waitTimeoutMs: number
|
|
private readonly tabId = createId()
|
|
private readonly channel: BroadcastChannelLike | null
|
|
private readonly waiters = new Map<string, Waiter>()
|
|
private readonly earlyResults = new Map<string, RefreshResult>()
|
|
private readonly recentSuccesses = new Map<string, number>()
|
|
private readonly successObservers = new Set<() => void>()
|
|
|
|
private readonly onStorage = (event: StorageEvent): void => {
|
|
if (event.key !== REFRESH_RESULT_KEY || !event.newValue) {
|
|
return
|
|
}
|
|
const result = parseJson<RefreshResult>(event.newValue)
|
|
if (result) {
|
|
this.resolveWaiter(result)
|
|
}
|
|
}
|
|
|
|
private readonly onBroadcastMessage = (event: BroadcastMessageEvent): void => {
|
|
const message = event.data as RefreshEventMessage | null
|
|
if (!message || message.type !== 'refresh-result') {
|
|
return
|
|
}
|
|
this.resolveWaiter(message.payload)
|
|
}
|
|
|
|
constructor(options: CoordinatorOptions = {}) {
|
|
this.storage = options.storage ?? (typeof window !== 'undefined' ? window.localStorage : null)
|
|
this.waitTimeoutMs = options.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS
|
|
this.channel = (options.channelFactory ?? defaultChannelFactory)(REFRESH_CHANNEL_NAME)
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('storage', this.onStorage)
|
|
}
|
|
this.channel?.addEventListener('message', this.onBroadcastMessage)
|
|
}
|
|
|
|
destroy(): void {
|
|
if (typeof window !== 'undefined') {
|
|
window.removeEventListener('storage', this.onStorage)
|
|
}
|
|
this.channel?.removeEventListener('message', this.onBroadcastMessage)
|
|
this.channel?.close?.()
|
|
for (const waiter of this.waiters.values()) {
|
|
clearTimeout(waiter.timeoutId)
|
|
}
|
|
this.waiters.clear()
|
|
this.earlyResults.clear()
|
|
this.recentSuccesses.clear()
|
|
this.successObservers.clear()
|
|
}
|
|
|
|
async run(executor: () => Promise<string>, retryCount = 0): Promise<string> {
|
|
const activeLock = this.readActiveLock()
|
|
if (activeLock && activeLock.owner !== this.tabId) {
|
|
return this.waitForRefreshResult(activeLock.requestId, executor, retryCount)
|
|
}
|
|
|
|
const lock = this.tryAcquireLock()
|
|
if (!lock) {
|
|
const currentLock = this.readActiveLock()
|
|
if (currentLock && currentLock.owner !== this.tabId) {
|
|
return this.waitForRefreshResult(currentLock.requestId, executor, retryCount)
|
|
}
|
|
return executor()
|
|
}
|
|
|
|
const attemptStartedAt = Date.now()
|
|
try {
|
|
const accessToken = await executor()
|
|
this.publishRefreshResult({
|
|
requestId: lock.requestId,
|
|
status: 'success',
|
|
emittedAt: Date.now(),
|
|
})
|
|
return accessToken
|
|
} catch (error) {
|
|
// localStorage has no compare-and-swap primitive. Another tab may have
|
|
// won the same refresh rotation even when this tab observed its own
|
|
// lock, so a failure is only a retry hint. Never make a peer treat it as
|
|
// an authoritative logout/final failure.
|
|
const currentLock = this.readActiveLock()
|
|
if (currentLock && currentLock.owner !== this.tabId) {
|
|
// We can prove that this attempt lost the best-effort lock race. Do
|
|
// not emit a failure for a request that another tab superseded; wait
|
|
// for that request's outcome after the finally block releases ours.
|
|
return Promise.resolve().then(() => this.waitForRefreshResult(
|
|
currentLock.requestId,
|
|
executor,
|
|
retryCount + 1,
|
|
))
|
|
}
|
|
|
|
if (this.ownsLock(lock)) {
|
|
this.publishRefreshResult({
|
|
requestId: lock.requestId,
|
|
status: 'failure',
|
|
emittedAt: Date.now(),
|
|
})
|
|
}
|
|
|
|
// The refresh endpoint reserves 409 for a concurrent token rotation.
|
|
// A direct 401/403 is authoritative, so waiting for the HTTP timeout
|
|
// cannot recover the session and would block initial navigation.
|
|
if (isDefinitiveRefreshRejection(error)) {
|
|
throw error
|
|
}
|
|
|
|
// A failure is a hint, never a cross-tab verdict. Give a concurrent
|
|
// winner a bounded opportunity to publish success before returning the
|
|
// local error. If one does, retry using this tab's shared HttpOnly cookie.
|
|
return Promise.resolve().then(async () => {
|
|
const concurrentSuccess = await this.waitForConcurrentSuccess(
|
|
attemptStartedAt,
|
|
retryCount,
|
|
)
|
|
if (concurrentSuccess && retryCount < MAX_RETRIES) {
|
|
return this.run(executor, retryCount + 1)
|
|
}
|
|
throw error
|
|
})
|
|
} finally {
|
|
this.releaseLock(lock)
|
|
}
|
|
}
|
|
|
|
private waitForRefreshResult(requestId: string, executor: () => Promise<string>, retryCount: number): Promise<string> {
|
|
return new Promise<RefreshResult>((resolve, reject) => {
|
|
const earlyResult = this.earlyResults.get(requestId)
|
|
if (earlyResult) {
|
|
this.earlyResults.delete(requestId)
|
|
resolve(earlyResult)
|
|
return
|
|
}
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
this.waiters.delete(requestId)
|
|
reject(new CrossTabRefreshTimeoutError(requestId))
|
|
}, this.waitTimeoutMs)
|
|
|
|
this.waiters.set(requestId, {
|
|
resolve,
|
|
reject,
|
|
timeoutId,
|
|
})
|
|
}).then((result) => {
|
|
if (result.status === 'success') {
|
|
// Tokens are never transferred between tabs. The previous request
|
|
// rotated the shared HttpOnly cookie; wait for its lock release, then
|
|
// obtain a token directly from the server in this tab.
|
|
return this.waitForLockRelease(executor, retryCount)
|
|
}
|
|
|
|
// A failure from another tab is deliberately non-authoritative. It can
|
|
// be the loser of a refresh-token rotation, so wait for that attempt to
|
|
// release its lock and verify the shared session from this tab.
|
|
if (retryCount >= MAX_RETRIES) {
|
|
return this.run(executor, retryCount)
|
|
}
|
|
return this.waitForLockRelease(executor, retryCount + 1)
|
|
}).catch((error: unknown) => {
|
|
if (error instanceof CrossTabRefreshTimeoutError) {
|
|
if (retryCount >= MAX_RETRIES) {
|
|
return executor()
|
|
}
|
|
return this.run(executor, retryCount + 1)
|
|
}
|
|
throw error
|
|
})
|
|
}
|
|
|
|
private waitForLockRelease(executor: () => Promise<string>, retryCount: number): Promise<string> {
|
|
return new Promise<string>((resolve) => {
|
|
const deadline = Date.now() + this.waitTimeoutMs
|
|
const wait = () => {
|
|
const activeLock = this.readActiveLock()
|
|
if (!activeLock || activeLock.owner === this.tabId || Date.now() >= deadline) {
|
|
resolve(this.run(executor, retryCount))
|
|
return
|
|
}
|
|
setTimeout(wait, 0)
|
|
}
|
|
wait()
|
|
})
|
|
}
|
|
|
|
private waitForConcurrentSuccess(attemptStartedAt: number, retryCount: number): Promise<boolean> {
|
|
if (retryCount >= MAX_RETRIES) {
|
|
return Promise.resolve(false)
|
|
}
|
|
|
|
const hasSuccess = (): boolean => {
|
|
for (const [requestId, emittedAt] of this.recentSuccesses) {
|
|
if (emittedAt >= attemptStartedAt && requestId) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
if (hasSuccess()) {
|
|
return Promise.resolve(true)
|
|
}
|
|
|
|
return new Promise<boolean>((resolve) => {
|
|
let settled = false
|
|
const finish = (value: boolean): void => {
|
|
if (settled) return
|
|
settled = true
|
|
this.successObservers.delete(onSuccess)
|
|
clearTimeout(timeoutId)
|
|
resolve(value)
|
|
}
|
|
const onSuccess = (): void => {
|
|
if (hasSuccess()) {
|
|
finish(true)
|
|
}
|
|
}
|
|
this.successObservers.add(onSuccess)
|
|
// A competing request can legitimately run until the HTTP client timeout.
|
|
// The coordinator timeout includes that budget for retryable failures.
|
|
const timeoutId = setTimeout(() => finish(hasSuccess()), this.waitTimeoutMs)
|
|
// A result may have arrived between the initial check and observer
|
|
// registration, so check once more synchronously.
|
|
onSuccess()
|
|
})
|
|
}
|
|
|
|
private tryAcquireLock(): RefreshLock | null {
|
|
if (!this.storage) {
|
|
return {
|
|
owner: this.tabId,
|
|
requestId: createId(),
|
|
expiresAt: Date.now() + this.waitTimeoutMs,
|
|
}
|
|
}
|
|
|
|
const existing = this.readActiveLock()
|
|
if (existing && existing.owner !== this.tabId) {
|
|
return null
|
|
}
|
|
|
|
const lock: RefreshLock = {
|
|
owner: this.tabId,
|
|
requestId: createId(),
|
|
expiresAt: Date.now() + this.waitTimeoutMs,
|
|
}
|
|
|
|
try {
|
|
// 这是一个 best-effort 跨标签页锁;写入后立刻回读,只认最终赢得竞态的 owner。
|
|
this.storage.setItem(REFRESH_LOCK_KEY, JSON.stringify(lock))
|
|
const current = this.readLock()
|
|
if (current && current.owner === lock.owner && current.requestId === lock.requestId) {
|
|
return current
|
|
}
|
|
} catch {
|
|
return lock
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
private releaseLock(lock: RefreshLock): void {
|
|
if (!this.storage) {
|
|
return
|
|
}
|
|
try {
|
|
const current = this.readLock()
|
|
if (current && current.owner === lock.owner && current.requestId === lock.requestId) {
|
|
this.storage.removeItem(REFRESH_LOCK_KEY)
|
|
}
|
|
} catch {
|
|
// ignore storage release failures and allow lock TTL to expire naturally
|
|
}
|
|
}
|
|
|
|
private ownsLock(lock: RefreshLock): boolean {
|
|
// Without storage the synthetic lock is local to this coordinator. A
|
|
// BroadcastChannel peer cannot atomically replace it, so treat it as ours
|
|
// for the bounded failure/retry policy.
|
|
if (!this.storage) {
|
|
return true
|
|
}
|
|
const current = this.readLock()
|
|
return Boolean(
|
|
current
|
|
&& current.owner === lock.owner
|
|
&& current.requestId === lock.requestId,
|
|
)
|
|
}
|
|
|
|
private publishRefreshResult(result: RefreshResult): void {
|
|
const message: RefreshEventMessage = {
|
|
type: 'refresh-result',
|
|
payload: result,
|
|
}
|
|
this.channel?.postMessage(message)
|
|
if (!this.storage) {
|
|
return
|
|
}
|
|
try {
|
|
this.storage.setItem(REFRESH_RESULT_KEY, JSON.stringify(result))
|
|
// Result metadata is transient; access tokens never enter storage.
|
|
setTimeout(() => {
|
|
try {
|
|
this.storage?.removeItem(REFRESH_RESULT_KEY)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, 2000)
|
|
} catch {
|
|
// ignore storage publish failures; BroadcastChannel already covers most browsers
|
|
}
|
|
}
|
|
|
|
private resolveWaiter(result: RefreshResult): void {
|
|
if (result.status === 'success') {
|
|
this.recentSuccesses.set(result.requestId, result.emittedAt)
|
|
setTimeout(() => {
|
|
if (this.recentSuccesses.get(result.requestId) === result.emittedAt) {
|
|
this.recentSuccesses.delete(result.requestId)
|
|
}
|
|
}, this.waitTimeoutMs)
|
|
for (const observer of this.successObservers) {
|
|
observer()
|
|
}
|
|
}
|
|
const waiter = this.waiters.get(result.requestId)
|
|
if (!waiter) {
|
|
this.earlyResults.set(result.requestId, result)
|
|
setTimeout(() => {
|
|
if (this.earlyResults.get(result.requestId) === result) {
|
|
this.earlyResults.delete(result.requestId)
|
|
}
|
|
}, this.waitTimeoutMs)
|
|
return
|
|
}
|
|
clearTimeout(waiter.timeoutId)
|
|
this.waiters.delete(result.requestId)
|
|
waiter.resolve(result)
|
|
}
|
|
|
|
private readActiveLock(): RefreshLock | null {
|
|
const lock = this.readLock()
|
|
if (!lock) {
|
|
return null
|
|
}
|
|
if (lock.expiresAt > Date.now()) {
|
|
return lock
|
|
}
|
|
if (this.storage) {
|
|
try {
|
|
this.storage.removeItem(REFRESH_LOCK_KEY)
|
|
} catch {
|
|
// ignore storage cleanup failures; stale lock will age out on next write
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
private readLock(): RefreshLock | null {
|
|
if (!this.storage) {
|
|
return null
|
|
}
|
|
try {
|
|
return parseJson<RefreshLock>(this.storage.getItem(REFRESH_LOCK_KEY))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
}
|