@rebasepro/server 0.10.1-canary.31c773c → 0.10.1-canary.811b3da
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/interfaces.d.ts +65 -2
- package/dist/auth/jwt.d.ts +17 -0
- package/dist/auth/routes.d.ts +16 -0
- package/dist/index.es.js +120 -18
- package/dist/index.es.js.map +1 -1
- package/dist/{jwt-B3zjddCa.js → jwt-Dj7r7QX7.js} +36 -24
- package/dist/{jwt-B3zjddCa.js.map → jwt-Dj7r7QX7.js.map} +1 -1
- package/package.json +5 -5
|
@@ -119,6 +119,33 @@ export interface RefreshTokenInfo {
|
|
|
119
119
|
createdAt: Date;
|
|
120
120
|
userAgent?: string | null;
|
|
121
121
|
ipAddress?: string | null;
|
|
122
|
+
/**
|
|
123
|
+
* The sign-in this token descends from. Every token minted by rotating
|
|
124
|
+
* this one carries the same id.
|
|
125
|
+
*
|
|
126
|
+
* Optional because a custom {@link TokenRepository} written against an
|
|
127
|
+
* older release does not supply it; the refresh endpoint then treats the
|
|
128
|
+
* token as a session of one, which costs reuse tolerance but still works.
|
|
129
|
+
*/
|
|
130
|
+
sessionId?: string;
|
|
131
|
+
/**
|
|
132
|
+
* Set when this token was superseded by a rotation. Being superseded is
|
|
133
|
+
* not an error — a client whose response was lost still holds it — so it
|
|
134
|
+
* stays usable to mint a sibling for a short window after this instant.
|
|
135
|
+
*/
|
|
136
|
+
rotatedAt?: Date | null;
|
|
137
|
+
/** Hard kill (logout, remote session revoke). Never usable again. */
|
|
138
|
+
revoked?: boolean;
|
|
139
|
+
/** When the sign-in happened; carried across rotations, unlike createdAt. */
|
|
140
|
+
sessionStartedAt?: Date;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Identity of the sign-in a refresh token belongs to, threaded through
|
|
144
|
+
* rotation so descendants stay grouped.
|
|
145
|
+
*/
|
|
146
|
+
export interface RefreshTokenSession {
|
|
147
|
+
id: string;
|
|
148
|
+
startedAt: Date;
|
|
122
149
|
}
|
|
123
150
|
/**
|
|
124
151
|
* Password reset token info
|
|
@@ -278,9 +305,45 @@ export interface RoleRepository {
|
|
|
278
305
|
*/
|
|
279
306
|
export interface TokenRepository {
|
|
280
307
|
/**
|
|
281
|
-
* Create a new refresh token
|
|
308
|
+
* Create a new refresh token.
|
|
309
|
+
*
|
|
310
|
+
* `session` groups this token with the sign-in it descends from. It is
|
|
311
|
+
* optional so that repositories written against an older release keep
|
|
312
|
+
* satisfying this interface; implementations that ignore it degrade to one
|
|
313
|
+
* session per token.
|
|
314
|
+
*/
|
|
315
|
+
createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string, session?: RefreshTokenSession): Promise<void>;
|
|
316
|
+
/**
|
|
317
|
+
* Mark a token as superseded by a rotation, WITHOUT making it unusable.
|
|
318
|
+
*
|
|
319
|
+
* The distinction from deletion is the entire point: a client that never
|
|
320
|
+
* received the rotated response still holds this token, and must be able
|
|
321
|
+
* to present it and be recognised. Implementations that omit this method
|
|
322
|
+
* fall back to {@link TokenRepository.deleteRefreshToken}, which restores
|
|
323
|
+
* the old, lossy behaviour.
|
|
324
|
+
*/
|
|
325
|
+
markRefreshTokenRotated?(tokenHash: string): Promise<void>;
|
|
326
|
+
/**
|
|
327
|
+
* Hard-kill every token of one sign-in (logout, remote session revoke).
|
|
328
|
+
* Unlike rotation this is final — no grace, no replay.
|
|
329
|
+
*/
|
|
330
|
+
revokeRefreshTokenSession?(sessionId: string): Promise<void>;
|
|
331
|
+
/**
|
|
332
|
+
* Drop tokens of a session that were superseded before `supersededBefore`,
|
|
333
|
+
* plus anything already expired. Keeps rotation from growing a row per
|
|
334
|
+
* refresh forever; called opportunistically, never load-bearing.
|
|
335
|
+
*/
|
|
336
|
+
pruneRefreshTokens?(uid: string, sessionId: string, supersededBefore: Date): Promise<void>;
|
|
337
|
+
/**
|
|
338
|
+
* The instant before which every session of this user is void, or null if
|
|
339
|
+
* none is set. See `users.tokens_valid_after`.
|
|
340
|
+
*/
|
|
341
|
+
getTokensValidAfter?(uid: string): Promise<Date | null>;
|
|
342
|
+
/**
|
|
343
|
+
* Void every session that began before `at`. Set alongside deleting the
|
|
344
|
+
* user's tokens so a rotation racing the delete cannot survive it.
|
|
282
345
|
*/
|
|
283
|
-
|
|
346
|
+
setTokensValidAfter?(uid: string, at: Date): Promise<void>;
|
|
284
347
|
/**
|
|
285
348
|
* Find a refresh token by hash
|
|
286
349
|
*/
|
package/dist/auth/jwt.d.ts
CHANGED
|
@@ -65,6 +65,23 @@ export declare function generateRefreshToken(): string;
|
|
|
65
65
|
* Hash a refresh token for database storage (don't store raw tokens)
|
|
66
66
|
*/
|
|
67
67
|
export declare function hashRefreshToken(token: string): string;
|
|
68
|
+
/**
|
|
69
|
+
* The longest a cookie can live. Chrome (since 104) and RFC 6265bis silently
|
|
70
|
+
* rewrite any `Max-Age` beyond 400 days down to 400 days, so promising a
|
|
71
|
+
* browser more is not a stricter policy — it is a policy that differs from
|
|
72
|
+
* what is actually enforced, which is worse than knowing the ceiling.
|
|
73
|
+
*/
|
|
74
|
+
export declare const MAX_COOKIE_AGE_MS: number;
|
|
75
|
+
/**
|
|
76
|
+
* How long a refresh token is valid for, in milliseconds.
|
|
77
|
+
*
|
|
78
|
+
* Every rotation issues a token with a fresh TTL, so this is a sliding window:
|
|
79
|
+
* a user who visits at all keeps their session indefinitely, and one who
|
|
80
|
+
* disappears loses it this long after their last visit. That is what both
|
|
81
|
+
* Firebase and Supabase do by default, and it is the behaviour people mean
|
|
82
|
+
* when they say they expect to still be signed in.
|
|
83
|
+
*/
|
|
84
|
+
export declare function getRefreshTokenTtlMs(): number;
|
|
68
85
|
/**
|
|
69
86
|
* Calculate refresh token expiration date
|
|
70
87
|
*/
|
package/dist/auth/routes.d.ts
CHANGED
|
@@ -47,6 +47,22 @@ export interface AuthModuleConfig {
|
|
|
47
47
|
* auth endpoints, and CORS must allow credentials (no `origin: "*"`).
|
|
48
48
|
*/
|
|
49
49
|
cookieAuth?: CookieAuthConfig;
|
|
50
|
+
/**
|
|
51
|
+
* How long a refresh token stays usable after it has been rotated away,
|
|
52
|
+
* in seconds. Default 10, matching GoTrue's `refresh_token_reuse_interval`.
|
|
53
|
+
*
|
|
54
|
+
* Rotation is only safe if the client is guaranteed to receive the
|
|
55
|
+
* replacement, and no network guarantees that. A pod rolls mid-response, a
|
|
56
|
+
* laptop suspends, a second tab boots at the same instant — and the client
|
|
57
|
+
* is left holding a token the database has moved past. Within this window
|
|
58
|
+
* that client is handed a fresh token of the same session instead of a
|
|
59
|
+
* 401, which is the difference between a hiccup and being silently signed
|
|
60
|
+
* out of an app you were using.
|
|
61
|
+
*
|
|
62
|
+
* Widen it if your clients are flaky or your deploys are long; the cost is
|
|
63
|
+
* how long a captured token stays useful to someone who copied it.
|
|
64
|
+
*/
|
|
65
|
+
refreshTokenReuseIntervalSeconds?: number;
|
|
50
66
|
}
|
|
51
67
|
/**
|
|
52
68
|
* Configuration for httpOnly refresh-token cookies.
|
package/dist/index.es.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire as __createRequire } from "module";
|
|
2
2
|
import process from "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
|
-
import { a as
|
|
4
|
+
import { _ as __toESM, a as generateRefreshToken, c as getRefreshTokenTtlMs, d as verifyAccessToken, f as verifyDownloadToken, g as __require, h as __exportAll, i as generateDownloadToken, l as hashRefreshToken, m as __commonJSMin, n as configureJwt, o as getAccessTokenExpiry, p as require_jsonwebtoken, r as generateAccessToken, s as getRefreshTokenExpiry, t as MAX_COOKIE_AGE_MS } from "./jwt-Dj7r7QX7.js";
|
|
5
5
|
import { _ as RebaseApiError, a as policy, c as isPostgresCollectionConfig, d as REST_TO_CANONICAL, f as toCanonicalOp, g as Vector, h as GeoPoint, i as isSQLAdmin, l as CANONICAL_TO_REST, m as EntityRelation, o as getCollectionDataPath, p as EntityReference, r as getDataSourceCapabilities, s as getDeclaredSubcollections, t as DEFAULT_STORAGE_SOURCE_KEY, u as NULL_OPS, v as RebaseClientError } from "./src-CsHhSKbi.js";
|
|
6
6
|
import { t as logger } from "./logger-BYU66ENZ.js";
|
|
7
7
|
import { t as nativeDynamicImport } from "./dynamic-import-Dvh-K5fl.js";
|
|
@@ -9888,7 +9888,10 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
9888
9888
|
const roleIds = (await authRepo.getUserRoles(userCtx.uid)).map((r) => r.id);
|
|
9889
9889
|
const accessToken = generateAccessToken(userCtx.uid, roleIds, "aal2");
|
|
9890
9890
|
const refreshToken = generateRefreshToken();
|
|
9891
|
-
await authRepo.createRefreshToken(userCtx.uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown"
|
|
9891
|
+
await authRepo.createRefreshToken(userCtx.uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown", {
|
|
9892
|
+
id: randomUUID$1(),
|
|
9893
|
+
startedAt: /* @__PURE__ */ new Date()
|
|
9894
|
+
});
|
|
9892
9895
|
if (ops.onMfaVerified) ops.onMfaVerified(userCtx.uid, factor.id).catch((err) => {
|
|
9893
9896
|
logger.error("[AuthHooks] onMfaVerified error", { error: err instanceof Error ? err.message : err });
|
|
9894
9897
|
});
|
|
@@ -9959,7 +9962,8 @@ function setRefreshCookie(c, refreshToken, config) {
|
|
|
9959
9962
|
let cookie = `${settings.name}=${encodeURIComponent(refreshToken)}; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}`;
|
|
9960
9963
|
if (isSecure) cookie += "; Secure";
|
|
9961
9964
|
if (settings.domain) cookie += `; Domain=${settings.domain}`;
|
|
9962
|
-
|
|
9965
|
+
const maxAgeSeconds = Math.floor(Math.min(getRefreshTokenTtlMs(), MAX_COOKIE_AGE_MS) / 1e3);
|
|
9966
|
+
cookie += `; Max-Age=${maxAgeSeconds}`;
|
|
9963
9967
|
c.header("Set-Cookie", cookie, { append: true });
|
|
9964
9968
|
}
|
|
9965
9969
|
/**
|
|
@@ -10025,12 +10029,14 @@ function mountSessionRoutes(opts) {
|
|
|
10025
10029
|
const refreshToken = readRefreshToken(c, parseBody(logoutSchema, await c.req.json().catch(() => ({}))), config.cookieAuth);
|
|
10026
10030
|
if (refreshToken) {
|
|
10027
10031
|
const tokenHash = hashRefreshToken(refreshToken);
|
|
10028
|
-
await authRepo.
|
|
10032
|
+
const sessionId = (await authRepo.findRefreshTokenByHash(tokenHash).catch(() => null))?.sessionId;
|
|
10033
|
+
if (sessionId && authRepo.revokeRefreshTokenSession) await authRepo.revokeRefreshTokenSession(sessionId);
|
|
10034
|
+
else await authRepo.deleteRefreshToken(tokenHash);
|
|
10029
10035
|
}
|
|
10030
10036
|
clearRefreshCookie(c, config.cookieAuth);
|
|
10031
10037
|
const authHeader = c.req.header("authorization");
|
|
10032
10038
|
if (ops.afterLogout && authHeader?.startsWith("Bearer ")) {
|
|
10033
|
-
const { verifyAccessToken } = await import("./jwt-
|
|
10039
|
+
const { verifyAccessToken } = await import("./jwt-Dj7r7QX7.js").then((n) => n.u);
|
|
10034
10040
|
const payload = verifyAccessToken(authHeader.substring(7));
|
|
10035
10041
|
if (payload) ops.afterLogout(payload.uid).catch((err) => {
|
|
10036
10042
|
logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
|
|
@@ -10047,12 +10053,20 @@ function mountSessionRoutes(opts) {
|
|
|
10047
10053
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
10048
10054
|
const currentRefreshToken = c.req.header("x-refresh-token");
|
|
10049
10055
|
const currentTokenHash = currentRefreshToken ? hashRefreshToken(currentRefreshToken) : null;
|
|
10050
|
-
const
|
|
10056
|
+
const tokens = await authRepo.listRefreshTokensForUser(userCtx.uid);
|
|
10057
|
+
const bySession = /* @__PURE__ */ new Map();
|
|
10058
|
+
for (const token of tokens) {
|
|
10059
|
+
if (token.revoked) continue;
|
|
10060
|
+
const key = token.sessionId ?? token.id;
|
|
10061
|
+
const existing = bySession.get(key);
|
|
10062
|
+
if (!existing || existing.createdAt < token.createdAt) bySession.set(key, token);
|
|
10063
|
+
}
|
|
10064
|
+
const mappedSessions = [...bySession.values()].map((s) => ({
|
|
10051
10065
|
id: s.id,
|
|
10052
10066
|
userAgent: s.userAgent,
|
|
10053
10067
|
ipAddress: s.ipAddress,
|
|
10054
|
-
createdAt: s.createdAt,
|
|
10055
|
-
isCurrentSession: currentTokenHash ?
|
|
10068
|
+
createdAt: s.sessionStartedAt ?? s.createdAt,
|
|
10069
|
+
isCurrentSession: currentTokenHash ? tokens.some((t) => t.tokenHash === currentTokenHash && (t.sessionId ?? t.id) === (s.sessionId ?? s.id)) : false
|
|
10056
10070
|
}));
|
|
10057
10071
|
return c.json({ sessions: mappedSessions });
|
|
10058
10072
|
});
|
|
@@ -10064,6 +10078,7 @@ function mountSessionRoutes(opts) {
|
|
|
10064
10078
|
const userCtx = c.get("user");
|
|
10065
10079
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
10066
10080
|
await authRepo.deleteAllRefreshTokensForUser(userCtx.uid);
|
|
10081
|
+
await authRepo.setTokensValidAfter?.(userCtx.uid, /* @__PURE__ */ new Date()).catch(() => void 0);
|
|
10067
10082
|
return c.json({
|
|
10068
10083
|
success: true,
|
|
10069
10084
|
message: "All sessions revoked successfully"
|
|
@@ -10078,7 +10093,9 @@ function mountSessionRoutes(opts) {
|
|
|
10078
10093
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
10079
10094
|
const id = c.req.param("id");
|
|
10080
10095
|
if (!id) throw ApiError.badRequest("Session ID is required", "INVALID_INPUT");
|
|
10081
|
-
await authRepo.
|
|
10096
|
+
const target = (await authRepo.listRefreshTokensForUser(userCtx.uid).catch(() => [])).find((t) => t.id === id);
|
|
10097
|
+
if (target?.sessionId && authRepo.revokeRefreshTokenSession) await authRepo.revokeRefreshTokenSession(target.sessionId);
|
|
10098
|
+
else await authRepo.deleteRefreshTokenById(id, userCtx.uid);
|
|
10082
10099
|
return c.json({
|
|
10083
10100
|
success: true,
|
|
10084
10101
|
message: "Session revoked successfully"
|
|
@@ -10442,7 +10459,10 @@ function createAuthRoutes(config) {
|
|
|
10442
10459
|
}
|
|
10443
10460
|
const accessToken = generateAccessToken(uid, roleIds, "aal1", customClaims);
|
|
10444
10461
|
const refreshToken = generateRefreshToken();
|
|
10445
|
-
await authRepo.createRefreshToken(uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress
|
|
10462
|
+
await authRepo.createRefreshToken(uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress, {
|
|
10463
|
+
id: randomUUID$1(),
|
|
10464
|
+
startedAt: /* @__PURE__ */ new Date()
|
|
10465
|
+
});
|
|
10446
10466
|
return {
|
|
10447
10467
|
roleIds,
|
|
10448
10468
|
accessToken,
|
|
@@ -10675,6 +10695,7 @@ function createAuthRoutes(config) {
|
|
|
10675
10695
|
await authRepo.updatePassword(storedToken.uid, passwordHash);
|
|
10676
10696
|
await authRepo.markPasswordResetTokenUsed(tokenHash);
|
|
10677
10697
|
await authRepo.deleteAllRefreshTokensForUser(storedToken.uid);
|
|
10698
|
+
await authRepo.setTokensValidAfter?.(storedToken.uid, /* @__PURE__ */ new Date()).catch(() => void 0);
|
|
10678
10699
|
if (ops.onPasswordReset) ops.onPasswordReset(storedToken.uid).catch((err) => {
|
|
10679
10700
|
logger.error("[AuthHooks] onPasswordReset error", { error: err instanceof Error ? err.message : err });
|
|
10680
10701
|
});
|
|
@@ -10699,6 +10720,7 @@ function createAuthRoutes(config) {
|
|
|
10699
10720
|
const passwordHash = await ops.hashPassword(newPassword);
|
|
10700
10721
|
await authRepo.updatePassword(user.id, passwordHash);
|
|
10701
10722
|
await authRepo.deleteAllRefreshTokensForUser(user.id);
|
|
10723
|
+
await authRepo.setTokensValidAfter?.(user.id, /* @__PURE__ */ new Date()).catch(() => void 0);
|
|
10702
10724
|
return c.json({
|
|
10703
10725
|
success: true,
|
|
10704
10726
|
message: "Password has been changed successfully"
|
|
@@ -10762,15 +10784,34 @@ function createAuthRoutes(config) {
|
|
|
10762
10784
|
if (!refreshToken) throw ApiError.unauthenticated("No refresh token presented", "NO_SESSION");
|
|
10763
10785
|
const tokenHash = hashRefreshToken(refreshToken);
|
|
10764
10786
|
const storedToken = await authRepo.findRefreshTokenByHash(tokenHash);
|
|
10765
|
-
if (!storedToken)
|
|
10787
|
+
if (!storedToken) throw ApiError.unauthorized("Invalid refresh token", "INVALID_TOKEN");
|
|
10788
|
+
if (storedToken.revoked) {
|
|
10766
10789
|
clearRefreshCookie(c, config.cookieAuth);
|
|
10767
|
-
throw ApiError.unauthorized("
|
|
10790
|
+
throw ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED");
|
|
10768
10791
|
}
|
|
10769
10792
|
if (/* @__PURE__ */ new Date() > storedToken.expiresAt) {
|
|
10770
10793
|
await authRepo.deleteRefreshToken(tokenHash);
|
|
10771
10794
|
clearRefreshCookie(c, config.cookieAuth);
|
|
10772
10795
|
throw ApiError.unauthorized("Refresh token expired", "TOKEN_EXPIRED");
|
|
10773
10796
|
}
|
|
10797
|
+
const sessionStartedAt = storedToken.sessionStartedAt ?? storedToken.createdAt;
|
|
10798
|
+
const tokensValidAfter = await authRepo.getTokensValidAfter?.(storedToken.uid).catch(() => null);
|
|
10799
|
+
if (tokensValidAfter && sessionStartedAt < tokensValidAfter) {
|
|
10800
|
+
await authRepo.deleteRefreshToken(tokenHash);
|
|
10801
|
+
clearRefreshCookie(c, config.cookieAuth);
|
|
10802
|
+
throw ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED");
|
|
10803
|
+
}
|
|
10804
|
+
const reuseWindowMs = Math.max(0, config.refreshTokenReuseIntervalSeconds ?? 10) * 1e3;
|
|
10805
|
+
const supersededAt = storedToken.rotatedAt ? new Date(storedToken.rotatedAt) : null;
|
|
10806
|
+
if (supersededAt && Date.now() - supersededAt.getTime() > reuseWindowMs) {
|
|
10807
|
+
logger.warn("[Auth] Refresh token replayed after the reuse window", {
|
|
10808
|
+
uid: storedToken.uid,
|
|
10809
|
+
sessionId: storedToken.sessionId,
|
|
10810
|
+
supersededSecondsAgo: Math.round((Date.now() - supersededAt.getTime()) / 1e3),
|
|
10811
|
+
userAgent: c.req.header("user-agent") || "unknown"
|
|
10812
|
+
});
|
|
10813
|
+
throw ApiError.unauthorized("Refresh token already used", "TOKEN_ALREADY_USED");
|
|
10814
|
+
}
|
|
10774
10815
|
const roleIds = (await authRepo.getUserRoles(storedToken.uid)).map((r) => r.id);
|
|
10775
10816
|
const user = await authRepo.getUserById(storedToken.uid).catch((err) => {
|
|
10776
10817
|
logger.warn("[Auth] Could not load user during token refresh; returning tokens only", {
|
|
@@ -10792,8 +10833,16 @@ function createAuthRoutes(config) {
|
|
|
10792
10833
|
const newRefreshToken = generateRefreshToken();
|
|
10793
10834
|
const userAgent = c.req.header("user-agent") || "unknown";
|
|
10794
10835
|
const ipAddress = c.req.header("x-forwarded-for") || "unknown";
|
|
10795
|
-
|
|
10796
|
-
|
|
10836
|
+
const session = {
|
|
10837
|
+
id: storedToken.sessionId ?? storedToken.id,
|
|
10838
|
+
startedAt: sessionStartedAt
|
|
10839
|
+
};
|
|
10840
|
+
if (!supersededAt) if (authRepo.markRefreshTokenRotated) await authRepo.markRefreshTokenRotated(tokenHash);
|
|
10841
|
+
else await authRepo.deleteRefreshToken(tokenHash);
|
|
10842
|
+
await authRepo.createRefreshToken(storedToken.uid, hashRefreshToken(newRefreshToken), getRefreshTokenExpiry(), userAgent, ipAddress, session);
|
|
10843
|
+
authRepo.pruneRefreshTokens?.(storedToken.uid, session.id, new Date(Date.now() - reuseWindowMs)).catch((err) => {
|
|
10844
|
+
logger.warn("[Auth] Refresh token prune failed", { error: err instanceof Error ? err.message : String(err) });
|
|
10845
|
+
});
|
|
10797
10846
|
const tokensOnlyResponse = { tokens: {
|
|
10798
10847
|
accessToken: newAccessToken,
|
|
10799
10848
|
refreshToken: newRefreshToken,
|
|
@@ -15032,19 +15081,39 @@ function createAuth(transport, options) {
|
|
|
15032
15081
|
*/
|
|
15033
15082
|
function isFatalRefreshError(err) {
|
|
15034
15083
|
if (!(err instanceof RebaseApiError)) return false;
|
|
15084
|
+
if (err.code === "TOKEN_ALREADY_USED") return false;
|
|
15035
15085
|
if (err.code === "INVALID_TOKEN" || err.code === "TOKEN_EXPIRED") return true;
|
|
15036
15086
|
return err.status === 401 || err.status === 403;
|
|
15037
15087
|
}
|
|
15088
|
+
/**
|
|
15089
|
+
* Drop this client's session without telling the server.
|
|
15090
|
+
*
|
|
15091
|
+
* `signOut()` is a user action: it POSTs /logout, which revokes the whole
|
|
15092
|
+
* sign-in. That is the wrong hammer for a refresh that failed. Our token
|
|
15093
|
+
* may be stale precisely because a sibling tab holds a live one, and
|
|
15094
|
+
* logging out on its behalf would turn one tab's bad luck into everybody
|
|
15095
|
+
* being signed out — the exact failure this work exists to remove.
|
|
15096
|
+
*/
|
|
15097
|
+
function abandonSessionLocally() {
|
|
15098
|
+
currentSession = null;
|
|
15099
|
+
clearStoredSession();
|
|
15100
|
+
if (refreshTimeout) {
|
|
15101
|
+
clearTimeout(refreshTimeout);
|
|
15102
|
+
refreshTimeout = null;
|
|
15103
|
+
}
|
|
15104
|
+
transport.setToken(null);
|
|
15105
|
+
emit("SIGNED_OUT", null);
|
|
15106
|
+
}
|
|
15038
15107
|
async function attemptScheduledRefresh(attempt) {
|
|
15039
15108
|
try {
|
|
15040
15109
|
await refreshSession();
|
|
15041
15110
|
} catch (err) {
|
|
15042
15111
|
if (isFatalRefreshError(err)) {
|
|
15043
|
-
|
|
15112
|
+
abandonSessionLocally();
|
|
15044
15113
|
return;
|
|
15045
15114
|
}
|
|
15046
15115
|
if (attempt >= MAX_REFRESH_RETRIES) {
|
|
15047
|
-
|
|
15116
|
+
abandonSessionLocally();
|
|
15048
15117
|
return;
|
|
15049
15118
|
}
|
|
15050
15119
|
const backoff = Math.min(REFRESH_RETRY_BASE_MS * 2 ** attempt, REFRESH_RETRY_MAX_MS);
|
|
@@ -15265,9 +15334,42 @@ function createAuth(transport, options) {
|
|
|
15265
15334
|
transport.setToken(null);
|
|
15266
15335
|
emit("SIGNED_OUT", null);
|
|
15267
15336
|
}
|
|
15337
|
+
/**
|
|
15338
|
+
* Serialise refreshes across TABS, not just within one.
|
|
15339
|
+
*
|
|
15340
|
+
* The in-flight promise below covers callers inside a single JavaScript
|
|
15341
|
+
* context. It does nothing about the far more common case: two tabs of the
|
|
15342
|
+
* same app booting together, each firing its own /refresh with the same
|
|
15343
|
+
* cookie. The server tolerates that now (superseded tokens stay usable for
|
|
15344
|
+
* a grace window), but tolerating a stampede is not the same as avoiding
|
|
15345
|
+
* one, and every extra rotation is another chance to end up holding a
|
|
15346
|
+
* token whose response never arrived.
|
|
15347
|
+
*
|
|
15348
|
+
* Web Locks are best-effort on purpose. supabase-js shipped this and then
|
|
15349
|
+
* spent a year fielding deadlock reports — a lock held by a crashed or
|
|
15350
|
+
* frozen tab must never be able to wedge sign-in — so a lock we cannot
|
|
15351
|
+
* take within the timeout is simply not taken, and the refresh proceeds
|
|
15352
|
+
* unserialised, exactly as it did before.
|
|
15353
|
+
*/
|
|
15354
|
+
const REFRESH_LOCK_NAME = "rebase-auth-refresh";
|
|
15355
|
+
const REFRESH_LOCK_TIMEOUT_MS = 5e3;
|
|
15356
|
+
async function withRefreshLock(fn) {
|
|
15357
|
+
const locks = globalThis.navigator?.locks;
|
|
15358
|
+
if (!locks?.request) return fn();
|
|
15359
|
+
const controller = new AbortController();
|
|
15360
|
+
const giveUp = setTimeout(() => controller.abort(), REFRESH_LOCK_TIMEOUT_MS);
|
|
15361
|
+
try {
|
|
15362
|
+
return await locks.request(REFRESH_LOCK_NAME, { signal: controller.signal }, async () => fn());
|
|
15363
|
+
} catch (e) {
|
|
15364
|
+
if (e?.name !== "AbortError") throw e;
|
|
15365
|
+
return fn();
|
|
15366
|
+
} finally {
|
|
15367
|
+
clearTimeout(giveUp);
|
|
15368
|
+
}
|
|
15369
|
+
}
|
|
15268
15370
|
function refreshSession() {
|
|
15269
15371
|
if (inFlightRefresh) return inFlightRefresh;
|
|
15270
|
-
inFlightRefresh = doRefreshSession().finally(() => {
|
|
15372
|
+
inFlightRefresh = withRefreshLock(() => doRefreshSession()).finally(() => {
|
|
15271
15373
|
inFlightRefresh = null;
|
|
15272
15374
|
});
|
|
15273
15375
|
return inFlightRefresh;
|
|
@@ -21782,7 +21884,7 @@ var rebaseEnvSchema = object({
|
|
|
21782
21884
|
ADMIN_CONNECTION_STRING: string().url().optional(),
|
|
21783
21885
|
JWT_SECRET: string().min(32, "JWT_SECRET must be at least 32 characters long"),
|
|
21784
21886
|
JWT_ACCESS_EXPIRES_IN: string().default("1h"),
|
|
21785
|
-
JWT_REFRESH_EXPIRES_IN: string().default("
|
|
21887
|
+
JWT_REFRESH_EXPIRES_IN: string().default("400d"),
|
|
21786
21888
|
GOOGLE_CLIENT_ID: string().optional(),
|
|
21787
21889
|
GOOGLE_CLIENT_SECRET: string().optional(),
|
|
21788
21890
|
REBASE_SERVICE_KEY: string().optional(),
|