@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 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, currentRefresh) {
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 accessToken = typeof data.access_token === "string"
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: accessToken,
100
- refresh_token: refreshToken,
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 }, 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 }, 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 }, 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 AuthSignUpResult, type CreateClientOptions, type DefaultTableMap, type LoomupProject, type OAuthAuthorizeInput, type OAuthExchangeInput } from "@loomup/client";
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
- private persistFromTokens;
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
- persistFromTokens(data) {
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
- writeTokens(this.cookieStore, data, this.cookieOptions);
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.15",
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.0",
40
+ "@loomup/client": "^0.1.17",
41
41
  "astro": ">=4.0.0"
42
42
  },
43
43
  "peerDependenciesMeta": {