@commonpub/layer 0.21.14 → 0.21.16
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/components/views/ProjectView.vue +41 -7
- package/package.json +8 -8
- 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
|
@@ -73,6 +73,20 @@ const authorUrl = computed(() =>
|
|
|
73
73
|
: `/u/${props.content.author?.username}`,
|
|
74
74
|
);
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Whether the right sidebar has any content to render. When false we
|
|
78
|
+
* suppress the sidebar `<aside>` AND the grid's 260px column so the
|
|
79
|
+
* content column gets the full width. Without this guard, projects
|
|
80
|
+
* with no BOM/parts AND no community hub get a squished content
|
|
81
|
+
* column with empty whitespace on the right (heatsynclabs.io
|
|
82
|
+
* report, 2026-05-19).
|
|
83
|
+
*/
|
|
84
|
+
const hasSidebar = computed(() => {
|
|
85
|
+
const bom = (props.content?.parts?.length ?? 0) > 0 || (bomProducts.value?.length ?? 0) > 0;
|
|
86
|
+
const community = hubsEnabled.value && !!props.content?.community;
|
|
87
|
+
return bom || community;
|
|
88
|
+
});
|
|
89
|
+
|
|
76
90
|
const formattedDate = computed(() => {
|
|
77
91
|
const date = props.content?.publishedAt || props.content?.createdAt;
|
|
78
92
|
if (!date) return '';
|
|
@@ -418,7 +432,7 @@ async function handleBuild(): Promise<void> {
|
|
|
418
432
|
|
|
419
433
|
<!-- MAIN CONTENT GRID -->
|
|
420
434
|
<div class="cpub-page-outer">
|
|
421
|
-
<div class="cpub-content-grid" :class="{ 'cpub-has-toc': tocEntries.length > 0 && activeTab === 'overview' }">
|
|
435
|
+
<div class="cpub-content-grid" :class="{ 'cpub-has-toc': tocEntries.length > 0 && activeTab === 'overview', 'cpub-has-sidebar': hasSidebar }">
|
|
422
436
|
<!-- LEFT: TABLE OF CONTENTS -->
|
|
423
437
|
<nav v-if="tocEntries.length > 0 && activeTab === 'overview'" class="cpub-toc-col">
|
|
424
438
|
<div class="cpub-toc">
|
|
@@ -561,8 +575,9 @@ async function handleBuild(): Promise<void> {
|
|
|
561
575
|
</template>
|
|
562
576
|
</div>
|
|
563
577
|
|
|
564
|
-
<!-- RIGHT: SIDEBAR
|
|
565
|
-
|
|
578
|
+
<!-- RIGHT: SIDEBAR (rendered only when there's BOM/community content;
|
|
579
|
+
the cpub-has-sidebar grid-class reserves the 260px column to match) -->
|
|
580
|
+
<aside v-if="hasSidebar" class="cpub-sidebar">
|
|
566
581
|
<!-- BOM Summary -->
|
|
567
582
|
<div v-if="content.parts?.length || bomProducts?.length" class="cpub-sb-card">
|
|
568
583
|
<div class="cpub-sb-title">BOM Summary</div>
|
|
@@ -940,15 +955,29 @@ async function handleBuild(): Promise<void> {
|
|
|
940
955
|
border-bottom-color: var(--border);
|
|
941
956
|
}
|
|
942
957
|
|
|
943
|
-
/* ── CONTENT GRID ──
|
|
958
|
+
/* ── CONTENT GRID ──
|
|
959
|
+
4 layouts via 2 boolean modifier classes:
|
|
960
|
+
base → content only
|
|
961
|
+
.cpub-has-toc → TOC + content
|
|
962
|
+
.cpub-has-sidebar → content + sidebar
|
|
963
|
+
.cpub-has-toc.cpub-has-sidebar → TOC + content + sidebar
|
|
964
|
+
The sidebar 260px column is reserved ONLY when there's sidebar
|
|
965
|
+
content to put in it (BOM/parts OR community hub) — otherwise the
|
|
966
|
+
content column gets the freed width. */
|
|
944
967
|
.cpub-content-grid {
|
|
945
968
|
display: grid;
|
|
946
|
-
grid-template-columns: minmax(0, 1fr)
|
|
969
|
+
grid-template-columns: minmax(0, 1fr);
|
|
947
970
|
gap: clamp(16px, 3vw, 32px);
|
|
948
971
|
align-items: start;
|
|
949
972
|
padding-bottom: 64px;
|
|
950
973
|
}
|
|
951
974
|
.cpub-content-grid.cpub-has-toc {
|
|
975
|
+
grid-template-columns: 200px minmax(0, 1fr);
|
|
976
|
+
}
|
|
977
|
+
.cpub-content-grid.cpub-has-sidebar {
|
|
978
|
+
grid-template-columns: minmax(0, 1fr) 260px;
|
|
979
|
+
}
|
|
980
|
+
.cpub-content-grid.cpub-has-toc.cpub-has-sidebar {
|
|
952
981
|
grid-template-columns: 200px minmax(0, 1fr) 260px;
|
|
953
982
|
}
|
|
954
983
|
|
|
@@ -1561,9 +1590,12 @@ async function handleBuild(): Promise<void> {
|
|
|
1561
1590
|
|
|
1562
1591
|
/* ── RESPONSIVE ── */
|
|
1563
1592
|
|
|
1564
|
-
/* 1200px: Drop left TOC column, keep sidebar */
|
|
1593
|
+
/* 1200px: Drop left TOC column, keep sidebar if it exists */
|
|
1565
1594
|
@media (max-width: 1200px) {
|
|
1566
1595
|
.cpub-content-grid.cpub-has-toc {
|
|
1596
|
+
grid-template-columns: minmax(0, 1fr);
|
|
1597
|
+
}
|
|
1598
|
+
.cpub-content-grid.cpub-has-toc.cpub-has-sidebar {
|
|
1567
1599
|
grid-template-columns: minmax(0, 1fr) 260px;
|
|
1568
1600
|
}
|
|
1569
1601
|
.cpub-toc-col { display: none; }
|
|
@@ -1572,7 +1604,9 @@ async function handleBuild(): Promise<void> {
|
|
|
1572
1604
|
/* 1024px: Single column — sidebar stacks below content */
|
|
1573
1605
|
@media (max-width: 1024px) {
|
|
1574
1606
|
.cpub-content-grid,
|
|
1575
|
-
.cpub-content-grid.cpub-has-toc
|
|
1607
|
+
.cpub-content-grid.cpub-has-toc,
|
|
1608
|
+
.cpub-content-grid.cpub-has-sidebar,
|
|
1609
|
+
.cpub-content-grid.cpub-has-toc.cpub-has-sidebar {
|
|
1576
1610
|
grid-template-columns: minmax(0, 1fr);
|
|
1577
1611
|
}
|
|
1578
1612
|
.cpub-sidebar { position: static; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.16",
|
|
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/explainer": "0.7.15",
|
|
54
53
|
"@commonpub/auth": "0.6.0",
|
|
55
|
-
"@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
54
|
"@commonpub/docs": "0.6.3",
|
|
55
|
+
"@commonpub/editor": "0.7.10",
|
|
56
|
+
"@commonpub/explainer": "0.7.15",
|
|
61
57
|
"@commonpub/learning": "0.5.2",
|
|
62
|
-
"@commonpub/
|
|
58
|
+
"@commonpub/protocol": "0.12.0",
|
|
59
|
+
"@commonpub/server": "2.55.0",
|
|
60
|
+
"@commonpub/ui": "0.8.5",
|
|
61
|
+
"@commonpub/schema": "0.16.0",
|
|
62
|
+
"@commonpub/config": "0.13.0"
|
|
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
|
+
}
|