@loomup/astro 0.1.15 → 0.1.17
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 +7 -0
- package/dist/auth.js +10 -33
- package/dist/authTokens.d.ts +11 -0
- package/dist/authTokens.js +40 -0
- package/dist/cookies.d.ts +0 -3
- package/dist/cookies.js +2 -0
- package/dist/server.d.ts +5 -14
- package/dist/server.js +30 -23
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -52,6 +52,13 @@ Package exports:
|
|
|
52
52
|
- `@loomup/astro/middleware` — authentication middleware.
|
|
53
53
|
- `@loomup/astro/auth` — lower-level cookie authentication helpers.
|
|
54
54
|
|
|
55
|
+
The server client accepts both JSON token responses and Loomup `cookie_mode`
|
|
56
|
+
responses, where the rotated refresh credential is supplied only in an upstream
|
|
57
|
+
`Set-Cookie` header. It writes both credentials to application-owned HttpOnly
|
|
58
|
+
cookies before updating the session. An incomplete token exchange throws
|
|
59
|
+
`LoomupError` with code `invalid_response` and status `502`, leaving the existing
|
|
60
|
+
session unchanged. Call server auth methods before response headers are sent.
|
|
61
|
+
|
|
55
62
|
## Coordinated browser sessions
|
|
56
63
|
|
|
57
64
|
`createAuthenticatedProject()` coordinates cookie refresh within a tab and
|
package/dist/auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Same-origin Astro auth endpoint for Loomup-backed applications. */
|
|
2
2
|
import { LoomupError } from "@loomup/client";
|
|
3
3
|
import { readTokens, writeTokens } from "./cookies.js";
|
|
4
|
+
import { normalizeAuthTokens } from "./authTokens.js";
|
|
4
5
|
import { createServerClient, resolveServerUrl, } from "./server.js";
|
|
5
6
|
const OAUTH_VERIFIER_COOKIE = "loomup-oauth-verifier";
|
|
6
7
|
const OAUTH_RETURN_COOKIE = "loomup-oauth-return";
|
|
@@ -38,22 +39,6 @@ function publicSession(user) {
|
|
|
38
39
|
function joinUrl(base, path) {
|
|
39
40
|
return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
|
40
41
|
}
|
|
41
|
-
function upstreamCookie(headers, name) {
|
|
42
|
-
const extended = headers;
|
|
43
|
-
const values = typeof extended.getSetCookie === "function"
|
|
44
|
-
? extended.getSetCookie()
|
|
45
|
-
: typeof extended.getAll === "function"
|
|
46
|
-
? extended.getAll("Set-Cookie")
|
|
47
|
-
: [headers.get("Set-Cookie") ?? ""];
|
|
48
|
-
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
49
|
-
const pattern = new RegExp(`(?:^|,\\s*)${escaped}=([^;]*)`);
|
|
50
|
-
for (const value of values) {
|
|
51
|
-
const match = pattern.exec(value);
|
|
52
|
-
if (match?.[1])
|
|
53
|
-
return match[1].replace(/^"|"$/g, "");
|
|
54
|
-
}
|
|
55
|
-
return undefined;
|
|
56
|
-
}
|
|
57
42
|
async function upstreamRequest(baseUrl, method, path, body, accessToken, serviceKey) {
|
|
58
43
|
const headers = { Accept: "application/json" };
|
|
59
44
|
if (body !== undefined)
|
|
@@ -79,28 +64,20 @@ async function upstreamRequest(baseUrl, method, path, body, accessToken, service
|
|
|
79
64
|
const code = envelope?.error?.code;
|
|
80
65
|
throw new LoomupError(String(message || upstream.statusText), typeof code === "string" ? code : undefined, upstream.status);
|
|
81
66
|
}
|
|
82
|
-
if (!envelope || !("data" in envelope)) {
|
|
67
|
+
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope) || !("data" in envelope)) {
|
|
83
68
|
throw new LoomupError("invalid response from Loomup", "invalid_response", 502);
|
|
84
69
|
}
|
|
85
70
|
return { data: envelope.data, response: upstream };
|
|
86
71
|
}
|
|
87
|
-
async function authExchange(baseUrl, cookies, options, path, body
|
|
72
|
+
async function authExchange(baseUrl, cookies, options, path, body) {
|
|
88
73
|
const { data, response: upstream } = await upstreamRequest(baseUrl, "POST", path, body, undefined, options.client?.serviceKey);
|
|
89
|
-
const
|
|
90
|
-
? data.access_token
|
|
91
|
-
: upstreamCookie(upstream.headers, "loomup_access");
|
|
92
|
-
const refreshToken = typeof data.refresh_token === "string"
|
|
93
|
-
? data.refresh_token
|
|
94
|
-
: upstreamCookie(upstream.headers, "loomup_refresh") ?? currentRefresh;
|
|
95
|
-
if (!accessToken || !refreshToken) {
|
|
96
|
-
throw new LoomupError("Loomup auth response did not include a complete session", "invalid_response", 502);
|
|
97
|
-
}
|
|
74
|
+
const tokens = normalizeAuthTokens(data, upstream.headers);
|
|
98
75
|
writeTokens(cookies, {
|
|
99
|
-
access_token:
|
|
100
|
-
refresh_token:
|
|
76
|
+
access_token: tokens.access_token,
|
|
77
|
+
refresh_token: tokens.refresh_token,
|
|
101
78
|
expires_in: typeof data.expires_in === "number" ? data.expires_in : undefined,
|
|
102
79
|
}, options.cookies);
|
|
103
|
-
return { accessToken, user: data.user };
|
|
80
|
+
return { accessToken: tokens.access_token, user: data.user };
|
|
104
81
|
}
|
|
105
82
|
async function userForAccess(baseUrl, accessToken) {
|
|
106
83
|
const { data } = await upstreamRequest(baseUrl, "GET", "/auth/me", undefined, accessToken);
|
|
@@ -120,7 +97,7 @@ async function sessionFromCookies(baseUrl, cookies, options) {
|
|
|
120
97
|
if (!tokens.refresh) {
|
|
121
98
|
throw new LoomupError("authentication required", "unauthorized", 401);
|
|
122
99
|
}
|
|
123
|
-
const session = await authExchange(baseUrl, cookies, options, "/auth/refresh", { refresh_token: tokens.refresh }
|
|
100
|
+
const session = await authExchange(baseUrl, cookies, options, "/auth/refresh", { refresh_token: tokens.refresh });
|
|
124
101
|
return {
|
|
125
102
|
accessToken: session.accessToken,
|
|
126
103
|
user: session.user ?? (await userForAccess(baseUrl, session.accessToken)),
|
|
@@ -173,7 +150,7 @@ async function proxyToLoomup(context, options, baseUrl, action) {
|
|
|
173
150
|
if (!tokens.refresh) {
|
|
174
151
|
throw new LoomupError("authentication required", "unauthorized", 401);
|
|
175
152
|
}
|
|
176
|
-
await authExchange(baseUrl, context.cookies, options, "/auth/refresh", { refresh_token: tokens.refresh }
|
|
153
|
+
await authExchange(baseUrl, context.cookies, options, "/auth/refresh", { refresh_token: tokens.refresh });
|
|
177
154
|
tokens = readTokens(context.cookies, options.cookies?.names);
|
|
178
155
|
}
|
|
179
156
|
if (!tokens.access) {
|
|
@@ -299,7 +276,7 @@ export function createLoomupAuthHandler(options = {}) {
|
|
|
299
276
|
const refreshToken = readTokens(context.cookies, options.cookies?.names).refresh;
|
|
300
277
|
if (!refreshToken)
|
|
301
278
|
throw new LoomupError("no refresh token", "no_refresh", 401);
|
|
302
|
-
const session = await authExchange(url, context.cookies, options, "/auth/refresh", { refresh_token: refreshToken }
|
|
279
|
+
const session = await authExchange(url, context.cookies, options, "/auth/refresh", { refresh_token: refreshToken });
|
|
303
280
|
const user = session.user ?? (await userForAccess(url, session.accessToken));
|
|
304
281
|
return response(publicSession(user));
|
|
305
282
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function assertSessionTokens(tokens: {
|
|
2
|
+
access_token?: unknown;
|
|
3
|
+
refresh_token?: unknown;
|
|
4
|
+
}): asserts tokens is {
|
|
5
|
+
access_token: string;
|
|
6
|
+
refresh_token: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function normalizeAuthTokens(data: unknown, headers: Headers): {
|
|
9
|
+
access_token: string;
|
|
10
|
+
refresh_token: string;
|
|
11
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Server-only normalization of Loomup JSON and HttpOnly cookie credentials. */
|
|
2
|
+
import { LoomupError } from "@loomup/client";
|
|
3
|
+
function credential(value) {
|
|
4
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
5
|
+
}
|
|
6
|
+
function upstreamCookie(headers, name) {
|
|
7
|
+
const extended = headers;
|
|
8
|
+
const values = typeof extended.getSetCookie === "function"
|
|
9
|
+
? extended.getSetCookie()
|
|
10
|
+
: typeof extended.getAll === "function"
|
|
11
|
+
? extended.getAll("Set-Cookie")
|
|
12
|
+
: [headers.get("Set-Cookie") ?? ""];
|
|
13
|
+
// Match cookie boundaries, without splitting commas inside Expires dates.
|
|
14
|
+
const pattern = new RegExp(`(?:^|,\\s*)${name}=([^;,]*)`);
|
|
15
|
+
for (const value of values) {
|
|
16
|
+
const match = pattern.exec(value);
|
|
17
|
+
const token = credential(match?.[1]?.replace(/^"|"$/g, ""));
|
|
18
|
+
if (token)
|
|
19
|
+
return token;
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
export function assertSessionTokens(tokens) {
|
|
24
|
+
if (!credential(tokens.access_token) || !credential(tokens.refresh_token)) {
|
|
25
|
+
throw new LoomupError("Loomup auth response did not include a complete session", "invalid_response", 502);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function normalizeAuthTokens(data, headers) {
|
|
29
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
30
|
+
throw new LoomupError("invalid response from Loomup", "invalid_response", 502);
|
|
31
|
+
}
|
|
32
|
+
const payload = data;
|
|
33
|
+
const tokens = {
|
|
34
|
+
...payload,
|
|
35
|
+
access_token: credential(payload.access_token) ?? upstreamCookie(headers, "loomup_access"),
|
|
36
|
+
refresh_token: credential(payload.refresh_token) ?? upstreamCookie(headers, "loomup_refresh"),
|
|
37
|
+
};
|
|
38
|
+
assertSessionTokens(tokens);
|
|
39
|
+
return tokens;
|
|
40
|
+
}
|
package/dist/cookies.d.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cookie helpers for Loomup auth tokens in Astro SSR.
|
|
3
|
-
*/
|
|
4
1
|
export declare const DEFAULT_ACCESS_COOKIE = "loomup-access";
|
|
5
2
|
export declare const DEFAULT_REFRESH_COOKIE = "loomup-refresh";
|
|
6
3
|
/** Minimal cookie API compatible with AstroCookies (and easy to mock in tests). */
|
package/dist/cookies.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cookie helpers for Loomup auth tokens in Astro SSR.
|
|
3
3
|
*/
|
|
4
|
+
import { assertSessionTokens } from "./authTokens.js";
|
|
4
5
|
export const DEFAULT_ACCESS_COOKIE = "loomup-access";
|
|
5
6
|
export const DEFAULT_REFRESH_COOKIE = "loomup-refresh";
|
|
6
7
|
export function resolveCookieNames(names) {
|
|
@@ -23,6 +24,7 @@ export function readTokens(cookies, names) {
|
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
export function writeTokens(cookies, tokens, options) {
|
|
27
|
+
assertSessionTokens(tokens);
|
|
26
28
|
const n = resolveCookieNames(options?.names);
|
|
27
29
|
const path = options?.path ?? "/";
|
|
28
30
|
const secure = isSecureDefault(options?.secure);
|
package/dist/server.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Server-side Loomup client for Astro (SSR frontmatter, endpoints, middleware).
|
|
3
3
|
* Persists access/refresh tokens in httpOnly cookies.
|
|
4
4
|
*/
|
|
5
|
-
import { LoomupClient, type AuthTokens, type
|
|
5
|
+
import { LoomupClient, type AuthTokens, type CreateClientOptions, type DefaultTableMap, type LoomupProject, type OAuthAuthorizeInput, type OAuthExchangeInput } from "@loomup/client";
|
|
6
6
|
import { type CookieOptions, type CookieStore } from "./cookies.js";
|
|
7
7
|
export type { CookieNames, CookieOptions, CookieStore, CookieWriteOptions, } from "./cookies.js";
|
|
8
8
|
export { DEFAULT_ACCESS_COOKIE, DEFAULT_REFRESH_COOKIE, clearTokens, readTokens, resolveCookieNames, writeTokens, } from "./cookies.js";
|
|
@@ -43,18 +43,9 @@ export declare class ServerLoomupClient<TMap = DefaultTableMap, TInsertMap = {
|
|
|
43
43
|
constructor(cookieStore: CookieStore, options: CreateClientOptions & {
|
|
44
44
|
cookieOptions?: CookieOptions;
|
|
45
45
|
});
|
|
46
|
-
|
|
46
|
+
protected applyTokens(data: AuthTokens): void;
|
|
47
|
+
protected normalizeResponse(method: string, path: string, payload: unknown, response: Response): unknown;
|
|
47
48
|
private clearCookieTokens;
|
|
48
|
-
signUp(creds: {
|
|
49
|
-
email: string;
|
|
50
|
-
password: string;
|
|
51
|
-
}): Promise<AuthSignUpResult>;
|
|
52
|
-
signIn(creds: {
|
|
53
|
-
email: string;
|
|
54
|
-
password: string;
|
|
55
|
-
}): Promise<AuthTokens>;
|
|
56
|
-
refresh(): Promise<AuthTokens>;
|
|
57
|
-
exchangeOAuthCode(input: OAuthExchangeInput): Promise<AuthTokens>;
|
|
58
49
|
signOut(): Promise<void>;
|
|
59
50
|
setToken(token: string | undefined): void;
|
|
60
51
|
setRefreshToken(token: string | undefined): void;
|
|
@@ -66,11 +57,11 @@ export declare class ServerLoomupClient<TMap = DefaultTableMap, TInsertMap = {
|
|
|
66
57
|
signUp: (creds: {
|
|
67
58
|
email: string;
|
|
68
59
|
password: string;
|
|
69
|
-
}) => Promise<AuthSignUpResult>;
|
|
60
|
+
}) => Promise<import("@loomup/client").AuthSignUpResult>;
|
|
70
61
|
register: (creds: {
|
|
71
62
|
email: string;
|
|
72
63
|
password: string;
|
|
73
|
-
}) => Promise<AuthSignUpResult>;
|
|
64
|
+
}) => Promise<import("@loomup/client").AuthSignUpResult>;
|
|
74
65
|
signIn: (creds: {
|
|
75
66
|
email: string;
|
|
76
67
|
password: string;
|
package/dist/server.js
CHANGED
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { LoomupClient, projectFromClient, } from "@loomup/client";
|
|
6
6
|
import { asCookieStore, clearTokens, readTokens, writeTokens, } from "./cookies.js";
|
|
7
|
+
import { normalizeAuthTokens } from "./authTokens.js";
|
|
8
|
+
const TOKEN_EXCHANGE_PATHS = new Set([
|
|
9
|
+
"/auth/register",
|
|
10
|
+
"/auth/login",
|
|
11
|
+
"/auth/refresh",
|
|
12
|
+
"/auth/oauth/exchange",
|
|
13
|
+
"/auth/email-verification/confirm",
|
|
14
|
+
"/auth/invitations/accept",
|
|
15
|
+
]);
|
|
7
16
|
export { DEFAULT_ACCESS_COOKIE, DEFAULT_REFRESH_COOKIE, clearTokens, readTokens, resolveCookieNames, writeTokens, } from "./cookies.js";
|
|
8
17
|
export { LoomupError, createClient, StorageBucket, encodeObjectPath, normalizeStorageUpload, } from "@loomup/client";
|
|
9
18
|
export { fileAndPathFromFormData, uploadFromFormData, storageDownloadResponse, } from "./objectStorage.js";
|
|
@@ -33,35 +42,33 @@ export class ServerLoomupClient extends LoomupClient {
|
|
|
33
42
|
this.cookieOptions = options.cookieOptions;
|
|
34
43
|
this.mirroredRefresh = options.refreshToken;
|
|
35
44
|
}
|
|
36
|
-
|
|
45
|
+
applyTokens(data) {
|
|
46
|
+
// Validate and persist before changing core state or notifying observers.
|
|
47
|
+
writeTokens(this.cookieStore, {
|
|
48
|
+
...data,
|
|
49
|
+
// Core setSession uses zero when the caller has no expiry metadata.
|
|
50
|
+
expires_in: data.expires_in === 0 ? undefined : data.expires_in,
|
|
51
|
+
}, this.cookieOptions);
|
|
37
52
|
this.mirroredRefresh = data.refresh_token;
|
|
38
|
-
|
|
53
|
+
super.applyTokens(data);
|
|
54
|
+
}
|
|
55
|
+
normalizeResponse(method, path, payload, response) {
|
|
56
|
+
if (method.toUpperCase() !== "POST" || !TOKEN_EXCHANGE_PATHS.has(path))
|
|
57
|
+
return payload;
|
|
58
|
+
const envelope = payload && typeof payload === "object" && !Array.isArray(payload)
|
|
59
|
+
? payload
|
|
60
|
+
: {};
|
|
61
|
+
const data = envelope.data;
|
|
62
|
+
if (path === "/auth/register" && data && typeof data === "object"
|
|
63
|
+
&& "verification_required" in data && data.verification_required === true
|
|
64
|
+
&& !("access_token" in data))
|
|
65
|
+
return payload;
|
|
66
|
+
return { ...envelope, data: normalizeAuthTokens(data, response.headers) };
|
|
39
67
|
}
|
|
40
68
|
clearCookieTokens() {
|
|
41
69
|
this.mirroredRefresh = undefined;
|
|
42
70
|
clearTokens(this.cookieStore, this.cookieOptions);
|
|
43
71
|
}
|
|
44
|
-
async signUp(creds) {
|
|
45
|
-
const data = await super.signUp(creds);
|
|
46
|
-
if ("access_token" in data)
|
|
47
|
-
this.persistFromTokens(data);
|
|
48
|
-
return data;
|
|
49
|
-
}
|
|
50
|
-
async signIn(creds) {
|
|
51
|
-
const data = await super.signIn(creds);
|
|
52
|
-
this.persistFromTokens(data);
|
|
53
|
-
return data;
|
|
54
|
-
}
|
|
55
|
-
async refresh() {
|
|
56
|
-
const data = await super.refresh();
|
|
57
|
-
this.persistFromTokens(data);
|
|
58
|
-
return data;
|
|
59
|
-
}
|
|
60
|
-
async exchangeOAuthCode(input) {
|
|
61
|
-
const data = await super.exchangeOAuthCode(input);
|
|
62
|
-
this.persistFromTokens(data);
|
|
63
|
-
return data;
|
|
64
|
-
}
|
|
65
72
|
async signOut() {
|
|
66
73
|
await super.signOut();
|
|
67
74
|
this.clearCookieTokens();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loomup/astro",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"description": "Astro integration and SSR helpers for Loomup Realtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"prepack": "npm run build"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@loomup/client": "^0.1.
|
|
40
|
+
"@loomup/client": "^0.1.17",
|
|
41
41
|
"astro": ">=4.0.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|