From 719849f74c46ca70ed5374a6edc3925950ec7824 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 19 May 2026 19:28:15 +0200 Subject: [PATCH 1/2] feat: allow users to mint authentication tokens from the ui, and communities to revert them --- .../CommunityAdminSettings.tsx | 3 + .../CommunitySettings/CommunityAuthTokens.tsx | 183 +++++++++ client/containers/Legal/AuthTokensCard.tsx | 374 ++++++++++++++++++ client/containers/Legal/Legal.tsx | 8 + client/containers/Legal/PrivacySettings.tsx | 3 + server/authToken/__tests__/api.test.ts | 67 ++++ server/authToken/api.ts | 72 ++++ server/community/queries.ts | 17 + server/routes/legal.tsx | 22 +- utils/api/contracts/authToken.ts | 68 ++++ 10 files changed, 810 insertions(+), 7 deletions(-) create mode 100644 client/containers/DashboardSettings/CommunitySettings/CommunityAuthTokens.tsx create mode 100644 client/containers/Legal/AuthTokensCard.tsx diff --git a/client/containers/DashboardSettings/CommunitySettings/CommunityAdminSettings.tsx b/client/containers/DashboardSettings/CommunitySettings/CommunityAdminSettings.tsx index fd12ae78c..b252a468f 100644 --- a/client/containers/DashboardSettings/CommunitySettings/CommunityAdminSettings.tsx +++ b/client/containers/DashboardSettings/CommunitySettings/CommunityAdminSettings.tsx @@ -8,6 +8,7 @@ import { SettingsSection } from 'components'; import { getDashUrl } from 'utils/dashboard'; import { usePageContext } from 'utils/hooks'; +import CommunityAuthTokens from './CommunityAuthTokens'; import DeleteCommunity from './DeleteCommunity'; import DiscussionsSection from './DiscussionsSection'; import { ExportCommunityDataButton } from './ExportCommunityDataButton'; @@ -98,6 +99,8 @@ const ExportAndDeleteSettings = (props: Props) => { + {communityData && } + ); diff --git a/client/containers/DashboardSettings/CommunitySettings/CommunityAuthTokens.tsx b/client/containers/DashboardSettings/CommunitySettings/CommunityAuthTokens.tsx new file mode 100644 index 000000000..4706958f6 --- /dev/null +++ b/client/containers/DashboardSettings/CommunitySettings/CommunityAuthTokens.tsx @@ -0,0 +1,183 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { Button, Callout, HTMLTable, Tag } from '@blueprintjs/core'; + +import { apiFetch } from 'client/utils/apiFetch'; +import { ConfirmDialog, SettingsSection } from 'components'; + +type CommunityAuthToken = { + id: string; + userId: string; + communityId: string; + expiresAt: string | null; + createdAt: string; + user?: { + id: string; + fullName?: string | null; + slug?: string | null; + avatar?: string | null; + initials?: string | null; + } | null; +}; + +type Props = { + communityData: { + id: string; + title: string; + }; +}; + +const formatDate = (iso: string | null | undefined) => { + if (!iso) { + return null; + } + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return null; + } + return date.toLocaleDateString(); +}; + +const errorMessage = (e: unknown, fallback: string) => { + if (e instanceof Error) { + return e.message; + } + if (typeof e === 'object' && e !== null && 'message' in e) { + return (e as { message: string }).message; + } + return fallback; +}; + +const CommunityAuthTokens = ({ communityData }: Props) => { + const [tokens, setTokens] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + let cancelled = false; + setIsLoading(true); + apiFetch + .get(`/api/authTokens/community/${communityData.id}`) + .then((result: CommunityAuthToken[]) => { + if (!cancelled) { + setTokens(result); + setLoadError(null); + } + }) + .catch((e) => { + if (!cancelled) { + setLoadError(errorMessage(e, 'Failed to load community auth tokens.')); + } + }) + .finally(() => { + if (!cancelled) { + setIsLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [communityData.id]); + + const handleRevoke = useCallback( + async (tokenId: string) => { + await apiFetch.delete(`/api/authTokens/community/${communityData.id}/${tokenId}`); + setTokens((prev) => prev.filter((t) => t.id !== tokenId)); + }, + [communityData.id], + ); + + return ( + +

+ Auth tokens grant the token’s owner programmatic access to this community with their + full admin privileges. Demoting a user automatically invalidates their tokens; + revoking a token here cuts off a single token without changing the user’s role. +

+ + {loadError && ( + + {loadError} + + )} + + {!isLoading && tokens.length === 0 && !loadError && ( +

No auth tokens have been minted for this community.

+ )} + + {tokens.length > 0 && ( + + + + Owner + Created + Expires + + + + + {tokens.map((t) => { + const expires = formatDate(t.expiresAt); + const isExpired = !!( + t.expiresAt && new Date(t.expiresAt).getTime() < Date.now() + ); + const ownerName = t.user?.fullName || t.user?.slug || t.userId; + return ( + + + {t.user?.slug ? ( + {ownerName} + ) : ( + ownerName + )} + + {formatDate(t.createdAt) ?? '—'} + + {expires ? ( + isExpired ? ( + + Expired + + ) : ( + expires + ) + ) : ( + 'Never' + )} + + + + Revoking this token will immediately invalidate + it for {communityData.title}. The owner can mint + a new one if they are still an admin. +

+ } + confirmLabel="Revoke" + onConfirm={() => handleRevoke(t.id)} + > + {({ open }) => ( + + )} +
+ + + ); + })} + +
+ )} +
+ ); +}; + +export default CommunityAuthTokens; diff --git a/client/containers/Legal/AuthTokensCard.tsx b/client/containers/Legal/AuthTokensCard.tsx new file mode 100644 index 000000000..3ee488a34 --- /dev/null +++ b/client/containers/Legal/AuthTokensCard.tsx @@ -0,0 +1,374 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { + Button, + Callout, + Card, + Classes, + Dialog, + HTMLSelect, + HTMLTable, + InputGroup, + Tag, +} from '@blueprintjs/core'; + +import { apiFetch } from 'client/utils/apiFetch'; +import { ConfirmDialog } from 'components'; + +type AdminCommunityRef = { + id: string; + title: string; + subdomain: string; +}; + +type ExistingToken = { + id: string; + userId: string; + communityId: string; + expiresAt: string | null; + createdAt: string; + community?: { + id: string; + title: string; + subdomain: string; + } | null; +}; + +type CreatedToken = { + id: string; + userId: string; + communityId: string; + token: string; + expiresAt: string | null; +}; + +type ExpiresAtChoice = 'never' | '1d' | '1w' | '1m' | '3m' | '1y'; + +const expiresAtOptions: { value: ExpiresAtChoice; label: string }[] = [ + { value: '1d', label: '1 day' }, + { value: '1w', label: '1 week' }, + { value: '1m', label: '1 month' }, + { value: '3m', label: '3 months' }, + { value: '1y', label: '1 year' }, + { value: 'never', label: 'Never' }, +]; + +type Props = { + adminCommunities?: AdminCommunityRef[]; +}; + +const formatDate = (iso: string | null | undefined) => { + if (!iso) { + return null; + } + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return null; + } + return date.toLocaleDateString(); +}; + +const errorMessage = (e: unknown, fallback: string) => { + if (e instanceof Error) { + return e.message; + } + if (typeof e === 'object' && e !== null && 'message' in e) { + return (e as { message: string }).message; + } + return fallback; +}; + +const AuthTokensCard = ({ adminCommunities = [] }: Props) => { + const [tokens, setTokens] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + const [selectedCommunityId, setSelectedCommunityId] = useState(''); + const [selectedExpiresAt, setSelectedExpiresAt] = useState('3m'); + const [isCreating, setIsCreating] = useState(false); + const [createError, setCreateError] = useState(null); + + const [createdToken, setCreatedToken] = useState(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (adminCommunities.length > 0 && !selectedCommunityId) { + setSelectedCommunityId(adminCommunities[0].id); + } + }, [adminCommunities, selectedCommunityId]); + + useEffect(() => { + let cancelled = false; + setIsLoading(true); + apiFetch + .get('/api/authTokens') + .then((result: ExistingToken[]) => { + if (!cancelled) { + setTokens(result); + setLoadError(null); + } + }) + .catch((e) => { + if (!cancelled) { + setLoadError(errorMessage(e, 'Failed to load auth tokens.')); + } + }) + .finally(() => { + if (!cancelled) { + setIsLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, []); + + const communitiesById = useMemo(() => { + const map = new Map(); + adminCommunities.forEach((c) => map.set(c.id, c)); + return map; + }, [adminCommunities]); + + const handleCreate = useCallback(async () => { + if (!selectedCommunityId) { + return; + } + setIsCreating(true); + setCreateError(null); + try { + const created: CreatedToken = await apiFetch.post('/api/authTokens', { + communityId: selectedCommunityId, + expiresAt: selectedExpiresAt, + }); + setCreatedToken(created); + setCopied(false); + const community = communitiesById.get(selectedCommunityId); + setTokens((prev) => [ + { + id: created.id, + userId: created.userId, + communityId: created.communityId, + expiresAt: created.expiresAt, + createdAt: new Date().toISOString(), + community: community + ? { + id: community.id, + title: community.title, + subdomain: community.subdomain, + } + : null, + }, + ...prev, + ]); + } catch (e) { + setCreateError(errorMessage(e, 'Failed to create auth token.')); + } finally { + setIsCreating(false); + } + }, [communitiesById, selectedCommunityId, selectedExpiresAt]); + + const handleRevoke = useCallback(async (tokenId: string) => { + await apiFetch.delete(`/api/authTokens/${tokenId}`); + setTokens((prev) => prev.filter((t) => t.id !== tokenId)); + }, []); + + const handleCopy = useCallback(() => { + if (!createdToken) { + return; + } + if (typeof navigator !== 'undefined' && navigator.clipboard) { + navigator.clipboard.writeText(createdToken.token).then(() => setCopied(true)); + } + }, [createdToken]); + + const closeCreatedDialog = useCallback(() => { + setCreatedToken(null); + setCopied(false); + }, []); + + const canMint = adminCommunities.length > 0; + + return ( + +
Auth tokens
+

+ Auth tokens let you authenticate against the PubPub API as yourself. Each token is + scoped to one community where you are an admin and grants the same privileges as + your account. A token automatically stops working if you are no longer an admin of + that community. +

+ + {canMint ? ( +
+
+ setSelectedCommunityId(e.target.value)} + disabled={isCreating} + aria-label="Community" + > + {adminCommunities.map((c) => ( + + ))} + + + setSelectedExpiresAt(e.target.value as ExpiresAtChoice) + } + disabled={isCreating} + aria-label="Expires after" + > + {expiresAtOptions.map((o) => ( + + ))} + + +
+ {createError && ( + + {createError} + + )} +
+ ) : ( + + Only community admins can mint auth tokens. + + )} + + {loadError && ( + + {loadError} + + )} + + {!isLoading && tokens.length === 0 && !loadError && ( +

You have no auth tokens.

+ )} + + {tokens.length > 0 && ( + + + + Community + Created + Expires + + + + + {tokens.map((t) => { + const expires = formatDate(t.expiresAt); + const isExpired = !!( + t.expiresAt && new Date(t.expiresAt).getTime() < Date.now() + ); + return ( + + {t.community?.title ?? t.communityId} + {formatDate(t.createdAt) ?? '—'} + + {expires ? ( + isExpired ? ( + + Expired + + ) : ( + expires + ) + ) : ( + 'Never' + )} + + + + Revoking this token will immediately invalidate + it. Any scripts or services using it will stop + working. +

+ } + confirmLabel="Revoke" + onConfirm={() => handleRevoke(t.id)} + > + {({ open }) => ( + + )} +
+ + + ); + })} + +
+ )} + + +
+ + This is the only time the token will be shown. Copy it now and store it + somewhere safe. + + {createdToken && ( + e.currentTarget.select()} + rightElement={ + + } + /> + )} +
+
+
+ +
+
+
+
+ ); +}; + +export default AuthTokensCard; diff --git a/client/containers/Legal/Legal.tsx b/client/containers/Legal/Legal.tsx index 7004530c2..e67b7e0b5 100644 --- a/client/containers/Legal/Legal.tsx +++ b/client/containers/Legal/Legal.tsx @@ -15,6 +15,12 @@ import Terms from './Terms'; import './legal.scss'; +export type AdminCommunityRef = { + id: string; + title: string; + subdomain: string; +}; + type Props = { integrations: Integration[]; userNotificationPreferences?: UserNotificationPreferences; @@ -26,6 +32,7 @@ type Props = { output: string | null; error: string | null; }[]; + adminCommunities?: AdminCommunityRef[]; }; const Legal = (props: Props) => { @@ -92,6 +99,7 @@ const Legal = (props: Props) => { integrations={props.integrations} userEmail={props.userEmail} accountExports={props.accountExports} + adminCommunities={props.adminCommunities} userNotificationPreferences={userNotificationPreferences} onUpdateUserNotificationPreferences={ updateUserNotificationPreferences diff --git a/client/containers/Legal/PrivacySettings.tsx b/client/containers/Legal/PrivacySettings.tsx index e2a0ced76..573b794b5 100644 --- a/client/containers/Legal/PrivacySettings.tsx +++ b/client/containers/Legal/PrivacySettings.tsx @@ -10,6 +10,7 @@ import AccountSecuritySettings from 'components/AccountSecuritySettings'; import UserNotificationPreferences from 'components/UserNotifications/UserNotificationPreferences'; import { usePageContext } from 'utils/hooks'; +import AuthTokensCard from './AuthTokensCard'; import DeleteAccount from './DeleteAccount'; import ExportAccountData from './ExportAccountData'; @@ -24,6 +25,7 @@ type PrivacySettingsProps = { output: string | null; error: string | null; }[]; + adminCommunities?: { id: string; title: string; subdomain: string }[]; userNotificationPreferences?: types.UserNotificationPreferences; onUpdateUserNotificationPreferences: ( preferences: Partial, @@ -147,6 +149,7 @@ const PrivacySettings = (props: PrivacySettingsProps) => { /> )} + diff --git a/server/authToken/__tests__/api.test.ts b/server/authToken/__tests__/api.test.ts index 24dfb4889..cc940a277 100644 --- a/server/authToken/__tests__/api.test.ts +++ b/server/authToken/__tests__/api.test.ts @@ -175,4 +175,71 @@ describe('authToken', () => { await agent.delete(`/api/authTokens`).send({ token: expiredToken.token }).expect(200); }); + + it('a user should be able to list their own tokens, without the token secret', async () => { + const { communityAdmin, community } = models; + + const agent = await login(communityAdmin); + + const result = await agent + .get('/api/authTokens') + .set('Host', `${community.subdomain}.pubpub.org`) + .expect(200); + + expect(Array.isArray(result.body)).toBe(true); + result.body.forEach((t: any) => { + expect(t.userId).toBe(communityAdmin.id); + expect(t.token).toBeUndefined(); + }); + }); + + it('a community admin should be able to list tokens scoped to that community', async () => { + const { communityAdmin, community } = models; + + const agent = await login(communityAdmin); + + const result = await agent + .get(`/api/authTokens/community/${community.id}`) + .set('Host', `${community.subdomain}.pubpub.org`) + .expect(200); + + expect(Array.isArray(result.body)).toBe(true); + result.body.forEach((t: any) => { + expect(t.communityId).toBe(community.id); + expect(t.token).toBeUndefined(); + }); + }); + + it('a non-admin should not be able to list tokens for a community', async () => { + const { communityManager, community } = models; + + const agent = await login(communityManager); + + await agent + .get(`/api/authTokens/community/${community.id}`) + .set('Host', `${community.subdomain}.pubpub.org`) + .expect(403); + }); + + it('a community admin should be able to revoke another admin’s token for that community', async () => { + const { communityAdmin, community, anotherToken } = models; + + const agent = await login(communityAdmin); + + await agent + .delete(`/api/authTokens/community/${community.id}/${anotherToken.id}`) + .set('Host', `${community.subdomain}.pubpub.org`) + .expect(200); + }); + + it('a community admin should not be able to revoke tokens for a different community', async () => { + const { communityAdmin, community, anotherAuthToken, anotherCommunity } = models; + + const agent = await login(communityAdmin); + + await agent + .delete(`/api/authTokens/community/${anotherCommunity.id}/${anotherAuthToken.id}`) + .set('Host', `${community.subdomain}.pubpub.org`) + .expect(403); + }); }); diff --git a/server/authToken/api.ts b/server/authToken/api.ts index 75da70271..1c1b12594 100644 --- a/server/authToken/api.ts +++ b/server/authToken/api.ts @@ -1,5 +1,6 @@ import { initServer } from '@ts-rest/express'; +import { Community, User } from 'server/models'; import { BadRequestError, ForbiddenError, NotFoundError } from 'server/utils/errors'; import { contract } from 'utils/api/contract'; import { ensureUserIsCommunityAdmin } from 'utils/ensureUserIsCommunityAdmin'; @@ -45,6 +46,55 @@ export const authTokenServer = s.router(contract.authToken, { body: authToken.toJSON(), }; }, + getForUser: async ({ req }) => { + if (!req.user) { + throw new BadRequestError(new Error('User not found')); + } + + const tokens = await AuthToken.findAll({ + where: { userId: req.user.id }, + attributes: { exclude: ['token'] }, + include: [ + { + model: Community, + as: 'community', + attributes: ['id', 'title', 'subdomain'], + }, + ], + order: [['createdAt', 'DESC']], + }); + + return { + status: 200, + body: tokens.map((t) => t.toJSON()) as any, + }; + }, + + getForCommunity: async ({ params, req }) => { + await ensureUserIsCommunityAdmin({ + user: req.user, + id: params.communityId, + }); + + const tokens = await AuthToken.findAll({ + where: { communityId: params.communityId }, + attributes: { exclude: ['token'] }, + include: [ + { + model: User, + as: 'user', + attributes: ['id', 'fullName', 'slug', 'avatar', 'initials'], + }, + ], + order: [['createdAt', 'DESC']], + }); + + return { + status: 200, + body: tokens.map((t) => t.toJSON()) as any, + }; + }, + remove: async ({ params, req }) => { if (!req.user) { throw new BadRequestError(new Error('User not found')); @@ -68,6 +118,28 @@ export const authTokenServer = s.router(contract.authToken, { }; }, + removeForCommunity: async ({ params, req }) => { + await ensureUserIsCommunityAdmin({ + user: req.user, + id: params.communityId, + }); + + const authToken = await AuthToken.findOne({ + where: { id: params.id, communityId: params.communityId }, + }); + + if (!authToken) { + throw new NotFoundError(new Error('Token not found')); + } + + await authToken.destroy(); + + return { + status: 200, + body: params.id, + }; + }, + removeByToken: async ({ body: { token }, req: { user } }) => { if (!user?.isSuperAdmin) { throw new ForbiddenError(new Error('User is not a superadmin')); diff --git a/server/community/queries.ts b/server/community/queries.ts index b1ec5c8f6..93699b1e4 100644 --- a/server/community/queries.ts +++ b/server/community/queries.ts @@ -35,6 +35,23 @@ export const getCommunity = (communityId: string) => { }); }; +export const getAdminCommunitiesForUser = async (userId: string) => { + const memberships = await Member.findAll({ + where: { userId, permissions: 'admin', communityId: { [Op.ne]: null } }, + attributes: ['communityId'], + raw: true, + }); + const communityIds = memberships.map((m) => m.communityId).filter(Boolean) as string[]; + if (communityIds.length === 0) { + return []; + } + return Community.findAll({ + where: { id: communityIds }, + attributes: ['id', 'title', 'subdomain'], + order: [['title', 'ASC']], + }); +}; + export const createCommunity = async ( inputValues: z.infer, userData: User, diff --git a/server/routes/legal.tsx b/server/routes/legal.tsx index d557142a1..de94f644b 100644 --- a/server/routes/legal.tsx +++ b/server/routes/legal.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { Router } from 'express'; import { Legal } from 'containers'; +import { getAdminCommunitiesForUser } from 'server/community/queries'; import Html from 'server/Html'; import { getAccountExports } from 'server/user/account'; import { getOrCreateUserNotificationPreferences } from 'server/userNotificationPreferences/queries'; @@ -35,13 +36,19 @@ router.get('/legal/:tab', async (req, res, next) => { const userId = req.user?.id; const isSettingsTab = req.params.tab === 'settings'; - const [initialData, integrations, userNotificationPreferences, accountExports] = - await Promise.all([ - getInitialData(req), - userId ? getIntegrations(userId) : [], - userId ? getOrCreateUserNotificationPreferences(userId) : undefined, - isSettingsTab && userId ? getAccountExports(userId) : undefined, - ]); + const [ + initialData, + integrations, + userNotificationPreferences, + accountExports, + adminCommunities, + ] = await Promise.all([ + getInitialData(req), + userId ? getIntegrations(userId) : [], + userId ? getOrCreateUserNotificationPreferences(userId) : undefined, + isSettingsTab && userId ? getAccountExports(userId) : undefined, + isSettingsTab && userId ? getAdminCommunitiesForUser(userId) : undefined, + ]); const title = tabToTitle[req.params.tab as keyof typeof tabToTitle]; @@ -56,6 +63,7 @@ router.get('/legal/:tab', async (req, res, next) => { userNotificationPreferences, userEmail: isSettingsTab ? req.user?.email : undefined, accountExports: isSettingsTab ? accountExports : undefined, + adminCommunities: isSettingsTab ? adminCommunities : undefined, }} headerComponents={generateMetaComponents({ initialData, diff --git a/utils/api/contracts/authToken.ts b/utils/api/contracts/authToken.ts index 5fce24a6c..41a0041b9 100644 --- a/utils/api/contracts/authToken.ts +++ b/utils/api/contracts/authToken.ts @@ -5,6 +5,28 @@ import { z } from 'zod'; extendZodWithOpenApi(z); +const authTokenMetadata = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + communityId: z.string().uuid(), + expiresAt: z.string().datetime().nullable(), + createdAt: z.string().datetime(), +}); + +const authTokenCommunityRef = z.object({ + id: z.string().uuid(), + title: z.string(), + subdomain: z.string(), +}); + +const authTokenUserRef = z.object({ + id: z.string().uuid(), + fullName: z.string().nullable().optional(), + slug: z.string().nullable().optional(), + avatar: z.string().nullable().optional(), + initials: z.string().nullable().optional(), +}); + export const authTokenRouter = { create: { path: '/api/authTokens', @@ -30,6 +52,37 @@ export const authTokenRouter = { }), }, }, + getForUser: { + path: '/api/authTokens', + method: 'GET', + summary: 'List the current user’s authentication tokens', + description: + 'List authentication tokens owned by the current user. The token secret is never returned by this endpoint — only metadata.', + responses: { + 200: z.array( + authTokenMetadata.extend({ + community: authTokenCommunityRef.nullable().optional(), + }), + ), + }, + }, + getForCommunity: { + path: '/api/authTokens/community/:communityId', + method: 'GET', + summary: 'List authentication tokens scoped to a community', + description: + 'List authentication tokens scoped to a community. Only accessible to admins of that community. The token secret is never returned.', + pathParams: z.object({ + communityId: z.string().uuid(), + }), + responses: { + 200: z.array( + authTokenMetadata.extend({ + user: authTokenUserRef.nullable().optional(), + }), + ), + }, + }, remove: { path: '/api/authTokens/:id', method: 'DELETE', @@ -43,6 +96,21 @@ export const authTokenRouter = { 200: z.string().uuid(), }, }, + removeForCommunity: { + path: '/api/authTokens/community/:communityId/:id', + method: 'DELETE', + summary: 'Revoke a token scoped to a community', + description: + 'Revoke an authentication token scoped to a community. Accessible to any admin of that community, regardless of whether they minted the token.', + pathParams: z.object({ + communityId: z.string().uuid(), + id: z.string().uuid(), + }), + body: z.union([z.null(), z.object({})]).optional(), + responses: { + 200: z.string().uuid(), + }, + }, removeByToken: { path: '/api/authTokens', method: 'DELETE', From 32d2d77a0368ef87a60033297dad163cbf217da8 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 21 May 2026 14:38:37 +0200 Subject: [PATCH 2/2] refactor: make auth tokens hashed in the db (womp) --- server/authToken/api.ts | 23 +++++++++--- server/authToken/model.ts | 8 +++-- server/authToken/strategy.ts | 3 +- server/authToken/tokenGenerator.ts | 21 +++++++++++ stubstub/modelize/builders.ts | 17 +++++++++ tools/migrations/2026_05_21_hashAuthTokens.js | 35 +++++++++++++++++++ utils/api/contracts/authToken.ts | 7 +++- 7 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 server/authToken/tokenGenerator.ts create mode 100644 tools/migrations/2026_05_21_hashAuthTokens.js diff --git a/server/authToken/api.ts b/server/authToken/api.ts index 1c1b12594..9f31ea1db 100644 --- a/server/authToken/api.ts +++ b/server/authToken/api.ts @@ -6,9 +6,12 @@ import { contract } from 'utils/api/contract'; import { ensureUserIsCommunityAdmin } from 'utils/ensureUserIsCommunityAdmin'; import { AuthToken } from './model'; +import { generateAuthToken, hashAuthToken } from './tokenGenerator'; const s = initServer(); +const PUBLIC_ATTRIBUTES = ['id', 'userId', 'communityId', 'lastFour', 'expiresAt', 'createdAt']; + export const authTokenServer = s.router(contract.authToken, { create: async ({ body, req }) => { const community = await ensureUserIsCommunityAdmin({ @@ -35,15 +38,27 @@ export const authTokenServer = s.router(contract.authToken, { } })(); + const { raw, hashedToken, lastFour } = generateAuthToken(); + const authToken = await AuthToken.create({ userId: req.user.id, communityId: community.id, + hashedToken, + lastFour, expiresAt, }); + // The raw token is shown to the user exactly once; we never persist it. return { status: 201, - body: authToken.toJSON(), + body: { + id: authToken.id, + userId: authToken.userId, + communityId: authToken.communityId, + lastFour: authToken.lastFour, + expiresAt: authToken.expiresAt ? authToken.expiresAt.toISOString() : null, + token: raw, + }, }; }, getForUser: async ({ req }) => { @@ -53,7 +68,7 @@ export const authTokenServer = s.router(contract.authToken, { const tokens = await AuthToken.findAll({ where: { userId: req.user.id }, - attributes: { exclude: ['token'] }, + attributes: PUBLIC_ATTRIBUTES, include: [ { model: Community, @@ -78,7 +93,7 @@ export const authTokenServer = s.router(contract.authToken, { const tokens = await AuthToken.findAll({ where: { communityId: params.communityId }, - attributes: { exclude: ['token'] }, + attributes: PUBLIC_ATTRIBUTES, include: [ { model: User, @@ -146,7 +161,7 @@ export const authTokenServer = s.router(contract.authToken, { } const destroyed = await AuthToken.destroy({ - where: { token }, + where: { hashedToken: hashAuthToken(token) }, }); if (destroyed === 0) { diff --git a/server/authToken/model.ts b/server/authToken/model.ts index 6c1f760de..093b78c70 100644 --- a/server/authToken/model.ts +++ b/server/authToken/model.ts @@ -36,10 +36,14 @@ export class AuthToken extends Model< @Column(DataType.UUID) declare communityId: string; + @AllowNull(false) @Unique - @Default(DataType.UUIDV4) @Column(DataType.TEXT) - declare token: CreationOptional; + declare hashedToken: string; + + @AllowNull(false) + @Column(DataType.STRING(8)) + declare lastFour: string; @Column(DataType.DATE) declare expiresAt: Date | null; diff --git a/server/authToken/strategy.ts b/server/authToken/strategy.ts index bac48e5e4..b9d0e8ba7 100644 --- a/server/authToken/strategy.ts +++ b/server/authToken/strategy.ts @@ -8,6 +8,7 @@ import { ForbiddenError } from 'server/utils/errors'; import { ensureUserIsCommunityAdmin } from 'utils/ensureUserIsCommunityAdmin'; import { AuthToken, includeUserModel } from '../models'; +import { hashAuthToken } from './tokenGenerator'; export const bearerStrategy = () => { return new BearerStrategy( @@ -28,7 +29,7 @@ export const bearerStrategy = () => { } const authToken = await AuthToken.findOne({ - where: { token }, + where: { hashedToken: hashAuthToken(token) }, include: [ includeUserModel({ as: 'user', diff --git a/server/authToken/tokenGenerator.ts b/server/authToken/tokenGenerator.ts new file mode 100644 index 000000000..09d44c39c --- /dev/null +++ b/server/authToken/tokenGenerator.ts @@ -0,0 +1,21 @@ +import crypto from 'crypto'; + +export const AUTH_TOKEN_PREFIX = 'pubpub_pat_'; + +/** + * Auth tokens are opaque, 32-byte random strings. They carry ~256 bits of + * entropy, so a fast hash (SHA-256) is sufficient at rest — bcrypt/argon2 are + * only needed when the input is low-entropy (passwords). SHA-256 is also fast + * enough to run on every authenticated API request. + */ +export const hashAuthToken = (raw: string): string => + crypto.createHash('sha256').update(raw).digest('hex'); + +export const generateAuthToken = () => { + const raw = `${AUTH_TOKEN_PREFIX}${crypto.randomBytes(32).toString('base64url')}`; + return { + raw, + hashedToken: hashAuthToken(raw), + lastFour: raw.slice(-4), + }; +}; diff --git a/stubstub/modelize/builders.ts b/stubstub/modelize/builders.ts index f0cd835c0..6499352d8 100644 --- a/stubstub/modelize/builders.ts +++ b/stubstub/modelize/builders.ts @@ -14,12 +14,14 @@ import SHA3 from 'crypto-js/sha3'; import uuid from 'uuid'; import { getEmptyDoc } from 'client/components/Editor'; +import { generateAuthToken } from 'server/authToken/tokenGenerator'; import { createCollection } from 'server/collection/queries'; import { createCollectionPub } from 'server/collectionPub/queries'; import { createCommunity } from 'server/community/queries'; import { createDoc } from 'server/doc/queries'; import { ActivityItem, + AuthToken, Community, FacetBinding, Member, @@ -300,6 +302,21 @@ export const builders = { return ActivityItem.create(activityItem, { hooks: applyHooks }); }, + AuthToken: async ( + args: WithOptional, 'hashedToken' | 'lastFour'>, + ) => { + // Mirror the production minting flow: persist only the hash, but expose + // the raw token on the returned instance so tests can use it as a Bearer. + const { raw, hashedToken, lastFour } = generateAuthToken(); + const authToken = await AuthToken.create({ + ...args, + hashedToken, + lastFour, + }); + (authToken as any).token = raw; + return authToken; + }, + UserSubscription: ( args: WithOptional< CreationAttributes, diff --git a/tools/migrations/2026_05_21_hashAuthTokens.js b/tools/migrations/2026_05_21_hashAuthTokens.js new file mode 100644 index 000000000..baace7eeb --- /dev/null +++ b/tools/migrations/2026_05_21_hashAuthTokens.js @@ -0,0 +1,35 @@ +/** + * Replace the plaintext `token` column on AuthTokens with a SHA-256 hash plus a + * short `lastFour` display preview. Existing rows are dropped because the + * plaintext is the only thing that could be hashed forward, and the feature has + * not been in real use yet. + */ +export const up = async ({ Sequelize, sequelize }) => { + await sequelize.query('DELETE FROM "AuthTokens";'); + + await sequelize.queryInterface.removeColumn('AuthTokens', 'token'); + + await sequelize.queryInterface.addColumn('AuthTokens', 'hashedToken', { + type: Sequelize.TEXT, + allowNull: false, + unique: true, + }); + + await sequelize.queryInterface.addColumn('AuthTokens', 'lastFour', { + type: Sequelize.STRING(8), + allowNull: false, + }); +}; + +export const down = async ({ Sequelize, sequelize }) => { + await sequelize.query('DELETE FROM "AuthTokens";'); + + await sequelize.queryInterface.removeColumn('AuthTokens', 'hashedToken'); + await sequelize.queryInterface.removeColumn('AuthTokens', 'lastFour'); + + await sequelize.queryInterface.addColumn('AuthTokens', 'token', { + type: Sequelize.TEXT, + unique: true, + defaultValue: Sequelize.literal('gen_random_uuid()'), + }); +}; diff --git a/utils/api/contracts/authToken.ts b/utils/api/contracts/authToken.ts index 41a0041b9..b05ce5034 100644 --- a/utils/api/contracts/authToken.ts +++ b/utils/api/contracts/authToken.ts @@ -9,6 +9,7 @@ const authTokenMetadata = z.object({ id: z.string().uuid(), userId: z.string().uuid(), communityId: z.string().uuid(), + lastFour: z.string(), expiresAt: z.string().datetime().nullable(), createdAt: z.string().datetime(), }); @@ -47,7 +48,11 @@ export const authTokenRouter = { id: z.string().uuid(), userId: z.string().uuid(), communityId: z.string().uuid(), - token: z.string(), + lastFour: z.string(), + token: z.string().openapi({ + description: + 'The raw token. Shown exactly once at creation. It is hashed before being persisted and cannot be retrieved later.', + }), expiresAt: z.string().datetime().nullable(), }), },