@commonpub/layer 0.21.14 → 0.21.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.21.14",
3
+ "version": "0.21.15",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -50,15 +50,15 @@
50
50
  "vue": "^3.4.0",
51
51
  "vue-router": "^4.3.0",
52
52
  "zod": "^4.3.6",
53
- "@commonpub/explainer": "0.7.15",
54
53
  "@commonpub/auth": "0.6.0",
55
54
  "@commonpub/config": "0.13.0",
56
- "@commonpub/editor": "0.7.10",
57
- "@commonpub/server": "2.54.3",
58
- "@commonpub/schema": "0.16.0",
59
- "@commonpub/protocol": "0.11.0",
60
55
  "@commonpub/docs": "0.6.3",
56
+ "@commonpub/server": "2.55.0",
57
+ "@commonpub/schema": "0.16.0",
58
+ "@commonpub/editor": "0.7.10",
61
59
  "@commonpub/learning": "0.5.2",
60
+ "@commonpub/protocol": "0.12.0",
61
+ "@commonpub/explainer": "0.7.15",
62
62
  "@commonpub/ui": "0.8.5"
63
63
  },
64
64
  "devDependencies": {
@@ -1,6 +1,7 @@
1
1
  import { deleteUser, federateDelete } from '@commonpub/server';
2
2
  import { contentItems } from '@commonpub/schema';
3
3
  import { eq, and } from 'drizzle-orm';
4
+ import { clearBetterAuthSessionCookies } from '../../utils/betterAuthCookie';
4
5
 
5
6
  export default defineEventHandler(async (event): Promise<{ success: true }> => {
6
7
  const user = requireAuth(event);
@@ -48,8 +49,13 @@ export default defineEventHandler(async (event): Promise<{ success: true }> => {
48
49
  // Delete the user (cascades to all related data)
49
50
  await deleteUser(db, user.id, user.id);
50
51
 
51
- // Clear the session cookie
52
- deleteCookie(event, 'better-auth.session_token', { path: '/' });
52
+ // Clear both Better Auth cookies — session_token AND session_data
53
+ // (SSR session cache). Federation-hardening Item 8: the previous
54
+ // single deleteCookie('better-auth.session_token') didn't match the
55
+ // `__Secure-`-prefixed prod cookie name and left the session_data
56
+ // cache cookie hanging for up to 5 minutes of stale enriched-user
57
+ // data on the response of a freshly-deleted account.
58
+ clearBetterAuthSessionCookies(event);
53
59
 
54
60
  return { success: true };
55
61
  });
@@ -1,25 +1,12 @@
1
- import { consumeOAuthState, exchangeCodeForToken, linkFederatedAccount, findUserByFederatedAccount, createFederatedSession, storePendingLink } from '@commonpub/server';
2
- import type { H3Event } from 'h3';
1
+ import { consumeOAuthState, exchangeCodeForToken, linkFederatedAccount, findUserByFederatedAccount, createFederatedSession, storePendingLink, getClientIp } from '@commonpub/server';
3
2
  import { z } from 'zod';
3
+ import { setBetterAuthSessionCookie } from '../../../utils/betterAuthCookie';
4
4
 
5
5
  const callbackSchema = z.object({
6
6
  code: z.string(),
7
7
  state: z.string(),
8
8
  });
9
9
 
10
- /**
11
- * Set the Better Auth session cookie after federated login.
12
- */
13
- function setSessionCookie(event: H3Event, token: string, expiresAt: Date): void {
14
- setCookie(event, 'better-auth.session_token', token, {
15
- httpOnly: true,
16
- secure: process.env.NODE_ENV === 'production',
17
- sameSite: 'lax',
18
- path: '/',
19
- expires: expiresAt,
20
- });
21
- }
22
-
23
10
  /**
24
11
  * OAuth2 callback handler for federated login.
25
12
  * Exchanges authorization code for token, links federated account,
@@ -48,18 +35,25 @@ export default defineEventHandler(async (event) => {
48
35
  });
49
36
  }
50
37
 
51
- const ipAddress = getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
52
- ?? getRequestHeader(event, 'x-real-ip')
53
- ?? undefined;
38
+ // Trusted client IP for the session audit row (federation-hardening
39
+ // Item 9 — use the rightmost XFF token / x-real-ip / socket address,
40
+ // matching the rate-limit middleware so the audit log + the rate-limit
41
+ // key reference the same address).
42
+ const resolvedIp = getClientIp(event);
43
+ const ipAddress = resolvedIp === 'unknown' ? undefined : resolvedIp;
54
44
  const userAgent = getRequestHeader(event, 'user-agent') ?? undefined;
55
45
 
56
46
  // Check if a local user is already linked to this federated account
57
47
  const existingLink = await findUserByFederatedAccount(db, tokenResult.user.actorUri);
58
48
 
59
49
  if (existingLink) {
60
- // User already linked — create session and redirect to dashboard
50
+ // User already linked — create session and redirect to dashboard.
51
+ // Cookie is signed + correctly named (federation-hardening Item 8);
52
+ // before the fix the bare-token/bare-name cookie was rejected by
53
+ // Better Auth's getSession on the next request, leaving the redirect
54
+ // anonymous.
61
55
  const session = await createFederatedSession(db, existingLink.userId, ipAddress, userAgent);
62
- setSessionCookie(event, session.sessionToken, session.expiresAt);
56
+ setBetterAuthSessionCookie(event, session.sessionToken, session.expiresAt);
63
57
  return sendRedirect(event, '/dashboard', 302);
64
58
  }
65
59
 
@@ -2,6 +2,7 @@ import { linkFederatedAccount, consumePendingLink } from '@commonpub/server';
2
2
  import { eq, and, isNull } from 'drizzle-orm';
3
3
  import { users } from '@commonpub/schema';
4
4
  import { z } from 'zod';
5
+ import { setBetterAuthSessionCookie } from '../../../utils/betterAuthCookie';
5
6
 
6
7
  const linkSchema = z.object({
7
8
  /** Local credentials */
@@ -74,14 +75,16 @@ export default defineEventHandler(async (event) => {
74
75
  throw createError({ statusCode: 409, statusMessage: msg });
75
76
  }
76
77
 
77
- // Step 5: Use the session Better Auth created — set cookie for the client
78
- setCookie(event, 'better-auth.session_token', signInResponse.session.token, {
79
- httpOnly: true,
80
- secure: process.env.NODE_ENV === 'production',
81
- sameSite: 'lax',
82
- path: '/',
83
- expires: new Date(signInResponse.session.expiresAt),
84
- });
78
+ // Step 5: Set a signed + correctly-named Better Auth session cookie so
79
+ // `auth.api.getSession` reads the session on the next request. Before
80
+ // federation-hardening Item 8, this used a bare token + bare cookie
81
+ // name, which Better Auth's getSignedCookie rejected silently — the
82
+ // sign-in succeeded server-side but the next request was anonymous.
83
+ setBetterAuthSessionCookie(
84
+ event,
85
+ signInResponse.session.token,
86
+ new Date(signInResponse.session.expiresAt),
87
+ );
85
88
 
86
89
  return {
87
90
  success: true,
@@ -22,7 +22,6 @@
22
22
  * Gated by `features.identity.signInWithRemote`.
23
23
  */
24
24
  import { z } from 'zod';
25
- import type { H3Event } from 'h3';
26
25
  import {
27
26
  consumeMastodonLoginState,
28
27
  createFederatedSession,
@@ -31,7 +30,9 @@ import {
31
30
  getOrRegisterRemoteClient,
32
31
  linkFederatedAccount,
33
32
  storePendingLink,
33
+ getClientIp,
34
34
  } from '@commonpub/server';
35
+ import { setBetterAuthSessionCookie } from '../../../utils/betterAuthCookie';
35
36
 
36
37
  const callbackSchema = z.object({
37
38
  code: z.string().min(1),
@@ -42,16 +43,6 @@ const callbackSchema = z.object({
42
43
  error_description: z.string().optional(),
43
44
  });
44
45
 
45
- function setSessionCookie(event: H3Event, token: string, expiresAt: Date): void {
46
- setCookie(event, 'better-auth.session_token', token, {
47
- httpOnly: true,
48
- secure: process.env.NODE_ENV === 'production',
49
- sameSite: 'lax',
50
- path: '/',
51
- expires: expiresAt,
52
- });
53
- }
54
-
55
46
  export default defineEventHandler(async (event) => {
56
47
  const config = useConfig();
57
48
  if (!config.features.identity.signInWithRemote) {
@@ -95,17 +86,21 @@ export default defineEventHandler(async (event) => {
95
86
  });
96
87
  }
97
88
 
98
- const ipAddress =
99
- getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
100
- ?? getRequestHeader(event, 'x-real-ip')
101
- ?? undefined;
89
+ // Trusted client IP for the session audit row (federation-hardening
90
+ // Item 9 — see federated/callback.get.ts).
91
+ const resolvedIp = getClientIp(event);
92
+ const ipAddress = resolvedIp === 'unknown' ? undefined : resolvedIp;
102
93
  const userAgent = getRequestHeader(event, 'user-agent') ?? undefined;
103
94
 
104
95
  // Outcome 1: identity already linked → log them in, redirect.
96
+ // Cookie minted via the Better Auth signed-cookie helper so
97
+ // `auth.api.getSession` authenticates the next request
98
+ // (federation-hardening Item 8 — bare-name/bare-value cookie was
99
+ // rejected before).
105
100
  const existingLink = await findUserByFederatedAccount(db, verified.profile.actorUri);
106
101
  if (existingLink) {
107
102
  const session = await createFederatedSession(db, existingLink.userId, ipAddress, userAgent);
108
- setSessionCookie(event, session.sessionToken, session.expiresAt);
103
+ setBetterAuthSessionCookie(event, session.sessionToken, session.expiresAt);
109
104
  return sendRedirect(event, loginState.returnTo ?? '/dashboard', 302);
110
105
  }
111
106
 
@@ -1,4 +1,4 @@
1
- import { incrementViewCount } from '@commonpub/server';
1
+ import { incrementViewCount, getClientIp } from '@commonpub/server';
2
2
 
3
3
  // Simple in-memory dedup — tracks IP+contentId pairs for 5 minutes
4
4
  const recentViews = new Map<string, number>();
@@ -18,10 +18,11 @@ export default defineEventHandler(async (event): Promise<{ success: boolean }> =
18
18
  const db = useDB();
19
19
  const { id } = parseParams(event, { id: 'uuid' });
20
20
 
21
- // De-duplicate views per IP + content within cooldown window
22
- const ip = getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
23
- || getRequestHeader(event, 'x-real-ip')
24
- || 'unknown';
21
+ // De-duplicate views per IP + content within cooldown window. Use the
22
+ // trusted (rightmost) XFF token (federation-hardening Item 9) — the
23
+ // previous leftmost-token read let any anonymous caller rotate
24
+ // `X-Forwarded-For: random` to inflate view counts past the 5-min cap.
25
+ const ip = getClientIp(event);
25
26
  const dedupKey = `${ip}:${id}`;
26
27
  const lastView = recentViews.get(dedupKey);
27
28
 
@@ -1,4 +1,4 @@
1
- import { incrementFederatedViewCount } from '@commonpub/server';
1
+ import { incrementFederatedViewCount, getClientIp } from '@commonpub/server';
2
2
 
3
3
  const recentViews = new Map<string, number>();
4
4
  const VIEW_COOLDOWN_MS = 5 * 60 * 1000;
@@ -15,9 +15,9 @@ export default defineEventHandler(async (event): Promise<{ success: boolean }> =
15
15
  const db = useDB();
16
16
  const { id } = parseParams(event, { id: 'uuid' });
17
17
 
18
- const ip = getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
19
- || getRequestHeader(event, 'x-real-ip')
20
- || 'unknown';
18
+ // Trusted client IP for view dedup (federation-hardening Item 9 — see
19
+ // sibling content/view.post.ts).
20
+ const ip = getClientIp(event);
21
21
  const dedupKey = `fed:${ip}:${id}`;
22
22
  const lastView = recentViews.get(dedupKey);
23
23
 
@@ -1,5 +1,5 @@
1
1
  // Security middleware — rate limiting + security headers + CSP
2
- import { checkRateLimit, createRateLimitStore, createRedisFailOpenLogger, shouldSkipRateLimit, getSecurityHeaders, buildCspHeader, buildCspDirectives } from '@commonpub/server';
2
+ import { checkRateLimit, createRateLimitStore, createRedisFailOpenLogger, shouldSkipRateLimit, getSecurityHeaders, buildCspHeader, buildCspDirectives, getClientIp } from '@commonpub/server';
3
3
 
4
4
  // Structured JSON sink for fail-open events. Emits one JSON line per event
5
5
  // to stdout so Docker logs / Loki / Datadog / CloudWatch can parse without
@@ -54,9 +54,15 @@ export default defineEventHandler(async (event) => {
54
54
 
55
55
  // Skip rate limiting in development — SSR + HMR + prefetch burns through limits instantly
56
56
  if (!isDev) {
57
- const ip = getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
58
- || getRequestHeader(event, 'x-real-ip')
59
- || 'unknown';
57
+ // Trusted client IP for the rate-limit bucket (federation-hardening
58
+ // Item 9). All 3 prod instances run Caddy with `header_up
59
+ // X-Forwarded-For {remote_host}` (overwrite) — XFF chain length 1,
60
+ // leftmost === rightmost, so the previous leftmost-token code was NOT
61
+ // live-exploitable in our setup. The rightmost-token rule here is
62
+ // forward-compatible hardening for self-hosters on nginx-append /
63
+ // multi-proxy topologies; they set CPUB_TRUSTED_PROXY_DEPTH to match
64
+ // their chain (default 1 covers single-proxy append too).
65
+ const ip = getClientIp(event);
60
66
 
61
67
  const userId = event.context.auth?.user?.id as string | undefined;
62
68
  const { result, headers: rlHeaders } = await checkRateLimit(store, ip, pathname, userId);
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Helpers for setting/clearing Better Auth (1.6.4 / better-call 1.3.5)
3
+ * session cookies from custom Nitro routes that mint a session WITHOUT
4
+ * going through the auth router (federated SSO callbacks).
5
+ *
6
+ * Federation-hardening Item 8. Before this helper, the callbacks at
7
+ * `auth/federated/callback.get.ts`, `auth/mastodon/callback.get.ts`,
8
+ * and `auth/federated/link.post.ts` set the cookie themselves with a
9
+ * BARE token and the BARE `better-auth.session_token` name. Better
10
+ * Auth (1.6.4) `getSignedCookie` requires `${token}.${HMAC}` format
11
+ * and the `__Secure-` prefix in production — so the bare-token cookie
12
+ * silently authenticated as anonymous on the next request (redirect to
13
+ * /dashboard but no session). Fail-closed (no bypass) but a complete
14
+ * functional break of the flagged auth flows. Identity flags
15
+ * `linkRemoteAccounts`/`signInWithRemote` are OFF in prod, so dormant.
16
+ *
17
+ * Cookie shape pinned against the vendored `better-auth@1.6.4` +
18
+ * `better-call@1.3.5`:
19
+ * - Name: `${prefix}better-auth.session_token` where
20
+ * `prefix === '__Secure-'` when NODE_ENV=production (matches
21
+ * better-auth's `isProduction` check in `cookies/index.mjs:20`).
22
+ * - Value: `encodeURIComponent(`${token}.${base64(HMAC-SHA256(secret, token))}`)`
23
+ * (matches `better-call/dist/crypto.mjs:22-32` `makeSignature`
24
+ * + `signCookieValue`).
25
+ * - Attributes: `httpOnly: true, secure: isProd, sameSite: 'lax', path: '/'`.
26
+ * Default attributes match `cookies/index.mjs:30-39` exactly.
27
+ *
28
+ * Node `createHmac('sha256', secret).update(token).digest('base64')` is
29
+ * byte-identical to better-auth's `btoa(String.fromCharCode(...uint8Array))`:
30
+ * both are RFC 4648 standard base64 with `=` padding (the signature is
31
+ * verified by `getSignedCookie` requiring `length === 44 && endsWith('=')`).
32
+ */
33
+ import { createHmac } from 'node:crypto';
34
+ import type { H3Event } from 'h3';
35
+
36
+ /** Better Auth session-token cookie name; `__Secure-` prefix when secure. */
37
+ export function getBetterAuthSessionCookieName(useSecurePrefix: boolean): string {
38
+ return useSecurePrefix ? '__Secure-better-auth.session_token' : 'better-auth.session_token';
39
+ }
40
+
41
+ /** Better Auth session_data cookie name (SSR cookie cache); `__Secure-` prefix when secure. */
42
+ export function getBetterAuthSessionDataCookieName(useSecurePrefix: boolean): string {
43
+ return useSecurePrefix ? '__Secure-better-auth.session_data' : 'better-auth.session_data';
44
+ }
45
+
46
+ /**
47
+ * Decide whether to apply the `__Secure-` cookie name prefix. Matches Better
48
+ * Auth's logic from `cookies/index.mjs:20` exactly:
49
+ * useSecure = options.useSecureCookies ?? baseURL.startsWith('https://') ?? isProduction
50
+ *
51
+ * Production always uses secure (we never want bare cookies in prod). In dev,
52
+ * checking `siteUrl.startsWith('https://')` covers the local-HTTPS case so
53
+ * `getSession` still finds our cookie (Better Auth applies the prefix when
54
+ * its baseURL is HTTPS, even outside production).
55
+ */
56
+ export function shouldUseSecurePrefix(): boolean {
57
+ if (process.env.NODE_ENV === 'production') return true;
58
+ try {
59
+ const cfg = useRuntimeConfig();
60
+ const pub = cfg.public as Record<string, unknown> | undefined;
61
+ const siteUrl = pub?.siteUrl;
62
+ if (typeof siteUrl === 'string' && siteUrl.startsWith('https://')) return true;
63
+ } catch {
64
+ // useRuntimeConfig unavailable outside a Nitro request context (e.g.
65
+ // tests that import this module without setting up Nuxt). Fall through.
66
+ }
67
+ return false;
68
+ }
69
+
70
+ /**
71
+ * Build the signed cookie value `${token}.${base64(HMAC-SHA256(secret, token))}`.
72
+ * Returns the RAW string — NOT URL-encoded. h3's `setCookie` calls
73
+ * `cookie-es.serialize` which `encodeURIComponent`s the value exactly
74
+ * once on the way out (see `cookie-es@3.x/dist/index.mjs` —
75
+ * `const enc = options?.encode || encodeURIComponent`). Pre-encoding
76
+ * here would double-encode (`+` → `%2B` → `%252B` on the wire), and
77
+ * Better Auth's `getSignedCookie` only decodes ONCE before checking
78
+ * `signature.length === 44 && endsWith('=')` — so the signature
79
+ * fails to parse and every federated session lands as anonymous.
80
+ * Same class of bug as session 149's safeFetch P0 (algorithm correct
81
+ * in isolation, broken once the surrounding layer runs).
82
+ */
83
+ export function signBetterAuthCookieValue(token: string, secret: string): string {
84
+ if (!secret) {
85
+ throw new Error('signBetterAuthCookieValue: secret is required');
86
+ }
87
+ const signature = createHmac('sha256', secret).update(token).digest('base64');
88
+ return `${token}.${signature}`;
89
+ }
90
+
91
+ /**
92
+ * Resolve the auth-signing secret. MUST match `middleware/auth.ts`'s
93
+ * secret-resolution logic exactly (lines 27-33) — if the two diverge,
94
+ * our cookies are signed with a different key than Better Auth's
95
+ * `getSession` verifies against, and every federated session lands as
96
+ * anonymous. KEEP IN SYNC WITH `middleware/auth.ts`.
97
+ *
98
+ * Prod-without-AUTH_SECRET throws (matches middleware's startup throw).
99
+ * Dev-without-AUTH_SECRET falls back to the shared `dev-secret-change-me`
100
+ * sentinel so federated callbacks work in `pnpm dev` without env config.
101
+ */
102
+ function getAuthSecret(): string {
103
+ const cfg = useRuntimeConfig();
104
+ const s = cfg.authSecret as string | undefined;
105
+ if (!s && process.env.NODE_ENV === 'production') {
106
+ throw new Error('AUTH_SECRET must be set in production');
107
+ }
108
+ return s || 'dev-secret-change-me';
109
+ }
110
+
111
+ /**
112
+ * Set a Better Auth session-token cookie on the H3 event. Produces a cookie
113
+ * indistinguishable from one Better Auth itself would have set during the
114
+ * sign-in/email flow — same name, same signed value, same attributes — so
115
+ * `getSession` (which reads via `getSignedCookie`) authenticates the
116
+ * subsequent request.
117
+ */
118
+ export function setBetterAuthSessionCookie(event: H3Event, token: string, expiresAt: Date): void {
119
+ const useSecure = shouldUseSecurePrefix();
120
+ const secret = getAuthSecret();
121
+ const value = signBetterAuthCookieValue(token, secret);
122
+ setCookie(event, getBetterAuthSessionCookieName(useSecure), value, {
123
+ httpOnly: true,
124
+ secure: useSecure,
125
+ sameSite: 'lax',
126
+ path: '/',
127
+ expires: expiresAt,
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Clear both Better Auth cookies (session token + SSR session_data cache).
133
+ * Use from delete-user / explicit sign-out flows that need to wipe the
134
+ * Better Auth cookie state without invoking the auth router.
135
+ */
136
+ export function clearBetterAuthSessionCookies(event: H3Event): void {
137
+ const useSecure = shouldUseSecurePrefix();
138
+ const opts = { path: '/', secure: useSecure, sameSite: 'lax' as const };
139
+ deleteCookie(event, getBetterAuthSessionCookieName(useSecure), opts);
140
+ deleteCookie(event, getBetterAuthSessionDataCookieName(useSecure), opts);
141
+ }