@commonpub/layer 0.21.13 → 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 +7 -7
- package/server/api/auth/delete-user.post.ts +8 -2
- package/server/api/auth/federated/callback.get.ts +14 -20
- package/server/api/auth/federated/link.post.ts +11 -8
- package/server/api/auth/mastodon/callback.get.ts +11 -16
- package/server/api/content/[id]/view.post.ts +6 -5
- package/server/api/federation/content/[id]/view.post.ts +4 -4
- package/server/middleware/security.ts +10 -4
- package/server/utils/betterAuthCookie.ts +141 -0
- package/server/utils/inbox.ts +35 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -50,16 +50,16 @@
|
|
|
50
50
|
"vue": "^3.4.0",
|
|
51
51
|
"vue-router": "^4.3.0",
|
|
52
52
|
"zod": "^4.3.6",
|
|
53
|
+
"@commonpub/auth": "0.6.0",
|
|
53
54
|
"@commonpub/config": "0.13.0",
|
|
54
55
|
"@commonpub/docs": "0.6.3",
|
|
55
|
-
"@commonpub/
|
|
56
|
-
"@commonpub/learning": "0.5.2",
|
|
56
|
+
"@commonpub/server": "2.55.0",
|
|
57
57
|
"@commonpub/schema": "0.16.0",
|
|
58
|
-
"@commonpub/explainer": "0.7.15",
|
|
59
|
-
"@commonpub/ui": "0.8.5",
|
|
60
58
|
"@commonpub/editor": "0.7.10",
|
|
61
|
-
"@commonpub/
|
|
62
|
-
"@commonpub/protocol": "0.
|
|
59
|
+
"@commonpub/learning": "0.5.2",
|
|
60
|
+
"@commonpub/protocol": "0.12.0",
|
|
61
|
+
"@commonpub/explainer": "0.7.15",
|
|
62
|
+
"@commonpub/ui": "0.8.5"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -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
|
|
52
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
}
|
package/server/utils/inbox.ts
CHANGED
|
@@ -78,28 +78,45 @@ export async function verifyInboxRequest(event: H3Event, label: string): Promise
|
|
|
78
78
|
throw createError({ statusCode: 401, statusMessage: 'Invalid actor URI' });
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
// 6. Date header
|
|
81
|
+
// 6. Date header is mandatory + must be fresh (replay-window protection).
|
|
82
82
|
const dateHeader = getHeader(event, 'date');
|
|
83
|
-
if (dateHeader) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
83
|
+
if (!dateHeader) {
|
|
84
|
+
throw createError({ statusCode: 401, statusMessage: 'Missing Date header' });
|
|
85
|
+
}
|
|
86
|
+
const requestDate = new Date(dateHeader).getTime();
|
|
87
|
+
if (isNaN(requestDate)) {
|
|
88
|
+
throw createError({ statusCode: 401, statusMessage: 'Invalid Date header' });
|
|
89
|
+
}
|
|
90
|
+
const skew = Math.abs(Date.now() - requestDate);
|
|
91
|
+
if (skew > MAX_DATE_SKEW_MS) {
|
|
92
|
+
console.warn(`[${label}] Date header too old/new: skew=${Math.round(skew / 1000)}s from ${actorUri}`);
|
|
93
|
+
throw createError({ statusCode: 401, statusMessage: 'Request date too far from server time' });
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
// 7. Read
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
// 7. Read the RAW body once. Two uses:
|
|
97
|
+
// - Hashing for digest verification (must match the sender's digest,
|
|
98
|
+
// which was computed over the exact bytes on the wire).
|
|
99
|
+
// - JSON.parse for handler consumption.
|
|
100
|
+
// `JSON.stringify(JSON.parse(x)) !== x` in general, so we cannot
|
|
101
|
+
// rebuild the verify-Request from a re-serialized copy without
|
|
102
|
+
// breaking digest comparison. Item 6 of federation-hardening Stage 3.
|
|
103
|
+
const rawBody = await readRawBody(event, false);
|
|
104
|
+
if (!rawBody) {
|
|
105
|
+
throw createError({ statusCode: 400, statusMessage: 'Empty body' });
|
|
106
|
+
}
|
|
107
|
+
const bodyStr = typeof rawBody === 'string' ? rawBody : Buffer.from(rawBody).toString('utf-8');
|
|
97
108
|
|
|
98
|
-
// Body size check on actual content (in case Content-Length was missing/wrong)
|
|
99
109
|
if (bodyStr.length > MAX_BODY_SIZE) {
|
|
100
110
|
throw createError({ statusCode: 413, statusMessage: 'Payload too large' });
|
|
101
111
|
}
|
|
102
112
|
|
|
113
|
+
let body: Record<string, unknown>;
|
|
114
|
+
try {
|
|
115
|
+
body = JSON.parse(bodyStr);
|
|
116
|
+
} catch {
|
|
117
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid JSON body' });
|
|
118
|
+
}
|
|
119
|
+
|
|
103
120
|
const url = getRequestURL(event);
|
|
104
121
|
const headers = new Headers();
|
|
105
122
|
for (const [key, value] of Object.entries(getHeaders(event))) {
|
|
@@ -111,7 +128,10 @@ export async function verifyInboxRequest(event: H3Event, label: string): Promise
|
|
|
111
128
|
body: bodyStr,
|
|
112
129
|
});
|
|
113
130
|
|
|
114
|
-
// 8. Verify HTTP Signature cryptographically
|
|
131
|
+
// 8. Verify HTTP Signature cryptographically (also enforces the coverage
|
|
132
|
+
// policy: (request-target), host, date, and — when body is non-empty —
|
|
133
|
+
// digest MUST all be in the signed headers set; digest must match raw
|
|
134
|
+
// body SHA-256. Item 7.)
|
|
115
135
|
const signatureValid = await verifyHttpSignature(verifyRequest, actor.publicKey.publicKeyPem);
|
|
116
136
|
if (!signatureValid) {
|
|
117
137
|
console.warn(`[${label}] HTTP Signature verification failed for ${actorUri}`);
|