Repository navigation
Expand file tree
/
Copy pathsession-expiry.ts
More file actions
157 lines (144 loc) · 5.12 KB
/
Copy pathsession-expiry.ts
File metadata and controls
157 lines (144 loc) · 5.12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
import { isAuthApiError, isAuthRetryableFetchError, isAuthSessionMissingError } from '@supabase/supabase-js';
import { getActiveEnv } from './env';
// Beside the session itself (client.ts's AUTH_STORAGE_KEY), per project.
const EXPIRED_FLAG_KEY = `debate-auth-${getActiveEnv()}-expired`;
// After the server accepts a refresh, later refusals in the same burst are the
// old token's requests landing late. Don't refresh again for each of them.
const RECHECK_AFTER_MS = 10_000;
const BACKOFF_START_MS = 1_000;
const BACKOFF_CAP_MS = 60_000;
type SessionAuth = {
refreshSession: () => Promise<{ error: unknown }>;
signOut: (options: { scope: 'local' }) => Promise<unknown>;
};
/**
* Whether a failed forced refresh means the session is gone for good (revoked,
* deleted account, reset password) rather than Auth being unreachable. Network
* failures, 5xx and rate limits keep the session: that is an outage.
*/
export function refreshMeansRevoked(error: unknown): boolean {
if (!error) return false;
if (isAuthRetryableFetchError(error)) return false;
if (isAuthSessionMissingError(error)) return true;
if (isAuthApiError(error)) return error.status >= 400 && error.status < 500 && error.status !== 429;
return false;
}
let checking = false;
let confirmedAt = Number.NEGATIVE_INFINITY;
let backoffMs = BACKOFF_START_MS;
let retryNotBefore = Number.NEGATIVE_INFINITY;
let dead = false;
let signingOut: Promise<void> | null = null;
/** A restored session was refused. Further SIGNED_IN events must not reload data. */
export function restoredSessionsBlocked(): boolean {
return dead;
}
/** An explicit sign-in is a new session, so stop ignoring restores. */
export function allowSessionsAfterSignIn(): void {
dead = false;
backoffMs = BACKOFF_START_MS;
retryNotBefore = Number.NEGATIVE_INFINITY;
}
/**
* The server refused this browser's user. Sign out once, locally, and ignore
* any session the client restores afterwards.
*/
export async function rejectDeadSession(auth: SessionAuth): Promise<void> {
dead = true;
if (signingOut) return signingOut;
signingOut = (async () => {
markSessionExpired();
try {
// Local scope: the server already dropped this session, and other devices are not ours to sign out.
await auth.signOut({ scope: 'local' });
} catch (error) {
console.warn('[Auth] Local sign-out after a refused session failed:', error);
}
})();
return signingOut;
}
/**
* A request was refused for auth. A 401/403 from Auth's own user lookup means
* the session is already dead, so sign out without another refresh (a refresh
* can succeed and keep the loop going). A REST 401 still gets one refresh.
* Outages back off. They never retry on a fixed short interval.
* Run it outside any supabase-js request (client.ts queues it).
*/
export async function verifySessionAfterRejection(
auth: SessionAuth,
now: () => number = Date.now,
options?: { userLookup?: boolean },
): Promise<'kept' | 'expired' | 'skipped'> {
if (dead) return 'expired';
if (checking) return 'skipped';
const clock = now();
if (clock - confirmedAt < RECHECK_AFTER_MS || clock < retryNotBefore) return 'skipped';
checking = true;
try {
if (options?.userLookup) {
await rejectDeadSession(auth);
return 'expired';
}
const { error } = await auth.refreshSession();
if (!error) {
confirmedAt = clock;
backoffMs = BACKOFF_START_MS;
retryNotBefore = Number.NEGATIVE_INFINITY;
return 'kept';
}
if (!refreshMeansRevoked(error)) {
retryNotBefore = clock + backoffMs;
backoffMs = Math.min(backoffMs * 2, BACKOFF_CAP_MS);
return 'kept';
}
await rejectDeadSession(auth);
return 'expired';
} catch (error) {
console.warn('[Auth] Session check after a refused request failed:', error);
retryNotBefore = clock + backoffMs;
backoffMs = Math.min(backoffMs * 2, BACKOFF_CAP_MS);
return 'kept';
} finally {
checking = false;
}
}
/**
* The student chose Sign in again after a request was refused for auth. Sign
* out here only; the login page says the session ended, and RequireAuth sends
* them back to the page they were on once they sign in.
*/
export async function endSessionToSignInAgain(auth: Pick<SessionAuth, 'signOut'>): Promise<void> {
markSessionExpired();
await auth.signOut({ scope: 'local' });
}
/** Test hook: forget the last confirmed refresh. */
export function resetSessionExpiryCheck(): void {
checking = false;
confirmedAt = Number.NEGATIVE_INFINITY;
backoffMs = BACKOFF_START_MS;
retryNotBefore = Number.NEGATIVE_INFINITY;
dead = false;
signingOut = null;
}
function markSessionExpired(): void {
try {
sessionStorage.setItem(EXPIRED_FLAG_KEY, '1');
} catch {
// Storage blocked: the login page just won't say why.
}
}
/** Whether this tab was signed out because the server refused its session. */
export function sessionExpiredNotice(): boolean {
try {
return sessionStorage.getItem(EXPIRED_FLAG_KEY) === '1';
} catch {
return false;
}
}
export function clearSessionExpiredNotice(): void {
try {
sessionStorage.removeItem(EXPIRED_FLAG_KEY);
} catch {
// Nothing to clear.
}
}