@loomup/astro 0.1.8 → 0.1.10
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/README.md +19 -0
- package/dist/auth.d.ts +6 -0
- package/dist/auth.js +1 -1
- package/dist/client.d.ts +24 -0
- package/dist/client.js +179 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,6 +52,25 @@ Package exports:
|
|
|
52
52
|
- `@loomup/astro/middleware` — authentication middleware.
|
|
53
53
|
- `@loomup/astro/auth` — lower-level cookie authentication helpers.
|
|
54
54
|
|
|
55
|
+
## Coordinated browser sessions
|
|
56
|
+
|
|
57
|
+
`createAuthenticatedProject()` coordinates cookie refresh within a tab and
|
|
58
|
+
across same-origin tabs. Custom browser integrations can use the same primitive:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { createBrowserSessionCoordinator } from "@loomup/astro/client";
|
|
62
|
+
|
|
63
|
+
const session = createBrowserSessionCoordinator({
|
|
64
|
+
lockName: "/api/loomup",
|
|
65
|
+
loadSession: () => fetch("/api/loomup/session").then((response) => response.json()),
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
For apps where every browser request uses that coordinator, configure the auth
|
|
70
|
+
handler with `dataProxyRefresh: "client-coordinated"`. This keeps single-use
|
|
71
|
+
refresh rotation on the session endpoint instead of racing parallel data proxy
|
|
72
|
+
requests. The compatibility default remains `"server"`.
|
|
73
|
+
|
|
55
74
|
See the [Astro SDK guide](https://tryloomup.com/docs) for middleware,
|
|
56
75
|
authenticated islands, object storage, and deployment guidance.
|
|
57
76
|
|
package/dist/auth.d.ts
CHANGED
|
@@ -10,6 +10,12 @@ export type LoomupAuthHandlerOptions = CreateServerClientOptions & {
|
|
|
10
10
|
param?: string;
|
|
11
11
|
/** Exact application callback URL allowlisted in `$auth.redirect_urls`. */
|
|
12
12
|
oauthCallbackUrl?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Who rotates an absent access cookie for data requests. The compatibility
|
|
15
|
+
* default is `server`; browser-coordinated applications should use
|
|
16
|
+
* `client-coordinated` so only the session endpoint rotates refresh tokens.
|
|
17
|
+
*/
|
|
18
|
+
dataProxyRefresh?: "server" | "client-coordinated";
|
|
13
19
|
};
|
|
14
20
|
/**
|
|
15
21
|
* Create one Astro catch-all endpoint for login, logout, session hydration,
|
package/dist/auth.js
CHANGED
|
@@ -169,7 +169,7 @@ async function proxyToLoomup(context, options, baseUrl, action) {
|
|
|
169
169
|
if (method !== "GET" && method !== "HEAD")
|
|
170
170
|
assertSameOrigin(context.request);
|
|
171
171
|
let tokens = readTokens(context.cookies, options.cookies?.names);
|
|
172
|
-
if (!tokens.access) {
|
|
172
|
+
if (!tokens.access && options.dataProxyRefresh !== "client-coordinated") {
|
|
173
173
|
if (!tokens.refresh) {
|
|
174
174
|
throw new LoomupError("authentication required", "unauthorized", 401);
|
|
175
175
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -14,6 +14,30 @@ export type CreateBrowserClientOptions = {
|
|
|
14
14
|
refreshToken?: string;
|
|
15
15
|
WebSocketImpl?: CreateClientOptions["WebSocketImpl"];
|
|
16
16
|
};
|
|
17
|
+
export type BrowserSessionCoordinatorOptions<TSession> = {
|
|
18
|
+
/** Load the current same-origin session. This may rotate an expired session. */
|
|
19
|
+
loadSession: () => Promise<TSession>;
|
|
20
|
+
/** Explicitly rotate a still-valid session that is close to expiry. */
|
|
21
|
+
refreshSession?: () => Promise<TSession>;
|
|
22
|
+
/** Return the access JWT when the session exposes it. */
|
|
23
|
+
accessToken?: (session: TSession) => string | undefined;
|
|
24
|
+
/** Refresh this long before the JWT expiry. Default: 60 seconds. */
|
|
25
|
+
expirySkewMs?: number;
|
|
26
|
+
/** Same-origin lock shared by every tab using this session. */
|
|
27
|
+
lockName?: string;
|
|
28
|
+
};
|
|
29
|
+
export type BrowserSessionCoordinator<TSession> = {
|
|
30
|
+
getSession(): Promise<TSession>;
|
|
31
|
+
ensureFresh(): Promise<TSession>;
|
|
32
|
+
invalidate(): void;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Coordinate cookie-session refreshes within one tab and across same-origin
|
|
36
|
+
* tabs. The operation always reloads the session after acquiring the lock, so
|
|
37
|
+
* a waiter adopts cookies rotated by the winner instead of replaying a
|
|
38
|
+
* single-use refresh token.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createBrowserSessionCoordinator<TSession>(options: BrowserSessionCoordinatorOptions<TSession>): BrowserSessionCoordinator<TSession>;
|
|
17
41
|
/**
|
|
18
42
|
* Create a browser Loomup client for islands.
|
|
19
43
|
*
|
package/dist/client.js
CHANGED
|
@@ -26,6 +26,178 @@ function resolveBrowserUrl(explicit) {
|
|
|
26
26
|
return fromEnv;
|
|
27
27
|
throw new Error("@loomup/astro: createBrowserClient requires `url` or PUBLIC_LOOMUP_URL (set via the loomup() integration)");
|
|
28
28
|
}
|
|
29
|
+
let coordinatorSequence = 0;
|
|
30
|
+
function delay(milliseconds) {
|
|
31
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
32
|
+
}
|
|
33
|
+
function jwtExpiry(token) {
|
|
34
|
+
if (!token)
|
|
35
|
+
return undefined;
|
|
36
|
+
try {
|
|
37
|
+
const encoded = token.split(".")[1];
|
|
38
|
+
if (!encoded)
|
|
39
|
+
return undefined;
|
|
40
|
+
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
41
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
42
|
+
const decode = globalThis.atob;
|
|
43
|
+
if (!decode)
|
|
44
|
+
return undefined;
|
|
45
|
+
const payload = JSON.parse(decode(padded));
|
|
46
|
+
return typeof payload.exp === "number" ? payload.exp * 1_000 : undefined;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function browserLockManager() {
|
|
53
|
+
try {
|
|
54
|
+
return globalThis.navigator?.locks;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function browserStorage() {
|
|
61
|
+
try {
|
|
62
|
+
return globalThis.localStorage;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function leaseOwner() {
|
|
69
|
+
const cryptoApi = globalThis.crypto;
|
|
70
|
+
if (typeof cryptoApi?.randomUUID === "function")
|
|
71
|
+
return cryptoApi.randomUUID();
|
|
72
|
+
coordinatorSequence += 1;
|
|
73
|
+
return `${Date.now()}-${coordinatorSequence}`;
|
|
74
|
+
}
|
|
75
|
+
function readLease(storage, key) {
|
|
76
|
+
try {
|
|
77
|
+
const raw = storage.getItem(key);
|
|
78
|
+
if (!raw)
|
|
79
|
+
return null;
|
|
80
|
+
const value = JSON.parse(raw);
|
|
81
|
+
if (typeof value.owner !== "string" || typeof value.expiresAt !== "number")
|
|
82
|
+
return null;
|
|
83
|
+
return { owner: value.owner, expiresAt: value.expiresAt };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function withLease(name, operation) {
|
|
90
|
+
const storage = browserStorage();
|
|
91
|
+
if (!storage)
|
|
92
|
+
return operation();
|
|
93
|
+
const key = `@loomup/astro:auth-lock:${name}`;
|
|
94
|
+
const owner = leaseOwner();
|
|
95
|
+
const deadline = Date.now() + 30_000;
|
|
96
|
+
const channel = typeof BroadcastChannel === "function"
|
|
97
|
+
? new BroadcastChannel(`@loomup/astro:auth:${name}`)
|
|
98
|
+
: undefined;
|
|
99
|
+
try {
|
|
100
|
+
while (Date.now() < deadline) {
|
|
101
|
+
const current = readLease(storage, key);
|
|
102
|
+
if (!current || current.expiresAt <= Date.now()) {
|
|
103
|
+
try {
|
|
104
|
+
storage.setItem(key, JSON.stringify({ owner, expiresAt: Date.now() + 15_000 }));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return operation();
|
|
108
|
+
}
|
|
109
|
+
// localStorage has no compare-and-swap. A short settle and owner check
|
|
110
|
+
// ensures only the last contender enters the critical section.
|
|
111
|
+
await delay(20);
|
|
112
|
+
if (readLease(storage, key)?.owner === owner) {
|
|
113
|
+
const heartbeat = setInterval(() => {
|
|
114
|
+
if (readLease(storage, key)?.owner === owner) {
|
|
115
|
+
storage.setItem(key, JSON.stringify({ owner, expiresAt: Date.now() + 15_000 }));
|
|
116
|
+
}
|
|
117
|
+
}, 5_000);
|
|
118
|
+
try {
|
|
119
|
+
return await operation();
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
clearInterval(heartbeat);
|
|
123
|
+
if (readLease(storage, key)?.owner === owner)
|
|
124
|
+
storage.removeItem(key);
|
|
125
|
+
channel?.postMessage("released");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
await delay(50);
|
|
130
|
+
}
|
|
131
|
+
throw new LoomupError("timed out waiting for session refresh", "auth_lock_timeout", 503);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
channel?.close();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async function withBrowserSessionLock(name, operation) {
|
|
138
|
+
const locks = browserLockManager();
|
|
139
|
+
if (locks) {
|
|
140
|
+
return locks.request(`@loomup/astro:auth:${name}`, { mode: "exclusive" }, operation);
|
|
141
|
+
}
|
|
142
|
+
return withLease(name, operation);
|
|
143
|
+
}
|
|
144
|
+
function isTerminalSessionError(error) {
|
|
145
|
+
return error instanceof LoomupError && error.status === 401;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Coordinate cookie-session refreshes within one tab and across same-origin
|
|
149
|
+
* tabs. The operation always reloads the session after acquiring the lock, so
|
|
150
|
+
* a waiter adopts cookies rotated by the winner instead of replaying a
|
|
151
|
+
* single-use refresh token.
|
|
152
|
+
*/
|
|
153
|
+
export function createBrowserSessionCoordinator(options) {
|
|
154
|
+
const lockName = options.lockName ?? "default";
|
|
155
|
+
const expirySkewMs = Math.max(0, options.expirySkewMs ?? 60_000);
|
|
156
|
+
let cached;
|
|
157
|
+
let inFlight = null;
|
|
158
|
+
const execute = (checkExpiry) => {
|
|
159
|
+
if (inFlight)
|
|
160
|
+
return inFlight;
|
|
161
|
+
const attempt = async () => withBrowserSessionLock(lockName, async () => {
|
|
162
|
+
const loaded = await options.loadSession();
|
|
163
|
+
if (!checkExpiry || !options.refreshSession || !options.accessToken)
|
|
164
|
+
return loaded;
|
|
165
|
+
const expiresAt = jwtExpiry(options.accessToken(loaded));
|
|
166
|
+
return expiresAt !== undefined && expiresAt - Date.now() <= expirySkewMs
|
|
167
|
+
? options.refreshSession()
|
|
168
|
+
: loaded;
|
|
169
|
+
});
|
|
170
|
+
inFlight = (async () => {
|
|
171
|
+
try {
|
|
172
|
+
cached = await attempt();
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (!isTerminalSessionError(error))
|
|
176
|
+
throw error;
|
|
177
|
+
// A tab that could not participate in the primary lock may have lost a
|
|
178
|
+
// rotation race. Reacquire and observe the cookie jar once before the
|
|
179
|
+
// caller treats the session as terminal.
|
|
180
|
+
await delay(100);
|
|
181
|
+
cached = await attempt();
|
|
182
|
+
}
|
|
183
|
+
return cached;
|
|
184
|
+
})().finally(() => {
|
|
185
|
+
inFlight = null;
|
|
186
|
+
});
|
|
187
|
+
return inFlight;
|
|
188
|
+
};
|
|
189
|
+
return {
|
|
190
|
+
getSession() {
|
|
191
|
+
return cached === undefined ? execute(false) : Promise.resolve(cached);
|
|
192
|
+
},
|
|
193
|
+
ensureFresh() {
|
|
194
|
+
return execute(true);
|
|
195
|
+
},
|
|
196
|
+
invalidate() {
|
|
197
|
+
cached = undefined;
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
29
201
|
/**
|
|
30
202
|
* Create a browser Loomup client for islands.
|
|
31
203
|
*
|
|
@@ -71,14 +243,18 @@ async function authRequest(fetchImpl, endpoint, action, init) {
|
|
|
71
243
|
export async function createAuthenticatedProject(options = {}) {
|
|
72
244
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
73
245
|
const endpoint = options.authEndpoint ?? "/api/loomup";
|
|
74
|
-
const
|
|
246
|
+
const coordinator = createBrowserSessionCoordinator({
|
|
247
|
+
lockName: endpoint,
|
|
248
|
+
loadSession: () => authRequest(fetchImpl, endpoint, "session", { method: "GET" }),
|
|
249
|
+
});
|
|
250
|
+
const session = await coordinator.getSession();
|
|
75
251
|
if (!session.user) {
|
|
76
252
|
throw new LoomupError("authenticated session required", "unauthorized", 401);
|
|
77
253
|
}
|
|
78
254
|
const db = createProject({
|
|
79
255
|
url: options.dataEndpoint ?? `${endpoint.replace(/\/$/, "")}/data`,
|
|
80
256
|
accessTokenProvider: async () => {
|
|
81
|
-
await
|
|
257
|
+
await coordinator.ensureFresh();
|
|
82
258
|
// The core client requires a truthy retry signal. This marker is sent
|
|
83
259
|
// only to the same-origin gateway, which replaces Authorization with
|
|
84
260
|
// the server-held access token.
|
|
@@ -90,6 +266,7 @@ export async function createAuthenticatedProject(options = {}) {
|
|
|
90
266
|
user: session.user,
|
|
91
267
|
async signOut() {
|
|
92
268
|
await authRequest(fetchImpl, endpoint, "logout", { method: "POST" });
|
|
269
|
+
coordinator.invalidate();
|
|
93
270
|
db.setToken(undefined);
|
|
94
271
|
},
|
|
95
272
|
};
|