diff --git a/apps/web/src/components/admin/users/invitations-view.tsx b/apps/web/src/components/admin/users/invitations-view.tsx index bac12bb62..3179ac1c5 100644 --- a/apps/web/src/components/admin/users/invitations-view.tsx +++ b/apps/web/src/components/admin/users/invitations-view.tsx @@ -27,7 +27,7 @@ const EMPTY_COPY: Record = { }, expired: { title: 'No expired invitations', - body: 'Pending invitations expire after 14 days. Expired ones show here so you can resend.', + body: 'Pending invitations expire after 30 days. Expired ones show here so you can resend.', }, all: { title: 'No invitations yet', diff --git a/apps/web/src/components/admin/users/invite-row.tsx b/apps/web/src/components/admin/users/invite-row.tsx index 0d676e8be..44327956a 100644 --- a/apps/web/src/components/admin/users/invite-row.tsx +++ b/apps/web/src/components/admin/users/invite-row.tsx @@ -62,12 +62,16 @@ interface InviteRowProps { * * The Revoke button uses an inline "confirm" two-step (avoids a dialog * for a per-row destructive action). Copy-link mints a fresh magic-link - * URL valid for ~10 minutes so admins can hand it to invitees out of - * band when SMTP isn't reachable. + * URL (valid for the invite's lifetime) so admins can hand it to invitees + * out of band when SMTP isn't reachable. */ export function InviteRow({ invite, onRevoke, onResend, revoking, resending }: InviteRowProps) { const [confirmRevoke, setConfirmRevoke] = useState(false) const [copyState, setCopyState] = useState<'idle' | 'copying' | 'copied' | 'error'>('idle') + // When the clipboard write is blocked (denied permission, unfocused doc), the + // link the server already minted (and revoked the prior one for) must still + // reach the admin — surface it here for manual copy rather than losing it. + const [fallbackLink, setFallbackLink] = useState(null) const sentDate = invite.lastSentAt ?? invite.createdAt const handleRevokeClick = () => { @@ -82,73 +86,103 @@ export function InviteRow({ invite, onRevoke, onResend, revoking, resending }: I const handleCopyLink = async () => { if (copyState === 'copying') return setCopyState('copying') + setFallbackLink(null) + + let link: string try { const result = await getPortalInviteLinkFn({ data: { inviteId: invite.id } }) - await navigator.clipboard.writeText(result.inviteLink) - setCopyState('copied') - setTimeout(() => setCopyState('idle'), 3000) + link = result.inviteLink } catch { setCopyState('error') setTimeout(() => setCopyState('idle'), 3000) + return + } + + // The link is already minted server-side (and the prior one revoked), so a + // clipboard failure must not lose it — fall back to showing it for manual copy. + try { + await navigator.clipboard.writeText(link) + setCopyState('copied') + setTimeout(() => setCopyState('idle'), 3000) + } catch { + setFallbackLink(link) + setCopyState('idle') } } return ( -
  • -
    -

    {invite.email}

    -

    Sent {formatInviteDate(sentDate)}

    +
  • +
    +
    +

    {invite.email}

    +

    Sent {formatInviteDate(sentDate)}

    +
    + + {invite.status === 'pending' && ( +
    + + + +
    + )}
    - - {invite.status === 'pending' && ( -
    - - - + {fallbackLink && ( +
    +

    + Couldn't copy automatically. Select and copy this link: +

    + e.currentTarget.select()} + className="w-full rounded border border-border/50 bg-background px-2 py-1 font-mono text-xs" + />
    )}
  • diff --git a/apps/web/src/lib/server/auth/__tests__/email-signin.test.ts b/apps/web/src/lib/server/auth/__tests__/email-signin.test.ts index e2e1b39ad..2e2aea51e 100644 --- a/apps/web/src/lib/server/auth/__tests__/email-signin.test.ts +++ b/apps/web/src/lib/server/auth/__tests__/email-signin.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const hoisted = vi.hoisted(() => ({ - mockMintMagicLinkUrl: vi.fn(async () => 'https://example.com/verify-magic-link?token=t'), + mockMintMagicLinkUrl: vi.fn(async () => ({ + url: 'https://example.com/verify-magic-link?token=t', + token: 't', + })), mockSendVerificationOTP: vi.fn(async () => undefined), mockSendMagicLinkEmail: vi.fn(async () => undefined), mockGetOTP: vi.fn(() => '123456'), diff --git a/apps/web/src/lib/server/auth/__tests__/magic-link-mint.test.ts b/apps/web/src/lib/server/auth/__tests__/magic-link-mint.test.ts index 5cbaabbaf..4d1cc3414 100644 --- a/apps/web/src/lib/server/auth/__tests__/magic-link-mint.test.ts +++ b/apps/web/src/lib/server/auth/__tests__/magic-link-mint.test.ts @@ -25,7 +25,20 @@ vi.mock('../index', async () => { } }) -const { mintMagicLinkUrl } = await import('../magic-link-mint') +const mockDeleteWhere = vi.fn().mockResolvedValue(undefined) +const mockDbDelete = vi.fn(() => ({ where: mockDeleteWhere })) +const mockEq = vi.fn((col: unknown, val: unknown) => ({ col, val })) +const mockVerificationTable = { identifier: 'verification.identifier' } + +vi.mock('@/lib/server/db', () => ({ + db: { delete: mockDbDelete }, + verification: mockVerificationTable, + eq: mockEq, + inArray: vi.fn((col: unknown, vals: unknown) => ({ op: 'inArray', col, vals })), +})) + +const { mintMagicLinkUrl, revokeMagicLinkToken, revokeMagicLinkTokens } = + await import('../magic-link-mint') beforeEach(() => { vi.clearAllMocks() @@ -76,12 +89,45 @@ describe('mintMagicLinkUrl', () => { }) it('returns a /verify-magic-link URL with the token embedded', async () => { - const url = await mintMagicLinkUrl({ + const { url, token } = await mintMagicLinkUrl({ email: 'a@b.com', callbackPath: '/admin', portalUrl: 'https://acme.test', }) expect(url).toMatch(/^https:\/\/acme\.test\/verify-magic-link\?token=/) expect(url).toContain('callbackURL=') + // The returned token is the verification identifier and the one in the URL. + expect(token).toBe(mockCreateVerificationValue.mock.calls[0][0].identifier) + expect(url).toContain(`token=${token}`) + }) +}) + +describe('revokeMagicLinkToken', () => { + it('deletes the verification row whose identifier is the token', async () => { + await revokeMagicLinkToken('tok_abc') + + expect(mockDbDelete).toHaveBeenCalledTimes(1) + expect(mockDbDelete).toHaveBeenCalledWith(mockVerificationTable) + expect(mockEq).toHaveBeenCalledWith(mockVerificationTable.identifier, 'tok_abc') + expect(mockDeleteWhere).toHaveBeenCalled() + }) + + it('is a no-op when the token is null (invite minted before tracking)', async () => { + await revokeMagicLinkToken(null) + expect(mockDbDelete).not.toHaveBeenCalled() + }) +}) + +describe('revokeMagicLinkTokens', () => { + it('deletes the verification rows for the whole set', async () => { + await revokeMagicLinkTokens(['tok_a', 'tok_b']) + expect(mockDbDelete).toHaveBeenCalledTimes(1) + expect(mockDbDelete).toHaveBeenCalledWith(mockVerificationTable) + expect(mockDeleteWhere).toHaveBeenCalled() + }) + + it('is a no-op for an empty set', async () => { + await revokeMagicLinkTokens([]) + expect(mockDbDelete).not.toHaveBeenCalled() }) }) diff --git a/apps/web/src/lib/server/auth/email-signin.ts b/apps/web/src/lib/server/auth/email-signin.ts index ea1c0faae..03422771d 100644 --- a/apps/web/src/lib/server/auth/email-signin.ts +++ b/apps/web/src/lib/server/auth/email-signin.ts @@ -30,7 +30,7 @@ export async function requestEmailSignin(opts: { // back to /auth/login (the public signup/login screen). const errorCallbackPath = opts.callbackURL.startsWith('/admin') ? '/admin/login' : '/auth/login' - const [signInUrl, , settings] = await Promise.all([ + const [{ url: signInUrl }, , settings] = await Promise.all([ mintMagicLinkUrl({ email: opts.email, callbackPath: opts.callbackURL, diff --git a/apps/web/src/lib/server/auth/magic-link-mint.ts b/apps/web/src/lib/server/auth/magic-link-mint.ts index cd9f9f623..ef7846ba8 100644 --- a/apps/web/src/lib/server/auth/magic-link-mint.ts +++ b/apps/web/src/lib/server/auth/magic-link-mint.ts @@ -1,4 +1,5 @@ import { generateRandomString } from 'better-auth/crypto' +import { db, verification, eq, inArray } from '@/lib/server/db' import { getAuth } from './index' interface MintOptions { @@ -45,7 +46,7 @@ export function buildVerifyMagicLinkUrl(opts: { * for internal token-mint. Token format mirrors BA's magic-link * plugin so its `/magic-link/verify` endpoint reads our row. */ -export async function mintMagicLinkUrl(opts: MintOptions): Promise { +export async function mintMagicLinkUrl(opts: MintOptions): Promise<{ url: string; token: string }> { const auth = await getAuth() const token = generateRandomString(32, 'a-z', 'A-Z') const expiresInSeconds = opts.expiresInSeconds ?? DEFAULT_EXPIRES_IN_SECONDS @@ -59,10 +60,37 @@ export async function mintMagicLinkUrl(opts: MintOptions): Promise { expiresAt: new Date(Date.now() + expiresInSeconds * 1000), }) - return buildVerifyMagicLinkUrl({ + const url = buildVerifyMagicLinkUrl({ origin: opts.portalUrl, token, callbackPath: opts.callbackPath, errorCallbackPath: opts.errorCallbackPath, }) + // `token` is the verification-row identifier. Callers that need to be able + // to invalidate the link later (invitations) persist it; sign-in callers + // ignore it. + return { url, token } +} + +/** + * Delete the verification row backing a previously-minted magic link, so the + * link can no longer be verified. Used by invitations when they're cancelled + * or re-issued. No-op when `token` is null/undefined (e.g. an invite minted + * before the token was tracked, or a path that never stored one). + */ +export async function revokeMagicLinkToken(token: string | null | undefined): Promise { + if (!token) return + // `token` is the row's `identifier` — see `createVerificationValue` in + // mintMagicLinkUrl above, which stores the raw token as the identifier. + await db.delete(verification).where(eq(verification.identifier, token)) +} + +/** + * Bulk-revoke a set of magic-link tokens by deleting their verification rows. + * Used by invite cancellation to invalidate every link the invite ever minted. + * No-op on an empty set; already-consumed/expired tokens simply match no rows. + */ +export async function revokeMagicLinkTokens(tokens: string[]): Promise { + if (tokens.length === 0) return + await db.delete(verification).where(inArray(verification.identifier, tokens)) } diff --git a/apps/web/src/lib/server/functions/__tests__/invitation-magic-link.test.ts b/apps/web/src/lib/server/functions/__tests__/invitation-magic-link.test.ts new file mode 100644 index 000000000..11a2cc074 --- /dev/null +++ b/apps/web/src/lib/server/functions/__tests__/invitation-magic-link.test.ts @@ -0,0 +1,102 @@ +import type { InviteId } from '@quackback/ids' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockMintMagicLinkUrl = vi.fn() +const mockRevokeMagicLinkToken = vi.fn() + +vi.mock('@/lib/server/auth/magic-link-mint', () => ({ + mintMagicLinkUrl: mockMintMagicLinkUrl, + revokeMagicLinkToken: mockRevokeMagicLinkToken, +})) + +// Minimal db update-chain mock for rotateInviteMagicLinkToken's compare-and-swap. +const mockReturning = vi.fn() +const mockWhere = vi.fn(() => ({ returning: mockReturning })) +const mockSet = vi.fn(() => ({ where: mockWhere })) +const mockUpdate = vi.fn(() => ({ set: mockSet })) + +vi.mock('@/lib/server/db', () => ({ + db: { update: mockUpdate }, + invitation: { + id: 'invitation.id', + status: 'invitation.status', + magicLinkTokens: 'invitation.magicLinkTokens', + }, + eq: vi.fn((col: unknown, val: unknown) => ({ op: 'eq', col, val })), + and: vi.fn((...parts: unknown[]) => ({ op: 'and', parts })), + sql: vi.fn((parts: TemplateStringsArray) => ({ op: 'sql', raw: parts.raw[0] })), +})) + +const { generateInvitationMagicLink, appendInviteMagicLinkToken, removeInviteMagicLinkToken } = + await import('../invitation-magic-link') + +beforeEach(() => { + vi.clearAllMocks() + mockMintMagicLinkUrl.mockResolvedValue({ + url: 'https://acme.test/verify-magic-link?token=abc', + token: 'tok_team', + }) + mockRevokeMagicLinkToken.mockResolvedValue(undefined) + // Default: the status-pinned append matched one row. + mockReturning.mockResolvedValue([{ id: 'invite_1' }]) +}) + +describe('generateInvitationMagicLink', () => { + it('mints a link that lives as long as the invitation record (30 days), not the 10-minute sign-in default', async () => { + await generateInvitationMagicLink( + 'invitee@example.com', + '/complete-signup/invite_1', + 'https://acme.test' + ) + + expect(mockMintMagicLinkUrl).toHaveBeenCalledTimes(1) + expect(mockMintMagicLinkUrl.mock.calls[0][0]).toMatchObject({ + email: 'invitee@example.com', + callbackPath: '/complete-signup/invite_1', + portalUrl: 'https://acme.test', + expiresInSeconds: 30 * 24 * 60 * 60, + }) + }) + + it('returns the minted url and token so the caller can persist the token for revocation', async () => { + const result = await generateInvitationMagicLink( + 'invitee@example.com', + '/complete-signup/invite_1', + 'https://acme.test' + ) + expect(result).toEqual({ + url: 'https://acme.test/verify-magic-link?token=abc', + token: 'tok_team', + }) + }) +}) + +describe('appendInviteMagicLinkToken', () => { + it('appends and returns true while the invite is pending', async () => { + mockReturning.mockResolvedValue([{ id: 'invite_1' }]) // status-pinned UPDATE matched + + const ok = await appendInviteMagicLinkToken('invite_1' as InviteId, 'tok_new') + + expect(ok).toBe(true) + expect(mockSet).toHaveBeenCalledTimes(1) // SET magic_link_tokens = array_append(...) + // Appending is a pure add — revocation is the caller's responsibility. + expect(mockRevokeMagicLinkToken).not.toHaveBeenCalled() + }) + + it('returns false without throwing when the invite is no longer pending', async () => { + mockReturning.mockResolvedValue([]) // UPDATE matched nothing (canceled/accepted/expired) + + const ok = await appendInviteMagicLinkToken('invite_1' as InviteId, 'tok_new') + + expect(ok).toBe(false) + }) +}) + +describe('removeInviteMagicLinkToken', () => { + it('drops the token from the set and revokes it', async () => { + await removeInviteMagicLinkToken('invite_1' as InviteId, 'tok_x') + + expect(mockSet).toHaveBeenCalledTimes(1) // SET magic_link_tokens = array_remove(...) + expect(mockRevokeMagicLinkToken).toHaveBeenCalledWith('tok_x') + }) +}) diff --git a/apps/web/src/lib/server/functions/__tests__/portal-invites-accept.test.ts b/apps/web/src/lib/server/functions/__tests__/portal-invites-accept.test.ts index 318cd94a9..9fe12d233 100644 --- a/apps/web/src/lib/server/functions/__tests__/portal-invites-accept.test.ts +++ b/apps/web/src/lib/server/functions/__tests__/portal-invites-accept.test.ts @@ -48,7 +48,10 @@ const hoisted = vi.hoisted(() => ({ // The accept handler now uses RETURNING to detect zero-row writes // (concurrent cancel / expiry / accept race). Tests can override this // per-case to simulate the race. - mockDbUpdateReturning: vi.fn(() => Promise.resolve([{ id: 'invite_1' }])), + mockDbUpdateReturning: vi.fn( + (): Promise> => + Promise.resolve([{ id: 'invite_1' }]) + ), mockDbQuery: { invitation: { findFirst: vi.fn() }, principal: { findFirst: vi.fn() }, @@ -57,6 +60,7 @@ const hoisted = vi.hoisted(() => ({ mockDbInsert: vi.fn(), mockSendPortalInviteEmail: vi.fn(), mockMintMagicLinkUrl: vi.fn(), + mockRevokeMagicLinkTokens: vi.fn(), mockGetEmailSafeUrl: vi.fn(), mockGetBaseUrl: vi.fn(), mockGenerateId: vi.fn(), @@ -126,6 +130,7 @@ vi.mock('@quackback/ids', () => ({ vi.mock('@/lib/server/auth/magic-link-mint', () => ({ mintMagicLinkUrl: hoisted.mockMintMagicLinkUrl, + revokeMagicLinkTokens: hoisted.mockRevokeMagicLinkTokens, })) vi.mock('@/lib/server/storage/s3', () => ({ @@ -403,6 +408,18 @@ describe('acceptPortalInviteFn — happy path', () => { expect(auditCall).toBeDefined() }) + it('revokes the invite token set on accept so sibling links can no longer sign in', async () => { + // An invite resent/copied has more than one live token; accepting via one + // must kill the rest. + hoisted.mockDbUpdateReturning.mockResolvedValue([ + { id: 'invite_1', magicLinkTokens: ['tok_used', 'tok_sibling'] }, + ]) + + await acceptHandler({ data: { inviteId: 'invite_1' } }) + + expect(hoisted.mockRevokeMagicLinkTokens).toHaveBeenCalledWith(['tok_used', 'tok_sibling']) + }) + it('includes the invite email in the audit after-value', async () => { await acceptHandler({ data: { inviteId: 'invite_1' } }) diff --git a/apps/web/src/lib/server/functions/__tests__/portal-invites.test.ts b/apps/web/src/lib/server/functions/__tests__/portal-invites.test.ts index 8c94360d6..1e12c60b5 100644 --- a/apps/web/src/lib/server/functions/__tests__/portal-invites.test.ts +++ b/apps/web/src/lib/server/functions/__tests__/portal-invites.test.ts @@ -54,6 +54,8 @@ const hoisted = vi.hoisted(() => ({ }, mockSendPortalInviteEmail: vi.fn(), mockMintMagicLinkUrl: vi.fn(), + mockRevokeMagicLinkToken: vi.fn(), + mockRevokeMagicLinkTokens: vi.fn(), mockGetEmailSafeUrl: vi.fn(), mockGetBaseUrl: vi.fn(), mockGenerateId: vi.fn(), @@ -104,6 +106,7 @@ vi.mock('@/lib/server/db', () => { and: vi.fn((...args: unknown[]) => args), or: vi.fn((...args: unknown[]) => args), gt: vi.fn((col, val) => ({ col, val })), + isNull: vi.fn((col) => ({ col, isNull: true })), sql: vi.fn((parts: TemplateStringsArray) => parts.raw[0]), } }) @@ -123,6 +126,8 @@ vi.mock('@quackback/ids', () => ({ // Dynamic imports used inside handlers vi.mock('@/lib/server/auth/magic-link-mint', () => ({ mintMagicLinkUrl: hoisted.mockMintMagicLinkUrl, + revokeMagicLinkToken: hoisted.mockRevokeMagicLinkToken, + revokeMagicLinkTokens: hoisted.mockRevokeMagicLinkTokens, })) vi.mock('@/lib/server/storage/s3', () => ({ @@ -169,9 +174,12 @@ beforeEach(async () => { // Sensible defaults hoisted.mockRequireAuth.mockResolvedValue(ADMIN_AUTH) hoisted.mockGetBaseUrl.mockReturnValue('https://acme.example.com') - hoisted.mockMintMagicLinkUrl.mockResolvedValue( - 'https://acme.example.com/verify-magic-link?token=abc' - ) + hoisted.mockMintMagicLinkUrl.mockResolvedValue({ + url: 'https://acme.example.com/verify-magic-link?token=abc', + token: 'tok_new', + }) + hoisted.mockRevokeMagicLinkToken.mockResolvedValue(undefined) + hoisted.mockRevokeMagicLinkTokens.mockResolvedValue(undefined) hoisted.mockGetEmailSafeUrl.mockReturnValue(null) hoisted.mockSendPortalInviteEmail.mockResolvedValue({ sent: false }) hoisted.mockGenerateId.mockReturnValue('invite_test') @@ -280,6 +288,20 @@ describe('sendPortalInviteFn — success (single)', () => { ) }) + it('mints a link that lives as long as the invite (30 days), not the 10-minute sign-in default', async () => { + await sendHandler({ data: { emails: ['invitee@example.com'] } }) + + const opts = hoisted.mockMintMagicLinkUrl.mock.calls[0][0] as { expiresInSeconds?: number } + expect(opts.expiresInSeconds).toBe(30 * 24 * 60 * 60) // 30-day invite lifetime, not the 10-min sign-in default + }) + + it('seeds the invite token set with the minted token so cancel can revoke it', async () => { + await sendHandler({ data: { emails: ['invitee@example.com'] } }) + + const insertCall = hoisted.mockDbInsert.mock.calls[0][0] as Record + expect(insertCall.magicLinkTokens).toEqual(['tok_new']) + }) + it('records a portal.invite.sent audit event', async () => { await sendHandler({ data: { emails: ['invitee@example.com'] } }) @@ -356,9 +378,10 @@ describe('getPortalInviteLinkFn', () => { email: 'user@example.com', expiresAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), }) - hoisted.mockMintMagicLinkUrl.mockResolvedValue( - 'https://acme.example.com/verify-magic-link?token=xyz' - ) + hoisted.mockMintMagicLinkUrl.mockResolvedValue({ + url: 'https://acme.example.com/verify-magic-link?token=xyz', + token: 'tok_xyz', + }) const result = await getLinkHandler({ data: { inviteId: 'invite_abc' } }) const r = result as { inviteLink: string; expiresAt: Date } @@ -366,6 +389,45 @@ describe('getPortalInviteLinkFn', () => { expect(r.expiresAt).toBeInstanceOf(Date) }) + it("caps the copy-link token at the invite's remaining lifetime so it can't outlive the invite", async () => { + // Invite was created earlier and expires in ~3 days; copy-link must mint a + // token that dies with the invite, not a fresh full-lifetime one. + const threeDaysSeconds = 3 * 24 * 60 * 60 + hoisted.mockDbQuery.invitation.findFirst.mockResolvedValue({ + id: 'invite_abc', + kind: 'portal', + status: 'pending', + email: 'user@example.com', + expiresAt: new Date(Date.now() + threeDaysSeconds * 1000), + magicLinkToken: 'tok_old', + }) + + await getLinkHandler({ data: { inviteId: 'invite_abc' } }) + + const opts = hoisted.mockMintMagicLinkUrl.mock.calls[0][0] as { expiresInSeconds?: number } + // Allow a few seconds of slack for execution time. + expect(opts.expiresInSeconds).toBeGreaterThan(threeDaysSeconds - 60) + expect(opts.expiresInSeconds).toBeLessThanOrEqual(threeDaysSeconds) + }) + + it('does not revoke other outstanding links when minting a copy (additive)', async () => { + hoisted.mockDbQuery.invitation.findFirst.mockResolvedValue({ + id: 'invite_abc', + kind: 'portal', + status: 'pending', + email: 'user@example.com', + expiresAt: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), + magicLinkTokens: ['tok_old'], + }) + + await getLinkHandler({ data: { inviteId: 'invite_abc' } }) + + // Copy mints a fresh token (additive) and never revokes existing links. + expect(hoisted.mockMintMagicLinkUrl).toHaveBeenCalled() + expect(hoisted.mockRevokeMagicLinkToken).not.toHaveBeenCalled() + expect(hoisted.mockRevokeMagicLinkTokens).not.toHaveBeenCalled() + }) + it('rejects for a non-portal invite kind (returns null from DB)', async () => { // findFirst returns null because kind='team' is filtered out in the WHERE clause hoisted.mockDbQuery.invitation.findFirst.mockResolvedValue(null) @@ -481,6 +543,24 @@ describe('cancelPortalInviteFn — success', () => { ) expect(auditCall).toBeDefined() }) + + it('revokes the whole token set returned by the cancel UPDATE', async () => { + hoisted.mockDbQuery.invitation.findFirst.mockResolvedValue({ + id: 'invite_1', + kind: 'portal', + status: 'pending', + email: 'user@example.com', + magicLinkTokens: ['tok_a', 'tok_b'], + }) + // The cancel UPDATE returns the full token set atomically at the flip. + hoisted.mockDbReturning.mockResolvedValue([ + { id: 'invite_1', magicLinkTokens: ['tok_a', 'tok_b', 'tok_c'] }, + ]) + + await cancelHandler({ data: { inviteId: 'invite_1' } }) + + expect(hoisted.mockRevokeMagicLinkTokens).toHaveBeenCalledWith(['tok_a', 'tok_b', 'tok_c']) + }) }) // --------------------------------------------------------------------------- @@ -529,6 +609,49 @@ describe('resendPortalInviteFn — success', () => { expect((result as { inviteId: string }).inviteId).toBe('invite_1') }) + it('appends the new token without revoking prior links (resend is additive)', async () => { + const futureDate = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) + hoisted.mockDbQuery.invitation.findFirst.mockResolvedValue({ + id: 'invite_1', + kind: 'portal', + status: 'pending', + email: 'user@example.com', + expiresAt: futureDate, + magicLinkTokens: ['tok_old'], + }) + + await resendHandler({ data: { inviteId: 'invite_1' } }) + + // The token set is updated (array_append) and the prior token is NOT revoked + // — both the old and new links stay valid until accept/cancel/expiry. + const appended = hoisted.mockDbSet.mock.calls.some( + (c) => 'magicLinkTokens' in (c[0] as Record) + ) + expect(appended).toBe(true) + expect(hoisted.mockRevokeMagicLinkToken).not.toHaveBeenCalled() + expect(hoisted.mockSendPortalInviteEmail).toHaveBeenCalled() + }) + + it('drops only the new token when the email send fails (prior links untouched)', async () => { + const futureDate = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) + hoisted.mockDbQuery.invitation.findFirst.mockResolvedValue({ + id: 'invite_1', + kind: 'portal', + status: 'pending', + email: 'user@example.com', + expiresAt: futureDate, + magicLinkTokens: ['tok_old'], + }) + hoisted.mockSendPortalInviteEmail.mockRejectedValue(new Error('smtp down')) + + await expect(resendHandler({ data: { inviteId: 'invite_1' } })).rejects.toThrow('smtp down') + + // removeInviteMagicLinkToken revokes the undelivered new token; the prior + // token is never revoked, so the invitee's existing link still works. + expect(hoisted.mockRevokeMagicLinkToken).toHaveBeenCalledWith('tok_new') + expect(hoisted.mockRevokeMagicLinkToken).not.toHaveBeenCalledWith('tok_old') + }) + it('emits portal.invite.resent (not portal.invite.sent) on resend', async () => { const futureDate = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) hoisted.mockDbQuery.invitation.findFirst.mockResolvedValue({ @@ -834,7 +957,7 @@ describe('cancelPortalInviteFn — race guard', () => { // --------------------------------------------------------------------------- describe('resendPortalInviteFn — extends expiresAt', () => { - it('sets expiresAt to ~14 days from now on resend', async () => { + it('sets expiresAt to ~30 days from now on resend', async () => { const nearExpiry = new Date(Date.now() + 2 * 60 * 60 * 1000) // 2 hours left hoisted.mockDbQuery.invitation.findFirst.mockResolvedValue({ id: 'invite_1', @@ -854,11 +977,11 @@ describe('resendPortalInviteFn — extends expiresAt', () => { expiresAt: Date } - // expiresAt must be ~14 days from the time of resend, not the old near-expiry. - const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000 + // expiresAt must be ~30 days from the time of resend, not the old near-expiry. + const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000 const expiresAtMs = setPayload.expiresAt.getTime() - expect(expiresAtMs).toBeGreaterThanOrEqual(before + FOURTEEN_DAYS_MS - 5000) - expect(expiresAtMs).toBeLessThanOrEqual(after + FOURTEEN_DAYS_MS + 5000) + expect(expiresAtMs).toBeGreaterThanOrEqual(before + THIRTY_DAYS_MS - 5000) + expect(expiresAtMs).toBeLessThanOrEqual(after + THIRTY_DAYS_MS + 5000) }) it('also updates lastSentAt on resend', async () => { diff --git a/apps/web/src/lib/server/functions/__tests__/recovery-codes-consume.test.ts b/apps/web/src/lib/server/functions/__tests__/recovery-codes-consume.test.ts index 33add0446..b6645ae15 100644 --- a/apps/web/src/lib/server/functions/__tests__/recovery-codes-consume.test.ts +++ b/apps/web/src/lib/server/functions/__tests__/recovery-codes-consume.test.ts @@ -132,7 +132,10 @@ beforeEach(() => { hoisted.findUser.mockResolvedValue(null) hoisted.findCodes.mockResolvedValue([]) hoisted.verifyRecoveryCode.mockResolvedValue(false) - hoisted.mintMagicLinkUrl.mockResolvedValue('https://acme.quackback.io/verify-magic-link?token=t') + hoisted.mintMagicLinkUrl.mockResolvedValue({ + url: 'https://acme.quackback.io/verify-magic-link?token=t', + token: 't', + }) }) await import('../recovery-codes-consume') diff --git a/apps/web/src/lib/server/functions/admin.ts b/apps/web/src/lib/server/functions/admin.ts index f1c40fb3b..fd2a83e0e 100644 --- a/apps/web/src/lib/server/functions/admin.ts +++ b/apps/web/src/lib/server/functions/admin.ts @@ -56,9 +56,12 @@ import { import type { UserAttributeId } from '@quackback/ids' import { sendInvitationEmail } from '@quackback/email' import { getBaseUrl } from '@/lib/server/config' - -/** Invitation expiry duration — 7 days in milliseconds */ -const INVITATION_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 +import { + INVITATION_EXPIRY_MS, + generateInvitationMagicLink, + appendInviteMagicLinkToken, + removeInviteMagicLinkToken, +} from './invitation-magic-link' /** * Server functions for admin data fetching. @@ -903,30 +906,6 @@ const invitationByIdSchema = z.object({ export type SendInvitationInput = z.infer export type InvitationByIdInput = z.infer -/** - * Generate a magic link for invitation authentication. - * Uses Better Auth's API to generate the token and stores it for later URL construction. - * - * @param email - The invitee's email address - * @param callbackPath - Relative path to redirect to after authentication (e.g., /complete-signup/{id}) - * @param portalUrl - The base portal URL (workspace domain) - * @returns The magic link URL with the correct workspace domain - */ -async function generateInvitationMagicLink( - email: string, - callbackPath: string, - portalUrl: string -): Promise { - console.log( - `[fn:admin] generateInvitationMagicLink: email=${email}, callbackPath=${callbackPath}, portalUrl=${portalUrl}` - ) - const { mintMagicLinkUrl } = await import('@/lib/server/auth/magic-link-mint') - // Invitations reuse the same path for success + error so an - // expired/consumed link sends the recipient back to the same - // invitation page (with its own expired-state copy). - return mintMagicLinkUrl({ email, callbackPath, portalUrl }) -} - /** * Send a team invitation */ @@ -977,6 +956,17 @@ export const sendInvitationFn = createServerFn({ method: 'POST' }) const expiresAt = new Date(Date.now() + INVITATION_EXPIRY_MS) const now = new Date() + // Mint the magic link before the insert so the row records its token in + // its token set (cancel revokes every token in the set). invitationId is + // fixed above, so the callback path is already known. + const portalUrl = getBaseUrl() + const callbackURL = `/complete-signup/${invitationId}` + const { url: inviteLink, token: magicLinkToken } = await generateInvitationMagicLink( + email, + callbackURL, + portalUrl + ) + await db.insert(invitation).values({ id: invitationId, email, @@ -987,13 +977,9 @@ export const sendInvitationFn = createServerFn({ method: 'POST' }) lastSentAt: now, inviterId: auth.user.id, createdAt: now, + magicLinkTokens: [magicLinkToken], }) - // Generate magic link for one-click authentication - const portalUrl = getBaseUrl() - const callbackURL = `/complete-signup/${invitationId}` - const inviteLink = await generateInvitationMagicLink(email, callbackURL, portalUrl) - const { getEmailSafeUrl } = await import('@/lib/server/storage/s3') const logoUrl = getEmailSafeUrl(auth.settings.logoKey) ?? undefined const result = await sendInvitationEmail({ @@ -1059,12 +1045,19 @@ export const cancelInvitationFn = createServerFn({ method: 'POST' }) eq(invitation.status, 'pending') ) ) - .returning({ id: invitation.id }) + .returning({ id: invitation.id, magicLinkTokens: invitation.magicLinkTokens }) if (cancelled.length === 0) { throw new Error('Invitation is no longer pending — refresh and try again') } + // Invalidate every link this invite ever minted, so a cancelled invite + // can't sign anyone in. Revoking the full set (returned atomically by the + // status flip) closes the resend/copy/worker-restart windows where a + // single rotating pointer could leave a token live but untracked. + const { revokeMagicLinkTokens } = await import('@/lib/server/auth/magic-link-mint') + await revokeMagicLinkTokens(cancelled[0].magicLinkTokens) + console.log(`[fn:admin] cancelInvitationFn: canceled`) return { invitationId } } catch (error) { @@ -1123,25 +1116,42 @@ export const resendInvitationFn = createServerFn({ method: 'POST' }) throw new Error('Invitation is no longer pending — refresh and try again') } - // Generate new magic link for one-click authentication + // Generate a new magic link and add it to the invite's token set. Prior + // tokens are left intact (resend is additive, not destructive) — both the + // old and new links work until the invite is accepted, cancelled, or + // expires. The token is recorded the moment it's minted, so even if the + // send below fails or the worker restarts, cancellation still revokes it. const portalUrl = getBaseUrl() const callbackURL = `/complete-signup/${invitationId}` - const inviteLink = await generateInvitationMagicLink( + const { url: inviteLink, token: magicLinkToken } = await generateInvitationMagicLink( invitationRecord.email, callbackURL, portalUrl ) + const { revokeMagicLinkToken } = await import('@/lib/server/auth/magic-link-mint') + if (!(await appendInviteMagicLinkToken(invitationId, magicLinkToken))) { + await revokeMagicLinkToken(magicLinkToken) // invite no longer pending; drop it + throw new Error('Invitation is no longer pending — refresh and try again') + } + const { getEmailSafeUrl } = await import('@/lib/server/storage/s3') const logoUrl = getEmailSafeUrl(auth.settings.logoKey) ?? undefined - const result = await sendInvitationEmail({ - to: invitationRecord.email, - invitedByName: auth.user.name, - inviteeName: invitationRecord.name || undefined, - workspaceName: auth.settings.name, - inviteLink, - logoUrl, - }) + let result: Awaited> + try { + result = await sendInvitationEmail({ + to: invitationRecord.email, + invitedByName: auth.user.name, + inviteeName: invitationRecord.name || undefined, + workspaceName: auth.settings.name, + inviteLink, + logoUrl, + }) + } catch (sendError) { + // The new link never went out — drop it from the set and revoke it. + await removeInviteMagicLinkToken(invitationId, magicLinkToken) + throw sendError + } console.log( `[fn:admin] resendInvitationFn: ${result.sent ? 'resent' : 'regenerated (email not configured)'}` diff --git a/apps/web/src/lib/server/functions/invitation-magic-link.ts b/apps/web/src/lib/server/functions/invitation-magic-link.ts new file mode 100644 index 000000000..ba62871de --- /dev/null +++ b/apps/web/src/lib/server/functions/invitation-magic-link.ts @@ -0,0 +1,78 @@ +/** + * Team-invitation magic link — the team counterpart to portal-invites.ts. + * Split out of admin.ts (and its large import surface) so the link's + * lifetime can be reasoned about and tested in isolation. Also hosts the + * shared token-rotation helper used by both team and portal invite paths. + */ +import type { InviteId } from '@quackback/ids' + +/** + * Team invitation lifetime — 30 days. Source of truth for both the + * invitation row's `expiresAt` and the emailed magic-link token TTL. + * + * The token deliberately lives this long rather than falling back to + * `mintMagicLinkUrl`'s 10-minute sign-in default: an invite is emailed and + * opened asynchronously — often days later — and the invitation row still + * governs long-term access either way. + */ +export const INVITATION_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000 + +/** + * Mint the invite's one-click sign-in link (lives for INVITATION_EXPIRY_MS). + * Returns both the URL and its `token` — persist the token on the invite row + * so {@link revokeMagicLinkToken} can invalidate the link on cancel/re-send. + */ +export async function generateInvitationMagicLink( + email: string, + callbackPath: string, + portalUrl: string +): Promise<{ url: string; token: string }> { + console.log( + `[fn:invite] generateInvitationMagicLink: email=${email}, callbackPath=${callbackPath}, portalUrl=${portalUrl}` + ) + const { mintMagicLinkUrl } = await import('@/lib/server/auth/magic-link-mint') + return mintMagicLinkUrl({ + email, + callbackPath, + portalUrl, + expiresInSeconds: INVITATION_EXPIRY_MS / 1000, + }) +} + +/** + * Append a freshly-minted token to the invite's token set, but only while the + * invite is still `pending`. Returns true if appended, false if the invite is + * no longer pending (canceled / accepted / expired) — in which case the caller + * should revoke the token it just minted rather than leave it live. + * + * Appending (rather than replacing) means a token is recorded the instant it's + * minted, so it can never be live-but-untracked: even if the email send then + * fails or the worker restarts, cancellation still revokes it via the set. + */ +export async function appendInviteMagicLinkToken( + inviteId: InviteId, + token: string +): Promise { + const { db, invitation, eq, and, sql } = await import('@/lib/server/db') + const updated = await db + .update(invitation) + .set({ magicLinkTokens: sql`array_append(${invitation.magicLinkTokens}, ${token})` }) + .where(and(eq(invitation.id, inviteId), eq(invitation.status, 'pending'))) + .returning({ id: invitation.id }) + return updated.length > 0 +} + +/** + * Drop a token from the invite's set and revoke its verification row. Used to + * discard a token whose link was never delivered (e.g. the email send threw), + * keeping the set to links that actually went out. + */ +export async function removeInviteMagicLinkToken(inviteId: InviteId, token: string): Promise { + const { db, invitation, eq, sql } = await import('@/lib/server/db') + const { revokeMagicLinkToken } = await import('@/lib/server/auth/magic-link-mint') + await db + .update(invitation) + .set({ magicLinkTokens: sql`array_remove(${invitation.magicLinkTokens}, ${token})` }) + .where(eq(invitation.id, inviteId)) + await revokeMagicLinkToken(token) +} diff --git a/apps/web/src/lib/server/functions/invitations.ts b/apps/web/src/lib/server/functions/invitations.ts index 48e583656..ab917f7cd 100644 --- a/apps/web/src/lib/server/functions/invitations.ts +++ b/apps/web/src/lib/server/functions/invitations.ts @@ -237,6 +237,20 @@ export const acceptInvitationFn = createServerFn({ method: 'POST' }) await db.update(user).set({ name: displayName }).where(eq(user.id, userId)) } + // The invite is accepted — revoke every token in its set so no other + // emailed/copied link for this invite can still sign anyone in. (The link + // just used was already consumed by the magic-link verify; siblings from + // resends/copies would otherwise stay live until their 30-day expiry.) + // Best-effort: the membership is already committed, so a cleanup failure + // here must NOT hit the outer catch and roll the accept back to pending — + // log it and move on (the stray tokens still expire with the invite). + try { + const { revokeMagicLinkTokens } = await import('@/lib/server/auth/magic-link-mint') + await revokeMagicLinkTokens(claimed.magicLinkTokens) + } catch (revokeError) { + console.error(`[fn:invitations] ⚠️ acceptInvitationFn: token revoke failed:`, revokeError) + } + console.log(`[fn:invitations] acceptInvitationFn: accepted`) return { invitationId: invitationId as InviteId } } catch (error) { diff --git a/apps/web/src/lib/server/functions/portal-access.ts b/apps/web/src/lib/server/functions/portal-access.ts index 62c9fa158..0871348d0 100644 --- a/apps/web/src/lib/server/functions/portal-access.ts +++ b/apps/web/src/lib/server/functions/portal-access.ts @@ -145,7 +145,7 @@ export const resolvePortalAccessForRequest = createServerOnlyFn( eq(invitation.kind, 'portal'), // Accepted invites are permanent until revoked — expiry only governs // pending invites. Dropping the expires_at check here prevents a - // user losing access 14 days after the invite was sent. + // user losing access once the invite's pending window passes. eq(invitation.status, 'accepted') ), columns: { id: true }, diff --git a/apps/web/src/lib/server/functions/portal-invites.ts b/apps/web/src/lib/server/functions/portal-invites.ts index b46914f46..72610d4fd 100644 --- a/apps/web/src/lib/server/functions/portal-invites.ts +++ b/apps/web/src/lib/server/functions/portal-invites.ts @@ -16,17 +16,25 @@ import type { InviteId, UserId } from '@quackback/ids' import { generateId } from '@quackback/ids' import { db, invitation, principal, user, eq, and, gt, or, sql } from '@/lib/server/db' import { requireAuth } from './auth-helpers' +import { appendInviteMagicLinkToken, removeInviteMagicLinkToken } from './invitation-magic-link' import { actorFromAuth, recordAuditEvent } from '@/lib/server/audit/log' import { getBaseUrl } from '@/lib/server/config' import { sendPortalInviteEmail } from '@quackback/email' import { getSession } from '@/lib/server/auth/session' import { safeEmail } from '@/lib/shared/utils/string' -/** Portal invite lifetime — 14 days. */ -const PORTAL_INVITE_EXPIRY_MS = 14 * 24 * 60 * 60 * 1000 +/** Portal invite lifetime — 30 days. */ +const PORTAL_INVITE_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000 -/** Magic-link token lifetime — 10 minutes. */ -const PORTAL_INVITE_MAGIC_LINK_TTL_SECONDS = 10 * 60 +/** Magic-link token lifetime — matches the invite's full lifetime so a + * link emailed and opened days later still works. The 10-minute sign-in + * default would strand invitees who don't click immediately; the invite + * row's own `expiresAt` still governs long-term access. */ +const PORTAL_INVITE_MAGIC_LINK_TTL_SECONDS = PORTAL_INVITE_EXPIRY_MS / 1000 + +/** The magic-link callback path a portal invitee lands on to accept. Shared by + * the mint and copy-link paths so the two always build the same URL. */ +const portalInviteCallbackPath = (inviteId: string) => `/portal-invite/${inviteId}` // --------------------------------------------------------------------------- // Schemas @@ -53,17 +61,18 @@ const portalInviteByIdSchema = z.object({ async function mintPortalInviteMagicLink( email: string, inviteId: string, - portalUrl: string -): Promise { + portalUrl: string, + // Defaults to the full lifetime (see PORTAL_INVITE_MAGIC_LINK_TTL_SECONDS). + // Copy-link passes the invite's *remaining* lifetime so a re-minted token + // can't outlive the invite row it belongs to. + expiresInSeconds: number = PORTAL_INVITE_MAGIC_LINK_TTL_SECONDS +): Promise<{ url: string; token: string }> { const { mintMagicLinkUrl } = await import('@/lib/server/auth/magic-link-mint') return mintMagicLinkUrl({ email, - callbackPath: `/portal-invite/${inviteId}`, + callbackPath: portalInviteCallbackPath(inviteId), portalUrl, - // Portal invite links live for the invite's full lifetime; a 10-minute - // magic-link token is enough since the invitee clicks it promptly after - // receiving the email. The invite row itself governs long-term access. - expiresInSeconds: PORTAL_INVITE_MAGIC_LINK_TTL_SECONDS, + expiresInSeconds, }) } @@ -126,6 +135,16 @@ async function sendOnePortalInvite({ const inviteId = generateId('invite') const expiresAt = new Date(now.getTime() + PORTAL_INVITE_EXPIRY_MS) + // Mint before the insert so the row records its token in its token set + // (cancel revokes every token in the set). inviteId is fixed above, so the + // callback path is known. + const portalUrl = getBaseUrl() + const { url: inviteLink, token: magicLinkToken } = await mintPortalInviteMagicLink( + email, + inviteId, + portalUrl + ) + await db.insert(invitation).values({ id: inviteId, email, @@ -137,11 +156,9 @@ async function sendOnePortalInvite({ createdAt: now, lastSentAt: now, inviterId: auth.user.id as UserId, + magicLinkTokens: [magicLinkToken], }) - const portalUrl = getBaseUrl() - const inviteLink = await mintPortalInviteMagicLink(email, inviteId, portalUrl) - const { getEmailSafeUrl } = await import('@/lib/server/storage/s3') const logoUrl = getEmailSafeUrl(auth.settings.logoKey) ?? undefined await sendPortalInviteEmail({ @@ -271,13 +288,20 @@ export const cancelPortalInviteFn = createServerFn({ method: 'POST' }) eq(invitation.status, 'pending') ) ) - .returning({ id: invitation.id }) + .returning({ id: invitation.id, magicLinkTokens: invitation.magicLinkTokens }) if (updated.length === 0) { console.log(`[fn:portal-invites] cancelPortalInviteFn: no-op (row concurrently mutated)`) return { inviteId, status: 'no_op_already_accepted' as const } } + // Invalidate every link this invite ever minted so a cancelled invite can't + // sign anyone in. Revoking the full set (returned atomically by the status + // flip) closes the resend/copy/worker-restart windows where a single + // rotating pointer could leave a token live but untracked. + const { revokeMagicLinkTokens } = await import('@/lib/server/auth/magic-link-mint') + await revokeMagicLinkTokens(updated[0].magicLinkTokens) + await recordAuditEvent({ event: 'portal.invite.revoked', actor, @@ -367,16 +391,36 @@ export const resendPortalInviteFn = createServerFn({ method: 'POST' }) } const portalUrl = getBaseUrl() - const inviteLink = await mintPortalInviteMagicLink(inv.email, inviteId, portalUrl) + const { url: inviteLink, token: magicLinkToken } = await mintPortalInviteMagicLink( + inv.email, + inviteId, + portalUrl + ) + + // Add the new token to the invite's set (resend is additive — prior links + // keep working until accept/cancel/expiry). Recorded the moment it's minted, + // so a send failure or worker restart can't leave it live-but-untracked. + const { revokeMagicLinkToken } = await import('@/lib/server/auth/magic-link-mint') + if (!(await appendInviteMagicLinkToken(inviteId, magicLinkToken))) { + await revokeMagicLinkToken(magicLinkToken) // invite no longer pending; drop it + throw new Error('Invitation is no longer pending — refresh and try again') + } const { getEmailSafeUrl } = await import('@/lib/server/storage/s3') const logoUrl = getEmailSafeUrl(auth.settings.logoKey) ?? undefined - const result = await sendPortalInviteEmail({ - to: inv.email, - workspaceName: auth.settings.name, - inviteLink, - logoUrl, - }) + let result: Awaited> + try { + result = await sendPortalInviteEmail({ + to: inv.email, + workspaceName: auth.settings.name, + inviteLink, + logoUrl, + }) + } catch (sendError) { + // The new link never went out — drop it from the set and revoke it. + await removeInviteMagicLinkToken(inviteId, magicLinkToken) + throw sendError + } await recordAuditEvent({ event: 'portal.invite.resent', @@ -444,7 +488,8 @@ export const fetchPortalInvitesFn = createServerFn({ method: 'GET' }).handler(as /** * Mint a fresh magic-link for a pending portal invite. * - * Admin-only. The link expires in 10 minutes. The invite row itself must + * Admin-only. The link lives as long as the invite (see + * PORTAL_INVITE_MAGIC_LINK_TTL_SECONDS). The invite row itself must * be `kind='portal'`, `status='pending'`, and not past its own `expiresAt`. * Records a `portal.invite.link_minted` audit event on success. */ @@ -463,7 +508,24 @@ export const getPortalInviteLinkFn = createServerFn({ method: 'POST' }) if (inv.expiresAt && inv.expiresAt < new Date()) throw new Error('Invite has expired') const portalUrl = getBaseUrl() - const inviteLink = await mintPortalInviteMagicLink(inv.email, inv.id, portalUrl) + const { revokeMagicLinkToken } = await import('@/lib/server/auth/magic-link-mint') + + // Mint a fresh link and add it to the set. Minting is additive — it never + // touches the invite's other outstanding links, so copying doesn't + // invalidate a link the invitee may already hold. Capping the token at the + // invite's remaining lifetime keeps it from outliving the invite, and a + // freshly-minted token always covers that full window (no stale reuse). + const remainingSeconds = Math.floor((inv.expiresAt.getTime() - Date.now()) / 1000) + const { url: inviteLink, token } = await mintPortalInviteMagicLink( + inv.email, + inv.id, + portalUrl, + remainingSeconds + ) + if (!(await appendInviteMagicLinkToken(inv.id, token))) { + await revokeMagicLinkToken(token) // invite no longer pending; drop it + throw new Error('Invite is no longer pending — refresh and try again') + } await recordAuditEvent({ event: 'portal.invite.link_minted', @@ -474,8 +536,7 @@ export const getPortalInviteLinkFn = createServerFn({ method: 'POST' }) metadata: { email: inv.email }, }) - const expiresAt = new Date(Date.now() + PORTAL_INVITE_MAGIC_LINK_TTL_SECONDS * 1000) - return { inviteLink, expiresAt } + return { inviteLink, expiresAt: inv.expiresAt } }) // --------------------------------------------------------------------------- @@ -650,6 +711,22 @@ export const acceptPortalInviteFn = createServerFn({ method: 'POST' }) after: { email: inv.email, kind: 'portal' }, }) + // Accepted — revoke every token in the set so no other emailed/copied link + // for this invite can still sign anyone in. The link just used was consumed + // by the magic-link verify; siblings from resends/copies would otherwise + // stay live until their 30-day expiry. Best-effort: the accept is already + // committed, so a cleanup failure must not fail the request (the stray + // tokens still expire with the invite). + try { + const { revokeMagicLinkTokens } = await import('@/lib/server/auth/magic-link-mint') + await revokeMagicLinkTokens(updated[0].magicLinkTokens) + } catch (revokeError) { + console.error( + `[fn:portal-invites] ⚠️ acceptPortalInviteFn: token revoke failed:`, + revokeError + ) + } + console.log(`[fn:portal-invites] acceptPortalInviteFn: accepted id=${inviteId}`) return { status: 'accepted', alreadyAccepted: false } }) diff --git a/apps/web/src/lib/server/functions/recovery-codes-consume.ts b/apps/web/src/lib/server/functions/recovery-codes-consume.ts index e1c84b66a..2e81ad4c8 100644 --- a/apps/web/src/lib/server/functions/recovery-codes-consume.ts +++ b/apps/web/src/lib/server/functions/recovery-codes-consume.ts @@ -156,7 +156,7 @@ export const consumeRecoveryCodeFn = createServerFn({ method: 'POST' }) .set({ usedAt: new Date() }) .where(eq(ssoRecoveryCode.id, matchedId as SsoRecoveryCodeId)) - const redirectUrl = await mintMagicLinkUrl({ + const { url: redirectUrl } = await mintMagicLinkUrl({ email: data.email, callbackPath: '/admin', errorCallbackPath: '/admin/login', diff --git a/packages/db/drizzle/0111_invitation_magic_link_token.sql b/packages/db/drizzle/0111_invitation_magic_link_token.sql new file mode 100644 index 000000000..633b4d29f --- /dev/null +++ b/packages/db/drizzle/0111_invitation_magic_link_token.sql @@ -0,0 +1,10 @@ +-- Tracks the verification-table identifier (the magic-link token) currently +-- minted for this invitation. Lets the cancel / re-send paths delete the +-- backing `verification` row so a cancelled or superseded invite link can no +-- longer mint a session -- without it, the emailed token stays live for its +-- full 30-day TTL regardless of the invite's status. +-- +-- Additive + backfill-safe: existing pending invites get NULL (their tokens +-- self-expire at the row's expires_at and are simply not revocable). Deletes +-- by verification.identifier are served by the existing verification_identifier_idx. +ALTER TABLE "invitation" ADD COLUMN "magic_link_token" text; diff --git a/packages/db/drizzle/0112_invitation_magic_link_tokens.sql b/packages/db/drizzle/0112_invitation_magic_link_tokens.sql new file mode 100644 index 000000000..96c039f51 --- /dev/null +++ b/packages/db/drizzle/0112_invitation_magic_link_tokens.sql @@ -0,0 +1,16 @@ +-- Track the FULL set of magic-link tokens minted for an invitation, not just +-- the latest. send/resend/copy each append a token; cancel revokes them all. +-- This makes cancellation robust under concurrency and worker restarts: a +-- token minted during a resend's email-send window is recorded immediately, so +-- it can never end up live-but-untracked (and thus survive a later cancel) the +-- way a single rotating pointer could. +-- +-- Replaces the single magic_link_token column added in 0111. Backfill-safe: +-- existing pending invites carry their one tracked token into the array. +ALTER TABLE "invitation" ADD COLUMN "magic_link_tokens" text[] NOT NULL DEFAULT '{}'; + +UPDATE "invitation" + SET "magic_link_tokens" = ARRAY["magic_link_token"] + WHERE "magic_link_token" IS NOT NULL; + +ALTER TABLE "invitation" DROP COLUMN "magic_link_token"; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index c5031fbf1..e81eafec2 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -778,6 +778,20 @@ "when": 1782172800000, "tag": "0110_oauth_refresh_token_family_idx", "breakpoints": true + }, + { + "idx": 111, + "version": "7", + "when": 1782259200000, + "tag": "0111_invitation_magic_link_token", + "breakpoints": true + }, + { + "idx": 112, + "version": "7", + "when": 1782345600000, + "tag": "0112_invitation_magic_link_tokens", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts index fd83c6186..2927475e3 100644 --- a/packages/db/src/schema/auth.ts +++ b/packages/db/src/schema/auth.ts @@ -464,6 +464,17 @@ export const invitation = pgTable( expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), lastSentAt: timestamp('last_sent_at', { withTimezone: true }), + /** + * The set of `verification.identifier` magic-link tokens minted for this + * invite (one per send/resend/copy). Cancel revokes every token in the set, + * so no link can outlive the invite — even one minted during a resend's + * send window or after a worker restart. Tokens are single-use and expire + * with the invite, so the set stays small and self-pruning. + */ + magicLinkTokens: text('magic_link_tokens') + .array() + .notNull() + .default(sql`'{}'::text[]`), inviterId: typeIdColumn('user')('inviter_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }),