@commonpub/layer 0.3.17 → 0.3.19

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.
@@ -1,4 +1,5 @@
1
- import { consumeOAuthState, exchangeCodeForToken, linkFederatedAccount } from '@commonpub/server';
1
+ import { consumeOAuthState, exchangeCodeForToken, linkFederatedAccount, findUserByFederatedAccount, createFederatedSession, storePendingLink } from '@commonpub/server';
2
+ import type { H3Event } from 'h3';
2
3
  import { z } from 'zod';
3
4
 
4
5
  const callbackSchema = z.object({
@@ -6,16 +7,30 @@ const callbackSchema = z.object({
6
7
  state: z.string(),
7
8
  });
8
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
+
9
23
  /**
10
24
  * OAuth2 callback handler for federated login.
11
- * Exchanges authorization code for token, links federated account.
25
+ * Exchanges authorization code for token, links federated account,
26
+ * creates a session, and redirects.
12
27
  */
13
28
  export default defineEventHandler(async (event) => {
14
29
  requireFeature('federation');
15
30
  const db = useDB();
16
31
  const { code, state: stateToken } = parseQueryParams(event, callbackSchema);
17
32
 
18
- // Retrieve and consume the stored OAuth state (single-use, 10min TTL)
33
+ // Retrieve and consume the stored OAuth state (single-use, atomic)
19
34
  const oauthState = await consumeOAuthState(db, stateToken);
20
35
  if (!oauthState) {
21
36
  throw createError({
@@ -33,14 +48,19 @@ export default defineEventHandler(async (event) => {
33
48
  });
34
49
  }
35
50
 
51
+ const ipAddress = getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
52
+ ?? getRequestHeader(event, 'x-real-ip')
53
+ ?? undefined;
54
+ const userAgent = getRequestHeader(event, 'user-agent') ?? undefined;
55
+
36
56
  // Check if a local user is already linked to this federated account
37
- const { findUserByFederatedAccount } = await import('@commonpub/server');
38
57
  const existingLink = await findUserByFederatedAccount(db, tokenResult.user.actorUri);
39
58
 
40
59
  if (existingLink) {
41
- // User already linked — redirect to dashboard
42
- // In a full implementation, this would also create a Better Auth session
43
- return sendRedirect(event, `/dashboard?federated=linked&user=${existingLink.username}`, 302);
60
+ // User already linked — create session and redirect to dashboard
61
+ const session = await createFederatedSession(db, existingLink.userId, ipAddress, userAgent);
62
+ setSessionCookie(event, session.sessionToken, session.expiresAt);
63
+ return sendRedirect(event, '/dashboard', 302);
44
64
  }
45
65
 
46
66
  // Check if the current user is logged in — if so, link to their account
@@ -55,13 +75,15 @@ export default defineEventHandler(async (event) => {
55
75
  return sendRedirect(event, '/settings/account?federated=linked', 302);
56
76
  }
57
77
 
58
- // Not logged in and no existing link — redirect to login page with federated context
59
- // The user needs to either create an account or log in to link
60
- const params = new URLSearchParams({
61
- federated: 'true',
78
+ // Not logged in and no existing link — store verified identity in a server-side token.
79
+ // Only the opaque token is passed to the client; the actorUri is never exposed in the URL.
80
+ const linkToken = await storePendingLink(db, {
62
81
  actorUri: tokenResult.user.actorUri,
63
82
  username: tokenResult.user.username,
64
- instance: oauthState.instanceDomain,
83
+ instanceDomain: oauthState.instanceDomain,
84
+ displayName: tokenResult.user.displayName ?? undefined,
85
+ avatarUrl: tokenResult.user.avatarUrl ?? undefined,
65
86
  });
66
- return sendRedirect(event, `/auth/login?${params.toString()}`, 302);
87
+
88
+ return sendRedirect(event, `/auth/login?federated=true&linkToken=${linkToken}`, 302);
67
89
  });
@@ -0,0 +1,82 @@
1
+ import { linkFederatedAccount, consumePendingLink } from '@commonpub/server';
2
+ import { z } from 'zod';
3
+
4
+ const linkSchema = z.object({
5
+ /** Local credentials */
6
+ identity: z.string().min(1),
7
+ password: z.string().min(1),
8
+ /** Server-side link token from the OAuth callback — proves the federated identity was verified */
9
+ linkToken: z.string().min(1),
10
+ });
11
+
12
+ /**
13
+ * Link a verified federated identity to a local account.
14
+ * The linkToken is a server-side opaque token stored during the OAuth callback.
15
+ * It proves the federated identity was authenticated — the actorUri is never
16
+ * sent from the client, preventing identity hijacking.
17
+ */
18
+ export default defineEventHandler(async (event) => {
19
+ requireFeature('federation');
20
+ const db = useDB();
21
+ const body = await parseBody(event, linkSchema);
22
+
23
+ // Step 1: Consume the pending link token (single-use, 10min TTL)
24
+ const pendingLink = await consumePendingLink(db, body.linkToken);
25
+ if (!pendingLink) {
26
+ throw createError({
27
+ statusCode: 400,
28
+ statusMessage: 'Link token is invalid or expired. Please start the federated login again.',
29
+ });
30
+ }
31
+
32
+ // Step 2: Resolve identity to email
33
+ const { email } = await $fetch<{ email: string }>('/api/resolve-identity', {
34
+ method: 'POST',
35
+ body: { identity: body.identity },
36
+ }).catch(() => {
37
+ throw createError({ statusCode: 401, statusMessage: 'Invalid credentials.' });
38
+ });
39
+
40
+ // Step 3: Authenticate via Better Auth sign-in (also creates session + sets cookie)
41
+ const signInResponse = await $fetch<{ user?: { id: string }; session?: { token: string; expiresAt: string } }>(
42
+ '/api/auth/sign-in/email',
43
+ {
44
+ method: 'POST',
45
+ body: { email, password: body.password },
46
+ },
47
+ ).catch(() => {
48
+ throw createError({ statusCode: 401, statusMessage: 'Invalid credentials.' });
49
+ });
50
+
51
+ if (!signInResponse?.user?.id || !signInResponse?.session?.token) {
52
+ throw createError({ statusCode: 401, statusMessage: 'Invalid credentials.' });
53
+ }
54
+
55
+ const userId = signInResponse.user.id;
56
+
57
+ // Step 4: Link the verified federated identity (from server-side token, NOT client input)
58
+ try {
59
+ await linkFederatedAccount(db, userId, pendingLink.actorUri, pendingLink.instanceDomain, {
60
+ preferredUsername: pendingLink.username,
61
+ displayName: pendingLink.displayName,
62
+ avatarUrl: pendingLink.avatarUrl,
63
+ });
64
+ } catch (err: unknown) {
65
+ const msg = err instanceof Error ? err.message : 'Failed to link account';
66
+ throw createError({ statusCode: 409, statusMessage: msg });
67
+ }
68
+
69
+ // Step 5: Use the session Better Auth created — set cookie for the client
70
+ setCookie(event, 'better-auth.session_token', signInResponse.session.token, {
71
+ httpOnly: true,
72
+ secure: process.env.NODE_ENV === 'production',
73
+ sameSite: 'lax',
74
+ path: '/',
75
+ expires: new Date(signInResponse.session.expiresAt),
76
+ });
77
+
78
+ return {
79
+ success: true,
80
+ linked: true,
81
+ };
82
+ });
@@ -1,4 +1,4 @@
1
- import { listConversations } from '@commonpub/server';
1
+ import { listConversations, getConversationUnreadCounts } from '@commonpub/server';
2
2
  import { users } from '@commonpub/schema';
3
3
  import { inArray } from 'drizzle-orm';
4
4
 
@@ -6,11 +6,14 @@ export default defineEventHandler(async (event) => {
6
6
  const db = useDB();
7
7
  const user = requireAuth(event);
8
8
 
9
- const conversations = await listConversations(db, user.id);
9
+ const [conversationList, unreadCounts] = await Promise.all([
10
+ listConversations(db, user.id),
11
+ getConversationUnreadCounts(db, user.id),
12
+ ]);
10
13
 
11
14
  // Collect all unique participant IDs
12
15
  const allIds = new Set<string>();
13
- for (const conv of conversations) {
16
+ for (const conv of conversationList) {
14
17
  for (const id of (conv.participants ?? [])) {
15
18
  allIds.add(id);
16
19
  }
@@ -28,9 +31,10 @@ export default defineEventHandler(async (event) => {
28
31
  }
29
32
  }
30
33
 
31
- // Replace participant IDs with resolved user objects
32
- return conversations.map((conv) => ({
34
+ // Replace participant IDs with resolved user objects, include unread count
35
+ return conversationList.map((conv) => ({
33
36
  ...conv,
37
+ unreadCount: unreadCounts[conv.id] ?? 0,
34
38
  participants: (conv.participants ?? []).map((id: string) => {
35
39
  const u = userMap.get(id);
36
40
  return u ? { username: u.username, displayName: u.displayName, avatarUrl: u.avatarUrl } : { username: id, displayName: null, avatarUrl: null };