diff --git a/apps/platform/app.ts b/apps/platform/app.ts index f62b7a2a..713a80cf 100644 --- a/apps/platform/app.ts +++ b/apps/platform/app.ts @@ -1,4 +1,4 @@ -import type { Ctx } from './ctx'; +import type { Ctx, TrpcContext } from './ctx'; import { env } from './env'; import { Hono } from 'hono'; import { serve } from '@hono/node-server'; @@ -42,12 +42,13 @@ app.use( '/trpc/*', trpcServer({ router: trpcPlatformRouter, - createContext: (_, c) => ({ - db, - account: c.get('account'), - org: null, - event: c - }) + createContext: (_, c) => + ({ + db, + account: c.get('account'), + org: null, + event: c + }) satisfies TrpcContext }) ); diff --git a/apps/platform/ctx.ts b/apps/platform/ctx.ts index ead94337..3628e1b5 100644 --- a/apps/platform/ctx.ts +++ b/apps/platform/ctx.ts @@ -1,13 +1,12 @@ import type { HttpBindings } from '@hono/node-server'; +import type { DBType } from '@u22n/database'; +import type { Context } from 'hono'; import type { DatabaseSession } from 'lucia'; export type Ctx = { Bindings: HttpBindings; Variables: { - account: { - id: number; - session: any; - } | null; + account: AccountContext; }; }; @@ -29,3 +28,10 @@ export type AccountContext = { id: number; session: DatabaseSession; } | null; + +export type TrpcContext = { + db: DBType; + account: AccountContext; + org: OrgContext; + event: Context; +}; diff --git a/apps/platform/middlewares.ts b/apps/platform/middlewares.ts index 8b5129e7..3134885a 100644 --- a/apps/platform/middlewares.ts +++ b/apps/platform/middlewares.ts @@ -15,8 +15,7 @@ export const authMiddleware = createMiddleware(async (c, next) => { await next(); } else { c.set('account', { - // @ts-expect-error, not typed properly yet - id: Number(sessionObject.attributes.account.id), + id: sessionObject.attributes.account.id, session: sessionObject }); await next(); diff --git a/apps/platform/package.json b/apps/platform/package.json index 4cf3abcc..79574360 100644 --- a/apps/platform/package.json +++ b/apps/platform/package.json @@ -5,8 +5,14 @@ "scripts": { "dev": "tsx watch --clear-screen=false app.ts", "start": "node --import=tsx app.ts", + "build": "echo 'No build step configured'", "check": "tsc --noEmit" }, + "exports": { + "./trpc": { + "types": "./trpc/index.ts" + } + }, "dependencies": { "@hono/node-server": "^1.11.1", "@hono/trpc-server": "^0.3.1", diff --git a/apps/platform/routes/auth.ts b/apps/platform/routes/auth.ts index a5f1e63e..f8279593 100644 --- a/apps/platform/routes/auth.ts +++ b/apps/platform/routes/auth.ts @@ -1,13 +1,13 @@ import { Hono } from 'hono'; -import type { Ctx } from '../ctx'; -import { lucia } from '../utils/auth'; +import type { Ctx } from '~platform/ctx'; +import { lucia } from '~platform/utils/auth'; import { setCookie } from 'hono/cookie'; export const authApi = new Hono(); authApi.get('/status', async (c) => { const account = c.get('account'); - if (!account || !account.id) { + if (!account) { return c.json({ authStatus: 'unauthenticated' }); } return c.json({ authStatus: 'authenticated' }); @@ -15,7 +15,7 @@ authApi.get('/status', async (c) => { authApi.post('/logout', async (c) => { const account = c.get('account'); - if (!account || !account.id || !account.session || !account.session.id) { + if (!account) { return c.json({ ok: true }); } const sessionId = account.session.id; diff --git a/apps/platform/routes/realtime.ts b/apps/platform/routes/realtime.ts index 7cd133c8..2bd133cd 100644 --- a/apps/platform/routes/realtime.ts +++ b/apps/platform/routes/realtime.ts @@ -1,12 +1,12 @@ import { Hono } from 'hono'; -import type { Ctx } from '../ctx'; +import type { Ctx } from '~platform/ctx'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; -import { validateOrgShortCode } from '../utils/orgShortCode'; +import { validateOrgShortCode } from '~platform/utils/orgShortCode'; import { db } from '@u22n/database'; import { and, eq } from '@u22n/database/orm'; import { orgMembers } from '@u22n/database/schema'; -import { realtime } from '../utils/realtime'; +import { realtime } from '~platform/utils/realtime'; export const realtimeApi = new Hono(); diff --git a/apps/platform/storage.ts b/apps/platform/storage.ts index 27e3adf3..83c64a7f 100644 --- a/apps/platform/storage.ts +++ b/apps/platform/storage.ts @@ -1,10 +1,15 @@ import { env } from './env'; import { ms } from 'itty-time'; import redisDriver from 'unstorage/drivers/redis'; -import { createStorage } from 'unstorage'; +import { createStorage, type StorageValue } from 'unstorage'; +import type { DatabaseSession } from 'lucia'; +import type { OrgContext } from './ctx'; -const createCachedStorage = (base: string, ttl: number) => - createStorage({ +const createCachedStorage = ( + base: string, + ttl: number +) => + createStorage({ driver: redisDriver({ url: env.DB_REDIS_CONNECTION_STRING, ttl, @@ -14,8 +19,8 @@ const createCachedStorage = (base: string, ttl: number) => export const storage = { auth: createCachedStorage('auth', ms('5 minutes')), - orgContext: createCachedStorage('org-context', ms('12 hours')), - session: createCachedStorage( + orgContext: createCachedStorage('org-context', ms('12 hours')), + session: createCachedStorage( 'sessions', env.NODE_ENV === 'development' ? ms('12 hours') : ms('30 days') ) diff --git a/apps/platform/trpc/routers/authRouter/passkeyRouter.ts b/apps/platform/trpc/routers/authRouter/passkeyRouter.ts index f4271181..b2e63aee 100644 --- a/apps/platform/trpc/routers/authRouter/passkeyRouter.ts +++ b/apps/platform/trpc/routers/authRouter/passkeyRouter.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { router, publicRateLimitedProcedure } from '../../trpc'; +import { router, publicRateLimitedProcedure } from '~platform/trpc/trpc'; import { eq } from '@u22n/database/orm'; import { accounts } from '@u22n/database/schema'; import { TRPCError } from '@trpc/server'; @@ -13,13 +13,16 @@ import { typeIdValidator, zodSchemas } from '@u22n/utils'; -import { UAParser } from 'ua-parser-js'; -import { usePasskeys } from '../../../utils/auth/passkeys'; -import { usePasskeysDb } from '../../../utils/auth/passkeyDbAdaptor'; -import { lucia } from '../../../utils/auth'; +import { + verifyRegistrationResponse, + generateRegistrationOptions, + generateAuthenticationOptions, + verifyAuthenticationResponse +} from '~platform/utils/auth/passkeys'; +import { createAuthenticator } from '~platform/utils/auth/passkeyUtils'; import { validateUsername } from './signupRouter'; -import { createLuciaSessionCookie } from '../../../utils/session'; -import { env } from '../../../env'; +import { createLuciaSessionCookie } from '~platform/utils/session'; +import { env } from '~platform/env'; import { ms } from 'itty-time'; import { getCookie, setCookie } from 'hono/cookie'; @@ -42,7 +45,7 @@ export const passkeyRouter = router({ } const publicId = typeIdGenerator('account'); - const passkeyOptions = await usePasskeys.generateRegistrationOptions({ + const passkeyOptions = await generateRegistrationOptions({ userDisplayName: username, username: username, accountPublicId: publicId @@ -63,7 +66,7 @@ export const passkeyRouter = router({ const registrationResponse = input.registrationResponseRaw as RegistrationResponseJSON; - const passkeyVerification = await usePasskeys.verifyRegistrationResponse({ + const passkeyVerification = await verifyRegistrationResponse({ registrationResponse: registrationResponse, publicId: input.publicId }); @@ -104,7 +107,7 @@ export const passkeyRouter = router({ }); } - const insertPasskey = await usePasskeysDb.createAuthenticator( + const insertPasskey = await createAuthenticator( { accountId: Number(newAccount.insertId), credentialID: passkeyVerification.registrationInfo.credentialID, @@ -137,12 +140,12 @@ export const passkeyRouter = router({ } }); - const cookie = await createLuciaSessionCookie(ctx.event, { + await createLuciaSessionCookie(ctx.event, { accountId, username: input.username, publicId: input.publicId }); - setCookie(ctx.event, cookie.name, cookie.value, cookie.attributes); + return { success: true }; }), @@ -160,7 +163,7 @@ export const passkeyRouter = router({ maxAge: ms('5 minutes'), domain: env.PRIMARY_DOMAIN }); - const passkeyOptions = await usePasskeys.generateAuthenticationOptions({ + const passkeyOptions = await generateAuthenticationOptions({ authChallengeId: authChallengeId }); @@ -187,11 +190,10 @@ export const passkeyRouter = router({ }); } - const passkeyVerification = - await usePasskeys.verifyAuthenticationResponse({ - authenticationResponse: verificationResponse, - authChallengeId: challengeCookie - }); + const passkeyVerification = await verifyAuthenticationResponse({ + authenticationResponse: verificationResponse, + authChallengeId: challengeCookie + }); if ( !passkeyVerification.result.verified || @@ -231,26 +233,11 @@ export const passkeyRouter = router({ }); } - const { device, os } = UAParser(event.req.header('User-Agent')); - const userDevice = - device.type === 'mobile' ? device.toString() : device.vendor; - - const accountSession = await lucia.createSession(account.id, { - account: { - id: account.id, - username: account.username, - publicId: account.publicId - }, - device: userDevice || 'Unknown', - os: os.name || 'Unknown' + await createLuciaSessionCookie(ctx.event, { + accountId: account.id, + username: account.username, + publicId: account.publicId }); - const cookie = lucia.createSessionCookie(accountSession.id); - setCookie(event, cookie.name, cookie.value, cookie.attributes); - - await db - .update(accounts) - .set({ lastLoginAt: new Date() }) - .where(eq(accounts.id, account.id)); const defaultOrg = account.orgMemberships.sort((a, b) => a.id - b.id)[0] ?.org.shortcode; diff --git a/apps/platform/trpc/routers/authRouter/passwordRouter.ts b/apps/platform/trpc/routers/authRouter/passwordRouter.ts index 2c4b22a1..a5cf64a6 100644 --- a/apps/platform/trpc/routers/authRouter/passwordRouter.ts +++ b/apps/platform/trpc/routers/authRouter/passwordRouter.ts @@ -4,7 +4,7 @@ import { router, accountProcedure, publicRateLimitedProcedure -} from '../../trpc'; +} from '~platform/trpc/trpc'; import { eq } from '@u22n/database/orm'; import { accounts } from '@u22n/database/schema'; import { @@ -14,14 +14,14 @@ import { strongPasswordSchema } from '@u22n/utils'; import { TRPCError } from '@trpc/server'; -import { lucia } from '../../../utils/auth'; +import { lucia } from '~platform/utils/auth'; import { validateUsername } from './signupRouter'; -import { createLuciaSessionCookie } from '../../../utils/session'; +import { createLuciaSessionCookie } from '~platform/utils/session'; import { decodeHex } from 'oslo/encoding'; import { TOTPController } from 'oslo/otp'; import { setCookie, getCookie, deleteCookie } from 'hono/cookie'; -import { env } from '../../../env'; -import { storage } from '../../../storage'; +import { env } from '~platform/env'; +import { storage } from '~platform/storage'; export const passwordRouter = router({ /** @@ -36,7 +36,7 @@ export const passwordRouter = router({ ) .mutation(async ({ ctx, input }) => { const { username, password } = input; - const { db, event } = ctx; + const { db } = ctx; const { accountId, publicId } = await db.transaction(async (tx) => { try { @@ -66,18 +66,12 @@ export const passwordRouter = router({ } }); - const cookie = await createLuciaSessionCookie(ctx.event, { + await createLuciaSessionCookie(ctx.event, { accountId, username, publicId }); - setCookie(event, cookie.name, cookie.value, cookie.attributes); - await db - .update(accounts) - .set({ lastLoginAt: new Date() }) - .where(eq(accounts.id, accountId)); - return { success: true }; }), @@ -164,20 +158,14 @@ export const passwordRouter = router({ } ); - const cookie = await createLuciaSessionCookie(event, { + await createLuciaSessionCookie(event, { accountId, username, publicId }); - setCookie(event, cookie.name, cookie.value, cookie.attributes); deleteCookie(event, 'un-2fa-challenge'); - await db - .update(accounts) - .set({ lastLoginAt: new Date() }) - .where(eq(accounts.id, accountId)); - return { success: true, error: null, recoveryCode }; }), @@ -296,17 +284,11 @@ export const passwordRouter = router({ if (validPassword && otpValid) { const { id: accountId, username, publicId } = userResponse; - const cookie = await createLuciaSessionCookie(event, { + await createLuciaSessionCookie(event, { accountId, username, publicId }); - setCookie(event, cookie.name, cookie.value, cookie.attributes); - - await db - .update(accounts) - .set({ lastLoginAt: new Date() }) - .where(eq(accounts.id, userResponse.id)); const defaultOrg = userResponse.orgMemberships.sort( (a, b) => a.id - b.id @@ -400,13 +382,12 @@ export const passwordRouter = router({ await lucia.invalidateUserSessions(accountId); } - const cookie = await createLuciaSessionCookie(event, { + await createLuciaSessionCookie(event, { accountId, username: accountData.username, publicId: accountData.publicId }); - setCookie(event, cookie.name, cookie.value, cookie.attributes); return { success: true }; }) }); diff --git a/apps/platform/trpc/routers/authRouter/recoveryRouter.ts b/apps/platform/trpc/routers/authRouter/recoveryRouter.ts index 8b9edba7..4c9665be 100644 --- a/apps/platform/trpc/routers/authRouter/recoveryRouter.ts +++ b/apps/platform/trpc/routers/authRouter/recoveryRouter.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { Argon2id } from 'oslo/password'; -import { router, publicRateLimitedProcedure } from '../../trpc'; +import { router, publicRateLimitedProcedure } from '~platform/trpc/trpc'; import { eq } from '@u22n/database/orm'; import { accounts } from '@u22n/database/schema'; import { @@ -10,12 +10,12 @@ import { zodSchemas } from '@u22n/utils'; import { TRPCError } from '@trpc/server'; -import { createLuciaSessionCookie } from '../../../utils/session'; +import { createLuciaSessionCookie } from '~platform/utils/session'; import { decodeHex, encodeHex } from 'oslo/encoding'; import { TOTPController, createTOTPKeyURI } from 'oslo/otp'; import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; -import { env } from '../../../env'; -import { storage } from '../../../storage'; +import { env } from '~platform/env'; +import { storage } from '~platform/storage'; import { ms } from 'itty-time'; export const recoveryRouter = router({ @@ -138,14 +138,12 @@ export const recoveryRouter = router({ ) { const { id: accountId, username, publicId } = userResponse; - const cookie = await createLuciaSessionCookie(event, { + await createLuciaSessionCookie(event, { accountId, username, publicId }); - setCookie(event, cookie.name, cookie.value, cookie.attributes); - const authStorage = storage.auth; const token = nanoIdToken(); authStorage.setItem( @@ -159,11 +157,6 @@ export const recoveryRouter = router({ sameSite: 'Lax' }); - await db - .update(accounts) - .set({ lastLoginAt: new Date() }) - .where(eq(accounts.id, userResponse.id)); - return { success: true }; } diff --git a/apps/platform/trpc/routers/authRouter/signupRouter.ts b/apps/platform/trpc/routers/authRouter/signupRouter.ts index c42deea6..04088c30 100644 --- a/apps/platform/trpc/routers/authRouter/signupRouter.ts +++ b/apps/platform/trpc/routers/authRouter/signupRouter.ts @@ -1,9 +1,9 @@ import { z } from 'zod'; -import { router, publicRateLimitedProcedure } from '../../trpc'; +import { router, publicRateLimitedProcedure } from '~platform/trpc/trpc'; import type { DBType } from '@u22n/database'; import { eq } from '@u22n/database/orm'; import { accounts } from '@u22n/database/schema'; -import { blockedUsernames, reservedUsernames } from '../../../utils/signup'; +import { blockedUsernames, reservedUsernames } from '~platform/utils/signup'; import { zodSchemas, calculatePasswordStrength } from '@u22n/utils'; export async function validateUsername( diff --git a/apps/platform/trpc/routers/authRouter/twoFactorRouter.ts b/apps/platform/trpc/routers/authRouter/twoFactorRouter.ts index 7bb34778..69cae361 100644 --- a/apps/platform/trpc/routers/authRouter/twoFactorRouter.ts +++ b/apps/platform/trpc/routers/authRouter/twoFactorRouter.ts @@ -3,7 +3,7 @@ import { router, accountProcedure, publicRateLimitedProcedure -} from '../../trpc'; +} from '~platform/trpc/trpc'; import { eq } from '@u22n/database/orm'; import { accounts } from '@u22n/database/schema'; import { decodeHex, encodeHex } from 'oslo/encoding'; @@ -12,8 +12,8 @@ import { TRPCError } from '@trpc/server'; import { nanoIdToken, zodSchemas } from '@u22n/utils'; import { Argon2id } from 'oslo/password'; import { getCookie, setCookie } from 'hono/cookie'; -import { storage } from '../../../storage'; -import { env } from '../../../env'; +import { storage } from '~platform/storage'; +import { env } from '~platform/env'; export const twoFactorRouter = router({ /** diff --git a/apps/platform/trpc/routers/contactRouter/contactRouter.ts b/apps/platform/trpc/routers/contactRouter/contactRouter.ts index 35844d32..aa69d889 100644 --- a/apps/platform/trpc/routers/contactRouter/contactRouter.ts +++ b/apps/platform/trpc/routers/contactRouter/contactRouter.ts @@ -1,17 +1,10 @@ import { z } from 'zod'; -import { router, orgProcedure } from '../../trpc'; +import { router, orgProcedure } from '~platform/trpc/trpc'; import { and, eq } from '@u22n/database/orm'; import { contacts } from '@u22n/database/schema'; -import { TRPCError } from '@trpc/server'; export const contactsRouter = router({ getOrgContacts: orgProcedure.input(z.object({})).query(async ({ ctx }) => { - if (!ctx.account || !ctx.org) { - throw new TRPCError({ - code: 'UNPROCESSABLE_CONTENT', - message: 'Account or Organization is not defined' - }); - } const { db, org } = ctx; const orgId = org?.id; diff --git a/apps/platform/trpc/routers/convoRouter/convoRouter.ts b/apps/platform/trpc/routers/convoRouter/convoRouter.ts index a29d74bf..3df84c82 100644 --- a/apps/platform/trpc/routers/convoRouter/convoRouter.ts +++ b/apps/platform/trpc/routers/convoRouter/convoRouter.ts @@ -1,7 +1,7 @@ -import { mailBridgeTrpcClient } from './../../../utils/tRPCServerClients'; +import { mailBridgeTrpcClient } from '~platform/utils/tRPCServerClients'; import { z } from 'zod'; import { parse } from 'superjson'; -import { router, orgProcedure } from '../../trpc'; +import { router, orgProcedure } from '~platform/trpc/trpc'; import { type InferInsertModel, and, @@ -36,8 +36,8 @@ import { TRPCError } from '@trpc/server'; import { tipTapExtensions } from '@u22n/tiptap/extensions'; import { tiptapCore, type tiptapVue3 } from '@u22n/tiptap'; import { convoEntryRouter } from './entryRouter'; -import { realtime, sendRealtimeNotification } from '../../../utils/realtime'; -import { env } from '../../../env'; +import { realtime, sendRealtimeNotification } from '~platform/utils/realtime'; +import { env } from '~platform/env'; export const convoRouter = router({ entries: convoEntryRouter, diff --git a/apps/platform/trpc/routers/convoRouter/entryRouter.ts b/apps/platform/trpc/routers/convoRouter/entryRouter.ts index 95b5e47f..4a63537a 100644 --- a/apps/platform/trpc/routers/convoRouter/entryRouter.ts +++ b/apps/platform/trpc/routers/convoRouter/entryRouter.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { router, orgProcedure } from '../../trpc'; +import { router, orgProcedure } from '~platform/trpc/trpc'; import { and, desc, eq, lt, or } from '@u22n/database/orm'; import { convos, convoEntries } from '@u22n/database/schema'; import { typeIdValidator } from '@u22n/utils'; diff --git a/apps/platform/trpc/routers/orgRouter/mail/domainsRouter.ts b/apps/platform/trpc/routers/orgRouter/mail/domainsRouter.ts index 60fb99fe..50a40d5e 100644 --- a/apps/platform/trpc/routers/orgRouter/mail/domainsRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/mail/domainsRouter.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { router, orgProcedure } from '../../../trpc'; +import { router, orgProcedure } from '~platform/trpc/trpc'; import { and, eq } from '@u22n/database/orm'; import { domains, @@ -8,8 +8,8 @@ import { } from '@u22n/database/schema'; import { typeIdGenerator, typeIdValidator } from '@u22n/utils'; import { TRPCError } from '@trpc/server'; -import { isAccountAdminOfOrg } from '../../../../utils/account'; -import { mailBridgeTrpcClient } from '../../../../utils/tRPCServerClients'; +import { isAccountAdminOfOrg } from '~platform/utils/account'; +import { mailBridgeTrpcClient } from '~platform/utils/tRPCServerClients'; import { lookupNS } from '@u22n/utils'; export const domainsRouter = router({ diff --git a/apps/platform/trpc/routers/orgRouter/mail/emailIdentityExternalRouter.ts b/apps/platform/trpc/routers/orgRouter/mail/emailIdentityExternalRouter.ts index 3d8cb300..a570a6a6 100644 --- a/apps/platform/trpc/routers/orgRouter/mail/emailIdentityExternalRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/mail/emailIdentityExternalRouter.ts @@ -1,6 +1,6 @@ -import { mailBridgeTrpcClient } from '../../../../utils/tRPCServerClients'; +import { mailBridgeTrpcClient } from '~platform/utils/tRPCServerClients'; import { z } from 'zod'; -import { router, orgProcedure } from '../../../trpc'; +import { router, orgProcedure } from '~platform/trpc/trpc'; import { and, eq, inArray, type InferInsertModel } from '@u22n/database/orm'; import { orgMembers, @@ -13,7 +13,7 @@ import { } from '@u22n/database/schema'; import { nanoIdToken, typeIdGenerator, typeIdValidator } from '@u22n/utils'; import { TRPCError } from '@trpc/server'; -import { env } from '../../../../env'; +import { env } from '~platform/env'; export const emailIdentityExternalRouter = router({ checkExternalAvailability: orgProcedure diff --git a/apps/platform/trpc/routers/orgRouter/mail/emailIdentityRouter.ts b/apps/platform/trpc/routers/orgRouter/mail/emailIdentityRouter.ts index 01341df7..de3b5cc7 100644 --- a/apps/platform/trpc/routers/orgRouter/mail/emailIdentityRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/mail/emailIdentityRouter.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { router, orgProcedure } from '../../../trpc'; +import { router, orgProcedure } from '~platform/trpc/trpc'; import { and, eq, @@ -23,10 +23,10 @@ import { type TypeId, nanoIdToken } from '@u22n/utils'; -import { isAccountAdminOfOrg } from '../../../../utils/account'; +import { isAccountAdminOfOrg } from '~platform/utils/account'; import { TRPCError } from '@trpc/server'; import { emailIdentityExternalRouter } from './emailIdentityExternalRouter'; -import { env } from '../../../../env'; +import { env } from '~platform/env'; export const emailIdentityRouter = router({ external: emailIdentityExternalRouter, diff --git a/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts b/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts index b69403fc..70abb66f 100644 --- a/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { router, accountProcedure } from '../../trpc'; +import { router, accountProcedure } from '~platform/trpc/trpc'; import type { DBType } from '@u22n/database'; import { eq, and } from '@u22n/database/orm'; import { @@ -10,7 +10,7 @@ import { } from '@u22n/database/schema'; import { typeIdGenerator } from '@u22n/utils'; import { TRPCError } from '@trpc/server'; -import { blockedUsernames, reservedUsernames } from '../../../utils/signup'; +import { blockedUsernames, reservedUsernames } from '~platform/utils/signup'; async function validateOrgShortCode( db: DBType, diff --git a/apps/platform/trpc/routers/orgRouter/orgStoreRouter.ts b/apps/platform/trpc/routers/orgRouter/orgStoreRouter.ts index 4943d35b..76c8a2e1 100644 --- a/apps/platform/trpc/routers/orgRouter/orgStoreRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/orgStoreRouter.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { router, accountProcedure } from '../../trpc'; +import { router, accountProcedure } from '~platform/trpc/trpc'; import { eq } from '@u22n/database/orm'; import { accounts } from '@u22n/database/schema'; import { TRPCError } from '@trpc/server'; diff --git a/apps/platform/trpc/routers/orgRouter/setup/billingRouter.ts b/apps/platform/trpc/routers/orgRouter/setup/billingRouter.ts index 06182f79..d0cbbfbe 100644 --- a/apps/platform/trpc/routers/orgRouter/setup/billingRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/setup/billingRouter.ts @@ -1,10 +1,10 @@ import { z } from 'zod'; -import { router, eeProcedure } from '../../../trpc'; +import { router, eeProcedure } from '~platform/trpc/trpc'; import { eq, and, sql } from '@u22n/database/orm'; import { domains, orgBilling, orgMembers, orgs } from '@u22n/database/schema'; -import { isAccountAdminOfOrg } from '../../../../utils/account'; +import { isAccountAdminOfOrg } from '~platform/utils/account'; import { TRPCError } from '@trpc/server'; -import { billingTrpcClient } from '../../../../utils/tRPCServerClients'; +import { billingTrpcClient } from '~platform/utils/tRPCServerClients'; export const billingRouter = router({ getOrgBillingOverview: eeProcedure diff --git a/apps/platform/trpc/routers/orgRouter/setup/profileRouter.ts b/apps/platform/trpc/routers/orgRouter/setup/profileRouter.ts index 100785af..8e9870c5 100644 --- a/apps/platform/trpc/routers/orgRouter/setup/profileRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/setup/profileRouter.ts @@ -1,9 +1,9 @@ import { z } from 'zod'; -import { router, orgProcedure } from '../../../trpc'; +import { router, orgProcedure } from '~platform/trpc/trpc'; import { eq } from '@u22n/database/orm'; import { orgs } from '@u22n/database/schema'; import { typeIdValidator } from '@u22n/utils'; -import { isAccountAdminOfOrg } from '../../../../utils/account'; +import { isAccountAdminOfOrg } from '~platform/utils/account'; import { TRPCError } from '@trpc/server'; export const orgProfileRouter = router({ diff --git a/apps/platform/trpc/routers/orgRouter/users/invitesRouter.ts b/apps/platform/trpc/routers/orgRouter/users/invitesRouter.ts index 0907826e..67382d57 100644 --- a/apps/platform/trpc/routers/orgRouter/users/invitesRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/users/invitesRouter.ts @@ -4,7 +4,7 @@ import { orgProcedure, accountProcedure, publicRateLimitedProcedure -} from '../../../trpc'; +} from '~platform/trpc/trpc'; import { eq } from '@u22n/database/orm'; import { domains, @@ -23,13 +23,13 @@ import { typeIdValidator, zodSchemas } from '@u22n/utils'; -import { refreshOrgShortCodeCache } from '../../../../utils/orgShortCode'; -import { isAccountAdminOfOrg } from '../../../../utils/account'; +import { refreshOrgShortCodeCache } from '~platform/utils/orgShortCode'; +import { isAccountAdminOfOrg } from '~platform/utils/account'; import { TRPCError } from '@trpc/server'; -import { billingTrpcClient } from '../../../../utils/tRPCServerClients'; +import { billingTrpcClient } from '~platform/utils/tRPCServerClients'; import { addOrgMemberToTeamHandler } from './teamsHandler'; -import { sendInviteEmail } from '../../../../utils/mail/transactional'; -import { env } from '../../../../env'; +import { sendInviteEmail } from '~platform/utils/mail/transactional'; +import { env } from '~platform/env'; export const invitesRouter = router({ createNewInvite: orgProcedure diff --git a/apps/platform/trpc/routers/orgRouter/users/membersRouter.ts b/apps/platform/trpc/routers/orgRouter/users/membersRouter.ts index d4fb1ca0..cfad2466 100644 --- a/apps/platform/trpc/routers/orgRouter/users/membersRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/users/membersRouter.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { router, orgProcedure } from '../../../trpc'; +import { router, orgProcedure } from '~platform/trpc/trpc'; import { eq, and, or } from '@u22n/database/orm'; import { orgs, orgMembers } from '@u22n/database/schema'; import { TRPCError } from '@trpc/server'; diff --git a/apps/platform/trpc/routers/orgRouter/users/teamsRouter.ts b/apps/platform/trpc/routers/orgRouter/users/teamsRouter.ts index 2099a6f7..3aec0400 100644 --- a/apps/platform/trpc/routers/orgRouter/users/teamsRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/users/teamsRouter.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { router, orgProcedure } from '../../../trpc'; +import { router, orgProcedure } from '~platform/trpc/trpc'; import { eq, and } from '@u22n/database/orm'; import { teams } from '@u22n/database/schema'; import { typeIdGenerator, typeIdValidator } from '@u22n/utils'; diff --git a/apps/platform/trpc/routers/userRouter/addressRouter.ts b/apps/platform/trpc/routers/userRouter/addressRouter.ts index b489fce7..aeff0691 100644 --- a/apps/platform/trpc/routers/userRouter/addressRouter.ts +++ b/apps/platform/trpc/routers/userRouter/addressRouter.ts @@ -1,6 +1,6 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { orgProcedure, router, accountProcedure } from '../../trpc'; +import { orgProcedure, router, accountProcedure } from '~platform/trpc/trpc'; import { eq } from '@u22n/database/orm'; import { emailIdentities, @@ -10,10 +10,9 @@ import { emailIdentitiesAuthorizedOrgMembers, emailRoutingRulesDestinations } from '@u22n/database/schema'; - import { orgMembers } from '@u22n/database/schema'; import { nanoIdToken, typeIdGenerator, typeIdValidator } from '@u22n/utils'; -import { env } from '../../../env'; +import { env } from '~platform/env'; export const addressRouter = router({ getPersonalAddresses: accountProcedure diff --git a/apps/platform/trpc/routers/userRouter/defaultsRouter.ts b/apps/platform/trpc/routers/userRouter/defaultsRouter.ts index 04cedd2e..63f0d1cd 100644 --- a/apps/platform/trpc/routers/userRouter/defaultsRouter.ts +++ b/apps/platform/trpc/routers/userRouter/defaultsRouter.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { router, accountProcedure, orgProcedure } from '../../trpc'; +import { router, accountProcedure, orgProcedure } from '~platform/trpc/trpc'; import { and, eq } from '@u22n/database/orm'; import { accounts, orgMembers } from '@u22n/database/schema'; import { TRPCError } from '@trpc/server'; diff --git a/apps/platform/trpc/routers/userRouter/profileRouter.ts b/apps/platform/trpc/routers/userRouter/profileRouter.ts index 01893c0f..f36a6181 100644 --- a/apps/platform/trpc/routers/userRouter/profileRouter.ts +++ b/apps/platform/trpc/routers/userRouter/profileRouter.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { router, accountProcedure } from '../../trpc'; +import { router, accountProcedure } from '~platform/trpc/trpc'; import { and, eq } from '@u22n/database/orm'; import { orgMemberProfiles, orgs, orgMembers } from '@u22n/database/schema'; import { typeIdValidator } from '@u22n/utils'; diff --git a/apps/platform/trpc/routers/userRouter/securityRouter.ts b/apps/platform/trpc/routers/userRouter/securityRouter.ts index 0b0dcaa8..a91a8351 100644 --- a/apps/platform/trpc/routers/userRouter/securityRouter.ts +++ b/apps/platform/trpc/routers/userRouter/securityRouter.ts @@ -1,6 +1,11 @@ -import { usePasskeys } from './../../../utils/auth/passkeys'; +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse +} from '~platform/utils/auth/passkeys'; import { z } from 'zod'; -import { router, accountProcedure } from '../../trpc'; +import { router, accountProcedure } from '~platform/trpc/trpc'; import { and, eq } from '@u22n/database/orm'; import { accounts, authenticators, sessions } from '@u22n/database/schema'; import { @@ -17,12 +22,12 @@ import type { RegistrationResponseJSON } from '@simplewebauthn/types'; import { Argon2id } from 'oslo/password'; -import { usePasskeysDb } from '../../../utils/auth/passkeyDbAdaptor'; +import { createAuthenticator } from '~platform/utils/auth/passkeyUtils'; import { decodeHex, encodeHex } from 'oslo/encoding'; import { TOTPController, createTOTPKeyURI } from 'oslo/otp'; -import { lucia } from '../../../utils/auth'; -import { storage } from '../../../storage'; -import { env } from '../../../env'; +import { lucia } from '~platform/utils/auth'; +import { storage } from '~platform/storage'; +import { env } from '~platform/env'; import { datePlus } from 'itty-time'; const authStorage = storage.auth; @@ -113,7 +118,7 @@ export const securityRouter = router({ domain: env.PRIMARY_DOMAIN }); - const passkeyOptions = await usePasskeys.generateAuthenticationOptions({ + const passkeyOptions = await generateAuthenticationOptions({ authChallengeId: authChallengeId, accountId: accountQuery.id }); @@ -162,11 +167,10 @@ export const securityRouter = router({ message: 'Challenge not found, try again' }); } - const passkeyVerification = - await usePasskeys.verifyAuthenticationResponse({ - authenticationResponse: verificationResponse, - authChallengeId: challengeCookie - }); + const passkeyVerification = await verifyAuthenticationResponse({ + authenticationResponse: verificationResponse, + authChallengeId: challengeCookie + }); if ( !passkeyVerification.result.verified || @@ -806,7 +810,7 @@ export const securityRouter = router({ }); } - const passkeyOptions = await usePasskeys.generateRegistrationOptions({ + const passkeyOptions = await generateRegistrationOptions({ userDisplayName: accountData.username, username: accountData.username, accountPublicId: accountData.publicId @@ -862,7 +866,7 @@ export const securityRouter = router({ const registrationResponse = input.registrationResponseRaw as RegistrationResponseJSON; - const passkeyVerification = await usePasskeys.verifyRegistrationResponse({ + const passkeyVerification = await verifyRegistrationResponse({ registrationResponse: registrationResponse, publicId: accountData.publicId }); @@ -891,7 +895,7 @@ export const securityRouter = router({ }); } - const insertPasskey = await usePasskeysDb.createAuthenticator( + const insertPasskey = await createAuthenticator( { accountId: accountQuery.id, credentialID: passkeyVerification.registrationInfo.credentialID, diff --git a/apps/platform/trpc/trpc.ts b/apps/platform/trpc/trpc.ts index 5cc31cd6..fb2c31a9 100644 --- a/apps/platform/trpc/trpc.ts +++ b/apps/platform/trpc/trpc.ts @@ -1,29 +1,18 @@ import { TRPCError, initTRPC } from '@trpc/server'; import superjson from 'superjson'; -import { validateOrgShortCode } from '../utils/orgShortCode'; +import { validateOrgShortCode } from '~platform/utils/orgShortCode'; import { type Duration, Ratelimit, NoopRatelimit } from '@unkey/ratelimit'; -import type { OrgContext, AccountContext } from '../ctx'; -import type { db } from '@u22n/database'; -import type { Context } from 'hono'; +import type { TrpcContext } from '~platform/ctx'; import { z } from 'zod'; -import { env } from '../env'; -import type { Ctx } from '../ctx'; +import { env } from '~platform/env'; export const trpcContext = initTRPC - .context<{ - db: typeof db; - account: AccountContext; - org: OrgContext; - event: Context; - }>() + .context() .create({ transformer: superjson }); const isAccountAuthenticated = trpcContext.middleware(({ next, ctx }) => { - if ( - !ctx.account || - !ctx.account.session.attributes.account.id || - !ctx.account.id - ) { + if (!ctx.account) { + ctx.event.header('Location', '/'); throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You are not logged in, redirecting...' @@ -98,8 +87,7 @@ export const publicRateLimitedProcedure = Object.entries( publicRateLimits ).reduce( (acc, [key, [limit, duration]]) => { - // @ts-expect-error, we know this is a valid key - acc[key] = trpcContext.procedure.use( + acc[key as keyof typeof publicRateLimits] = trpcContext.procedure.use( createRatelimiter({ limit, duration, namespace: `public.${key}` }) ); return acc; @@ -113,8 +101,8 @@ export const accountProcedure = trpcContext.procedure.use( export const orgProcedure = trpcContext.procedure .use(isAccountAuthenticated) .input(z.object({ orgShortCode: z.string() })) - .use(async (opts) => { - const { orgShortCode } = opts.input; + .use(async ({ input, ctx, next }) => { + const { orgShortCode } = input; const orgData = await validateOrgShortCode(orgShortCode); if (!orgData) { @@ -124,21 +112,22 @@ export const orgProcedure = trpcContext.procedure }); } - const accountId = opts.ctx.account.id; + const accountId = ctx.account.id; const orgMembership = orgData.members.find( (member) => member.accountId === accountId ); if (!accountId || !orgMembership) { + ctx.event.header('Location', '/'); throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You are not a member of this organization, redirecting...' }); } - return opts.next({ + return next({ ctx: { - ...opts.ctx, + ...ctx, org: { ...orgData, memberId: orgMembership.id } } }); diff --git a/apps/platform/tsconfig.json b/apps/platform/tsconfig.json index 605cfe57..7ac6ac11 100644 --- a/apps/platform/tsconfig.json +++ b/apps/platform/tsconfig.json @@ -1,3 +1,8 @@ { - "extends": ["@u22n/tsconfig"] + "extends": "@u22n/tsconfig", + "compilerOptions": { + "paths": { + "~platform/*": ["./*"] + } + } } diff --git a/apps/platform/utils/account.ts b/apps/platform/utils/account.ts index 6497f16c..d63315e7 100644 --- a/apps/platform/utils/account.ts +++ b/apps/platform/utils/account.ts @@ -1,10 +1,5 @@ -// import { db } from '@u22n/database'; -// import { and, eq } from '@u22n/database/orm'; -// import { orgMembers, orgs } from '@u22n/database/schema'; -// import type { TypeId } from '@u22n/utils'; -import type { OrgContext } from '../ctx'; +import type { OrgContext } from '~platform/ctx'; -// USED export async function isAccountAdminOfOrg(orgContext: OrgContext) { if (!orgContext?.memberId) return false; const accountOrgMembership = orgContext?.members.find((member) => { @@ -21,50 +16,3 @@ export async function isAccountAdminOfOrg(orgContext: OrgContext) { } return true; } - -//! FIX: Check if this is still used and if so, update it to use the orgMembers Cache -// export async function isAccountMemberOfOrg({ -// accountId, -// orgPublicId, -// orgId -// }: { -// accountId: number; -// orgPublicId?: TypeId<'org'>; -// orgId?: number; -// }): Promise<{ accountId: number; orgId: number; role: string }> { -// if (!orgPublicId && !orgId) { -// throw new Error('No orgPublicId or orgId provided'); -// } - -// // TODO: cache if user is member of org -// const orgMembersResponse = await db.query.orgMembers.findFirst({ -// where: and( -// eq( -// orgMembers.orgId, -// orgId -// ? orgId -// : orgPublicId -// ? db -// .select({ id: orgs.id }) -// .from(orgs) -// .where(eq(orgs.publicId, orgPublicId)) -// : 0 -// ), -// eq(orgMembers.accountId, accountId) -// ), -// columns: { -// orgId: true, -// role: true -// } -// }); -// if (!orgMembersResponse) { -// throw new Error('User not in org'); -// } -// // END TODO - -// return { -// accountId, -// orgId: orgMembersResponse.orgId, -// role: orgMembersResponse.role -// }; -// } diff --git a/apps/platform/utils/auth.ts b/apps/platform/utils/auth.ts index 0379a5e1..84e3de89 100644 --- a/apps/platform/utils/auth.ts +++ b/apps/platform/utils/auth.ts @@ -4,9 +4,9 @@ import { Lucia, TimeSpan } from 'lucia'; -import { UnInboxDBAdapter } from './auth/luciaDbAdaptor'; +import { UnInboxDBAdapter } from './auth/adapter'; import type { TypeId } from '@u22n/utils'; -import { env } from '../env'; +import { env } from '~platform/env'; const adapter = new UnInboxDBAdapter(); const devMode = env.NODE_ENV === 'development'; @@ -20,29 +20,8 @@ export const lucia = new Lucia(adapter, { domain: env.PRIMARY_DOMAIN } }, - getSessionAttributes: (attributes) => { - return { - account: attributes.account - }; - }, - getUserAttributes: (user) => { - const { - id, - publicId, - username, - passkeyEnabled, - passwordEnabled, - totpEnabled - } = user; - return { - id, - publicId, - username, - passwordEnabled, - totpEnabled, - passkeyEnabled - }; - } + getSessionAttributes: ({ account }) => ({ account }), + getUserAttributes: (user) => user }); declare module 'lucia' { @@ -79,17 +58,18 @@ export interface AuthSession { expiresAt: Date; } -export function luciaToAuthUser(user: DatabaseUser): AuthAccount { +export function luciaToAuthUser(user: DatabaseUser) { return { id: user.attributes.id, publicId: user.attributes.publicId, username: user.attributes.username - }; + } as AuthAccount; } -export function luciaToAuthSession(session: DatabaseSession): AuthSession { + +export function luciaToAuthSession(session: DatabaseSession) { return { sessionToken: session.id, account: session.attributes.account, expiresAt: session.expiresAt - }; + } as AuthSession; } diff --git a/apps/platform/utils/auth/adapter.ts b/apps/platform/utils/auth/adapter.ts new file mode 100644 index 00000000..0ec3670f --- /dev/null +++ b/apps/platform/utils/auth/adapter.ts @@ -0,0 +1,173 @@ +import type { Adapter, DatabaseSession } from 'lucia'; +import { db } from '@u22n/database'; +import { eq, inArray, lte } from '@u22n/database/orm'; +import { sessions, accounts } from '@u22n/database/schema'; +import { storage } from '~platform/storage'; +import { typeIdGenerator } from '@u22n/utils'; + +const sessionStorage = storage.session; + +export class UnInboxDBAdapter implements Adapter { + public async deleteSession(sessionId: string) { + await db + .delete(sessions) + .where(eq(sessions.sessionToken, sessionId)) + .execute(); + sessionStorage.removeItem(sessionId); + } + + public async deleteUserSessions(accountId: number) { + const accountObject = await db.query.accounts.findFirst({ + where: eq(accounts.id, accountId), + columns: { id: true }, + with: { + sessions: { + columns: { + sessionToken: true + } + } + } + }); + + if (!accountObject) return; + + const sessionIds = accountObject.sessions.map( + (session) => session.sessionToken + ); + + if (sessionIds.length > 0) { + await db + .delete(sessions) + .where(inArray(sessions.sessionToken, sessionIds)) + .execute(); + } + + await Promise.allSettled( + sessionIds.map((id) => sessionStorage.removeItem(id)) + ); + } + + public async getSessionAndUser(sessionId: string) { + return Promise.all([ + this.getSession(sessionId), + this.getUserFromSessionId(sessionId) + ]); + } + + public async getUserSessions(accountId: number) { + const accountSessions = await db.query.sessions.findMany({ + where: eq(sessions.accountId, accountId), + columns: { + sessionToken: true, + expiresAt: true, + device: true, + os: true + }, + with: { + account: { + columns: { + id: true, + username: true, + publicId: true + } + } + } + }); + + return accountSessions.map((session) => ({ + id: session.sessionToken, + userId: session.account.id, + expiresAt: session.expiresAt, + attributes: { + device: session.device, + os: session.os, + account: { + id: session.account.id, + publicId: session.account.publicId, + username: session.account.username + } + } + })); + } + + public async setSession(session: DatabaseSession) { + const accountId = session.attributes.account.id; + const accountPublicId = session.attributes.account.publicId; + const sessionPublicId = typeIdGenerator('accountSession'); + + await db.insert(sessions).values({ + publicId: sessionPublicId, + sessionToken: session.id, + accountPublicId: accountPublicId, + accountId: accountId, + device: session.attributes.device, + os: session.attributes.os, + expiresAt: session.expiresAt + }); + + await sessionStorage.setItem(session.id, session); + } + + public async updateSessionExpiration(sessionId: string, expiresAt: Date) { + await db + .update(sessions) + .set({ expiresAt: expiresAt }) + .where(eq(sessions.sessionToken, sessionId)); + const existingSession = await sessionStorage.getItem(sessionId); + + if (existingSession === null) return; + + sessionStorage.setItem(sessionId, existingSession, { + ttl: Math.ceil((expiresAt.getTime() - Date.now()) / 1000) + }); + } + + public async deleteExpiredSessions() { + await db.delete(sessions).where(lte(sessions.expiresAt, new Date())); + } + + private async getSession(sessionId: string) { + return await sessionStorage.getItem(sessionId); + } + + private async getUserFromSessionId(sessionId: string) { + const accountSessions = await db.query.sessions.findFirst({ + where: eq(sessions.sessionToken, sessionId), + columns: { + id: true + }, + with: { + account: { + columns: { + id: true, + username: true, + publicId: true, + twoFactorSecret: true, + passwordHash: true + }, + with: { + authenticators: { + columns: { + nickname: true + } + } + } + } + } + }); + + if (!accountSessions || !accountSessions.account) return null; + + return { + id: accountSessions.account.id, + attributes: { + id: accountSessions.account.id, + publicId: accountSessions.account.publicId, + username: accountSessions.account.username, + passkeyEnabled: accountSessions.account.authenticators.length > 0, + passwordEnabled: !!accountSessions.account.passwordHash, + totpEnabled: !!accountSessions.account.twoFactorSecret + } + }; + } +} diff --git a/apps/platform/utils/auth/luciaDbAdaptor.ts b/apps/platform/utils/auth/luciaDbAdaptor.ts deleted file mode 100644 index 930ddc5b..00000000 --- a/apps/platform/utils/auth/luciaDbAdaptor.ts +++ /dev/null @@ -1,235 +0,0 @@ -import type { Adapter, DatabaseSession, DatabaseUser } from 'lucia'; -import { db } from '@u22n/database'; -import { eq, inArray, lte } from '@u22n/database/orm'; -import { sessions, accounts } from '@u22n/database/schema'; -import { storage } from '../../storage'; -import { typeIdGenerator } from '@u22n/utils'; - -//! Enable debug logging -const debug = false; -const log = (...args: any[]) => { - if (debug) { - console.info('🔐 Lucia Auth DB Adapter', ...args); - } -}; - -export class UnInboxDBAdapter implements Adapter { - constructor() {} - - public async deleteSession(sessionId: string): Promise { - log('deleteSession', { sessionId }); - await db - .delete(sessions) - .where(eq(sessions.sessionToken, sessionId)) - .execute(); - - const sessionStorage = storage.session; - sessionStorage.removeItem(sessionId); - } - - public async deleteUserSessions(accountId: number): Promise { - log('deleteUserSessions', { accountId }); - const accountObject = await db.query.accounts.findFirst({ - where: eq(accounts.id, accountId), - columns: { id: true }, - with: { - sessions: { - columns: { - sessionToken: true - } - } - } - }); - log('deleteUserSessions', { accountObject }); - - if (!accountObject) { - return; - } - const sessionIds = accountObject?.sessions.map( - (session) => session.sessionToken - ); - - if (sessionIds && sessionIds.length > 0) { - await db - .delete(sessions) - .where(inArray(sessions.sessionToken, sessionIds)) - .execute(); - } - - const sessionStorage = storage.session; - sessionIds.forEach((id) => { - sessionStorage.removeItem(id); - }); - } - - public async getSessionAndUser( - sessionId: string - ): Promise<[session: DatabaseSession | null, user: DatabaseUser | null]> { - log('getSessionAndUser', { sessionId }); - //! verify this works - - const [databaseSession, databaseUser] = await Promise.all([ - this.getSession(sessionId), - this.getUserFromSessionId(sessionId) - ]); - log('getSessionAndUser', { databaseSession, databaseUser }); - return [databaseSession, databaseUser]; - } - - public async getUserSessions(accountId: number): Promise { - log('getUserSessions', { accountId }); - - const accountSessions = await db.query.sessions.findMany({ - where: eq(sessions.accountId, accountId), - columns: { - sessionToken: true, - expiresAt: true, - device: true, - os: true - }, - with: { - account: { - columns: { - id: true, - username: true, - publicId: true - } - } - } - }); - - log('getUserSessions', { accountSessions }); - - const results: DatabaseSession[] = []; - for (const session of accountSessions) { - results.push({ - id: session.sessionToken, - userId: session.account.id, - expiresAt: session.expiresAt, - attributes: { - device: session.device, - os: session.os, - account: { - id: session.account.id, - publicId: session.account.publicId, - username: session.account.username - } - } - }); - } - return results; - } - - public async setSession(session: DatabaseSession): Promise { - log('setSession', { session }); - const sessionStorage = storage.session; - const accountId = session.attributes.account.id; - const accountPublicId = session.attributes.account.publicId; - const sessionPublicId = typeIdGenerator('accountSession'); - - await db.insert(sessions).values({ - publicId: sessionPublicId, - sessionToken: session.id, - accountPublicId: accountPublicId, - accountId: accountId, - device: session.attributes.device, - os: session.attributes.os, - expiresAt: session.expiresAt - }); - - sessionStorage.setItem(session.id, session, { - ttl: - Math.ceil((session.expiresAt.getTime() - Date.now()) / 1000) || - 60 * 60 * 24 - }); - } - - public async updateSessionExpiration( - sessionId: string, - expiresAt: Date - ): Promise { - log('updateSessionExpiration', { sessionId, expiresAt }); - const sessionStorage = storage.session; - - await db - .update(sessions) - .set({ expiresAt: expiresAt }) - .where(eq(sessions.sessionToken, sessionId)) - .execute(); - - //! this needs to be tested - maybe it dosnt work - const existingSession: DatabaseSession | null = - await sessionStorage.getItem(sessionId); - log('updateSessionExpiration', { existingSession }); - if (existingSession === null) { - return; - } - sessionStorage.setItem(sessionId, existingSession, { - ttl: Math.ceil((expiresAt.getTime() - Date.now()) / 1000) || 60 * 60 * 24 - }); - return; - } - - public async deleteExpiredSessions(): Promise { - log('deleteExpiredSessions'); - await db - .delete(sessions) - .where(lte(sessions.expiresAt, new Date())) - .execute(); - } - - private async getSession(sessionId: string): Promise { - log('getSession', { sessionId }); - const sessionStorage = storage.session; - const sessionObject: DatabaseSession | null = - await sessionStorage.getItem(sessionId); - log('getSession', { sessionObject }); - return sessionObject; - } - - private async getUserFromSessionId( - sessionId: string - ): Promise { - log('getUserFromSessionId', { sessionId }); - const sessionToken = sessionId; - const accountSessions = await db.query.sessions.findFirst({ - where: eq(sessions.sessionToken, sessionToken), - columns: { - id: true - }, - with: { - account: { - columns: { - id: true, - username: true, - publicId: true, - twoFactorSecret: true, - passwordHash: true - }, - with: { - authenticators: { - columns: { - nickname: true - } - } - } - } - } - }); - log('getUserFromSessionId', { accountSessions }); - if (!accountSessions || !accountSessions.account) return null; - - const result: DatabaseUser = { - id: accountSessions.account.id, - attributes: { - id: accountSessions.account.id, - publicId: accountSessions.account.publicId, - username: accountSessions.account.username, - passkeyEnabled: accountSessions.account.authenticators.length > 0, - passwordEnabled: !!accountSessions.account.passwordHash, - totpEnabled: !!accountSessions.account.twoFactorSecret - } - }; - return result; - } -} diff --git a/apps/platform/utils/auth/passkeyDbAdaptor.ts b/apps/platform/utils/auth/passkeyUtils.ts similarity index 71% rename from apps/platform/utils/auth/passkeyDbAdaptor.ts rename to apps/platform/utils/auth/passkeyUtils.ts index 28d5632e..d075afe6 100644 --- a/apps/platform/utils/auth/passkeyDbAdaptor.ts +++ b/apps/platform/utils/auth/passkeyUtils.ts @@ -5,17 +5,18 @@ import type { AuthenticatorTransportFuture } from '@simplewebauthn/types'; import { isoBase64URL } from '@simplewebauthn/server/helpers'; import { typeIdGenerator } from '@u22n/utils'; -//! Enable debug logging -const debug = false; -const log = (...args: any[]) => { - if (debug) { - console.info('🔐 Passkey Auth DB Adapter', ...args); - } -}; - -//* Utils +export type CredentialDeviceType = 'singleDevice' | 'multiDevice'; +export interface Authenticator { + accountId: number; + credentialID: Uint8Array; + credentialPublicKey: Uint8Array; + counter: number; + credentialDeviceType: CredentialDeviceType; + credentialBackedUp: boolean; + transports?: AuthenticatorTransportFuture[]; +} -async function transformDbToAuthAuthenticator( +export async function transformDbToAuthAuthenticator( dbQuery: typeof authenticators.$inferInsert ): Promise { return { @@ -29,25 +30,12 @@ async function transformDbToAuthAuthenticator( }; } -//* Passkey DB -export type CredentialDeviceType = 'singleDevice' | 'multiDevice'; -export interface Authenticator { - accountId: number; - credentialID: Uint8Array; - credentialPublicKey: Uint8Array; - counter: number; - credentialDeviceType: CredentialDeviceType; - credentialBackedUp: boolean; - transports?: AuthenticatorTransportFuture[]; -} - -async function createAuthenticator( +export async function createAuthenticator( authenticator: Authenticator, nickname: string, // We use a default value for the db if not provided, so that we can also pass transactions passkeyDb = db ) { - log('passkey: createAuthenticator', { authenticator }); const b64ID = isoBase64URL.fromBuffer(authenticator.credentialID); const b64PK = isoBase64URL.fromBuffer(authenticator.credentialPublicKey); @@ -71,14 +59,10 @@ async function createAuthenticator( return authenticator; } -async function updateAuthenticatorCounter( - authenticator: Pick, +export async function updateAuthenticatorCounter( + authenticator: Authenticator, newCounter: number ) { - log('passkey: updateAuthenticatorCounter', { - authenticator, - newCounter - }); const b64ID = isoBase64URL.fromBuffer(authenticator.credentialID); const authenticatorObject = await db.query.authenticators.findFirst({ @@ -96,15 +80,13 @@ async function updateAuthenticatorCounter( }) .where(eq(authenticators.credentialID, b64ID)); - const updatedAuthenticator = { + return { ...authenticator, - counter: +newCounter + counter: newCounter } as Authenticator; - return updatedAuthenticator; } -async function getAuthenticator(credentialId: string) { - log('passkey: getAuthenticator', { credentialId }); +export async function getAuthenticator(credentialId: string) { const dbQuery = await db.query.authenticators.findFirst({ where: eq(authenticators.credentialID, credentialId), columns: { @@ -124,14 +106,10 @@ async function getAuthenticator(credentialId: string) { } }); if (!dbQuery) return null; - const decodedResult: Authenticator = - await transformDbToAuthAuthenticator(dbQuery); - - return decodedResult; + return await transformDbToAuthAuthenticator(dbQuery); } -async function deleteAuthenticator(credentialId: Uint8Array) { - log('passkey: deleteAuthenticator', { credentialId }); +export async function deleteAuthenticator(credentialId: Uint8Array) { const b64ID = isoBase64URL.fromBuffer(credentialId); const dbDeleteResult = await db @@ -141,10 +119,9 @@ async function deleteAuthenticator(credentialId: Uint8Array) { return dbDeleteResult.rowsAffected > 0; } -async function listAuthenticatorsByAccountCredentialId(accountId: number) { - log('passkey: listAuthenticatorsByAccountCredentialId', { - accountId - }); +export async function listAuthenticatorsByAccountCredentialId( + accountId: number +) { const dbQuery = await db.query.authenticators.findMany({ where: eq(authenticators.accountId, accountId), columns: { @@ -163,16 +140,10 @@ async function listAuthenticatorsByAccountCredentialId(accountId: number) { createdAt: true } }); - const decodedResults: Authenticator[] = await Promise.all( - dbQuery.map( - async (authenticator) => - await transformDbToAuthAuthenticator(authenticator) - ) - ); - return decodedResults; + return await Promise.all(dbQuery.map(transformDbToAuthAuthenticator)); } -async function listAuthenticatorsByAccountId(accountId: number) { - log('passkey: listAuthenticatorsByAccountId', { accountId }); + +export async function listAuthenticatorsByAccountId(accountId: number) { const dbQuery = await db.query.accounts.findFirst({ where: eq(accounts.id, accountId), columns: { @@ -199,20 +170,7 @@ async function listAuthenticatorsByAccountId(accountId: number) { } }); if (!dbQuery || !dbQuery.authenticators) return []; - const decodedResults: Authenticator[] = await Promise.all( - dbQuery.authenticators.map( - async (authenticator) => - await transformDbToAuthAuthenticator(authenticator) - ) + return await Promise.all( + dbQuery.authenticators.map(transformDbToAuthAuthenticator) ); - return decodedResults; } - -export const usePasskeysDb = { - createAuthenticator, - updateAuthenticatorCounter, - getAuthenticator, - deleteAuthenticator, - listAuthenticatorsByAccountCredentialId, - listAuthenticatorsByAccountId -}; diff --git a/apps/platform/utils/auth/passkeys.ts b/apps/platform/utils/auth/passkeys.ts index 36fd7991..e5e49fc7 100644 --- a/apps/platform/utils/auth/passkeys.ts +++ b/apps/platform/utils/auth/passkeys.ts @@ -1,18 +1,18 @@ -import { - generateRegistrationOptions as webAuthnGenerateRegistrationOptions, - verifyRegistrationResponse as webAuthnVerifyRegistrationResponse, - generateAuthenticationOptions as webAuthnGenerateAuthenticationOptions, - verifyAuthenticationResponse as webAuthnVerifyAuthenticationResponse -} from '@simplewebauthn/server'; +import * as webAuthn from '@simplewebauthn/server'; import type { RegistrationResponseJSON, PublicKeyCredentialDescriptorFuture, AuthenticationResponseJSON } from '@simplewebauthn/types'; -import { type Authenticator, usePasskeysDb } from './passkeyDbAdaptor'; +import { + type Authenticator, + getAuthenticator, + listAuthenticatorsByAccountCredentialId, + listAuthenticatorsByAccountId +} from './passkeyUtils'; import { TRPCError } from '@trpc/server'; -import { env } from '../../env'; -import { storage } from '../../storage'; +import { env } from '~platform/env'; +import { storage } from '~platform/storage'; type RegistrationOptions = { accountId?: number; @@ -22,22 +22,20 @@ type RegistrationOptions = { authenticatorAttachment?: 'platform' | 'cross-platform'; }; -async function generateRegistrationOptions(options: RegistrationOptions) { - const { - accountPublicId, - username, - userDisplayName, - // authenticatorAttachment, - accountId - } = options; +const authStorage = storage.auth; + +export async function generateRegistrationOptions( + options: RegistrationOptions +) { + const { accountPublicId, username, userDisplayName, accountId } = options; // We assume that the account is new if accountId is undefined, and we don't have any authenticators for them const accountAuthenticators: Authenticator[] = typeof accountId === 'undefined' ? [] - : await usePasskeysDb.listAuthenticatorsByAccountId(accountId); + : await listAuthenticatorsByAccountId(accountId); - const registrationOptions = await webAuthnGenerateRegistrationOptions({ + const registrationOptions = await webAuthn.generateRegistrationOptions({ rpName: env.APP_NAME, rpID: env.PRIMARY_DOMAIN, userID: accountPublicId, @@ -57,7 +55,6 @@ async function generateRegistrationOptions(options: RegistrationOptions) { } }); - const authStorage = storage.auth; authStorage.setItem( `passkeyChallenge: ${accountPublicId}`, registrationOptions.challenge @@ -66,14 +63,13 @@ async function generateRegistrationOptions(options: RegistrationOptions) { return registrationOptions; } -async function verifyRegistrationResponse({ +export async function verifyRegistrationResponse({ registrationResponse, publicId }: { registrationResponse: RegistrationResponseJSON; publicId: string; }) { - const authStorage = storage.auth; const expectedChallenge = await authStorage.getItem( `passkeyChallenge: ${publicId}` ); @@ -86,14 +82,13 @@ async function verifyRegistrationResponse({ }); } - const verifiedRegistrationResponse = await webAuthnVerifyRegistrationResponse( - { + const verifiedRegistrationResponse = + await webAuthn.verifyRegistrationResponse({ response: registrationResponse, expectedChallenge: expectedChallenge.toString(), expectedOrigin: env.WEBAPP_URL, expectedRPID: env.PRIMARY_DOMAIN - } - ); + }); await authStorage.removeItem(`passkeyChallenge: ${publicId}`); if (!verifiedRegistrationResponse.verified) { throw new Error('Registration verification failed'); @@ -101,7 +96,7 @@ async function verifyRegistrationResponse({ return verifiedRegistrationResponse; } -async function generateAuthenticationOptions({ +export async function generateAuthenticationOptions({ authChallengeId, accountId }: { @@ -112,7 +107,7 @@ async function generateAuthenticationOptions({ if (accountId) { const accountPasskeys = - await usePasskeysDb.listAuthenticatorsByAccountCredentialId(accountId); + await listAuthenticatorsByAccountCredentialId(accountId); for (const passkey of accountPasskeys) { credentials.push({ @@ -123,7 +118,7 @@ async function generateAuthenticationOptions({ } } - const authenticationOptions = await webAuthnGenerateAuthenticationOptions({ + const authenticationOptions = await webAuthn.generateAuthenticationOptions({ rpID: env.PRIMARY_DOMAIN, userVerification: 'preferred', timeout: 60000, @@ -131,24 +126,19 @@ async function generateAuthenticationOptions({ }); const userChallenge = authenticationOptions.challenge; - const authStorage = storage.auth; await authStorage.setItem(`authChallenge: ${authChallengeId}`, userChallenge); return authenticationOptions; } -async function verifyAuthenticationResponse({ +export async function verifyAuthenticationResponse({ authenticationResponse, - // expectedAllowedCredentials, authChallengeId }: { authenticationResponse: AuthenticationResponseJSON; authChallengeId: string; - expectedAllowedCredentials?: any; }) { - const authenticator = await usePasskeysDb.getAuthenticator( - authenticationResponse.id - ); + const authenticator = await getAuthenticator(authenticationResponse.id); if (!authenticator) { throw new TRPCError({ @@ -156,12 +146,12 @@ async function verifyAuthenticationResponse({ message: 'Authenticator not found' }); } - const authStorage = storage.auth; + const expectedChallenge = await authStorage.getItem( `authChallenge: ${authChallengeId}` ); - const verificationResult = await webAuthnVerifyAuthenticationResponse({ + const verificationResult = await webAuthn.verifyAuthenticationResponse({ response: authenticationResponse, expectedChallenge: expectedChallenge as string, expectedOrigin: env.WEBAPP_URL, @@ -174,10 +164,3 @@ async function verifyAuthenticationResponse({ accountId: authenticator.accountId }; } - -export const usePasskeys = { - generateRegistrationOptions, - verifyRegistrationResponse, - generateAuthenticationOptions, - verifyAuthenticationResponse -}; diff --git a/apps/platform/utils/mail/transactional.ts b/apps/platform/utils/mail/transactional.ts index 7fdb532f..197a3fb5 100644 --- a/apps/platform/utils/mail/transactional.ts +++ b/apps/platform/utils/mail/transactional.ts @@ -3,7 +3,7 @@ import { inviteTemplatePlainText, type InviteEmailProps } from './inviteTemplate'; -import { env } from '../../env'; +import { env } from '~platform/env'; type PostalResponse = | { diff --git a/apps/platform/utils/orgShortCode.ts b/apps/platform/utils/orgShortCode.ts index e7c895d7..d7612293 100644 --- a/apps/platform/utils/orgShortCode.ts +++ b/apps/platform/utils/orgShortCode.ts @@ -1,15 +1,13 @@ import { db } from '@u22n/database'; import { eq } from '@u22n/database/orm'; import { orgs } from '@u22n/database/schema'; -import type { OrgContext } from '../ctx'; -import { storage } from '../storage'; +import type { OrgContext } from '~platform/ctx'; +import { storage } from '~platform/storage'; -export const validateOrgShortCode = async (orgShortCode: string) => { - if (!orgShortCode) { - return null; - } +export async function validateOrgShortCode(orgShortCode: string) { + if (!orgShortCode) return null; - const cachedShortCodeOrgContext: OrgContext = + const cachedShortCodeOrgContext = await storage.orgContext.getItem(orgShortCode); if (cachedShortCodeOrgContext) { return cachedShortCodeOrgContext; @@ -42,9 +40,9 @@ export const validateOrgShortCode = async (orgShortCode: string) => { await storage.orgContext.setItem(orgShortCode, orgContext); return orgContext; -}; +} -export async function refreshOrgShortCodeCache(orgId: number): Promise { +export async function refreshOrgShortCodeCache(orgId: number) { const orgLookupResult = await db.query.orgs.findFirst({ where: eq(orgs.id, orgId), columns: { id: true, publicId: true, shortcode: true, name: true }, diff --git a/apps/platform/utils/realtime.ts b/apps/platform/utils/realtime.ts index 8e07cadf..36926c98 100644 --- a/apps/platform/utils/realtime.ts +++ b/apps/platform/utils/realtime.ts @@ -3,7 +3,7 @@ import { eq, inArray } from '@u22n/database/orm'; import { convoEntries, convoParticipants, convos } from '@u22n/database/schema'; import RealtimeServer from '@u22n/realtime/server'; import type { TypeId } from '@u22n/utils'; -import { env } from '../env'; +import { env } from '~platform/env'; export const realtime = new RealtimeServer({ appId: env.REALTIME_APP_ID, diff --git a/apps/platform/utils/session.ts b/apps/platform/utils/session.ts index a4386eb8..a9f9ac96 100644 --- a/apps/platform/utils/session.ts +++ b/apps/platform/utils/session.ts @@ -2,6 +2,10 @@ import { UAParser } from 'ua-parser-js'; import { lucia } from './auth'; import type { TypeId } from '@u22n/utils'; import type { Context } from 'hono'; +import { setCookie } from 'hono/cookie'; +import { db } from '@u22n/database'; +import { accounts } from '@u22n/database/schema'; +import { eq } from '@u22n/database/orm'; type SessionInfo = { accountId: number; @@ -9,13 +13,18 @@ type SessionInfo = { publicId: TypeId<'account'>; }; -export const createLuciaSessionCookie = async ( +/** + * Create a Lucia session cookie for given session info, set the cookie in for the event, update last login and return the cookie. + */ +export async function createLuciaSessionCookie( event: Context, info: SessionInfo -) => { - const { device, os } = UAParser(event.req.header('User-Agent')); +) { + const { device, os, browser } = UAParser(event.req.header('User-Agent')); const userDevice = - device.type === 'mobile' ? device.toString() : device.vendor; + device.type === 'mobile' + ? device.toString() + : device.vendor || device.model || device.type || 'Unknown'; const { accountId, username, publicId } = info; const accountSession = await lucia.createSession(accountId, { account: { @@ -23,9 +32,14 @@ export const createLuciaSessionCookie = async ( username, publicId }, - device: userDevice || 'Unknown', - os: os.name || 'Unknown' + device: userDevice, + os: `${browser.toString()} ${os.name || 'Unknown'}` }); const cookie = lucia.createSessionCookie(accountSession.id); + setCookie(event, cookie.name, cookie.value, cookie.attributes); + await db + .update(accounts) + .set({ lastLoginAt: new Date() }) + .where(eq(accounts.id, accountId)); return cookie; -}; +} diff --git a/apps/platform/utils/tRPCServerClients.ts b/apps/platform/utils/tRPCServerClients.ts index 25c56157..1d0942d6 100644 --- a/apps/platform/utils/tRPCServerClients.ts +++ b/apps/platform/utils/tRPCServerClients.ts @@ -3,7 +3,7 @@ import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import superjson from 'superjson'; import type { TrpcMailBridgeRouter } from '@u22n/mail-bridge/trpc'; import type { TrpcBillingRouter } from '@uninbox-ee/billing/trpc'; -import { env } from '../env'; +import { env } from '~platform/env'; export const mailBridgeTrpcClient = createTRPCProxyClient( { diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 238d23a4..f49663bb 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -3,6 +3,7 @@ /* Base Options: */ "esModuleInterop": true, "skipLibCheck": true, + "skipDefaultLibCheck": true, "target": "es2022", "allowJs": true, "resolveJsonModule": true, @@ -26,7 +27,9 @@ /* Path Aliases */ "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + // Make typescript know about the platform folder with its alias + "~platform/*": ["../platform/*"] } }, "include": [