diff --git a/packages/adders/lucia/index.ts b/packages/adders/lucia/index.ts index 9b5fcd4ed..0064c73a2 100644 --- a/packages/adders/lucia/index.ts +++ b/packages/adders/lucia/index.ts @@ -1,3 +1,4 @@ +import MagicString from 'magic-string'; import { colors, dedent, @@ -12,19 +13,13 @@ import { common, exports, imports, variables, object, functions, kit } from '@sv import type { AstTypes } from '@sveltejs/cli-core/js'; import { parseScript } from '@sveltejs/cli-core/parsers'; -const LUCIA_ADAPTER = { - mysql: 'DrizzleMySQLAdapter', - postgresql: 'DrizzlePostgreSQLAdapter', - sqlite: 'DrizzleSQLiteAdapter' -} as const; - const TABLE_TYPE = { mysql: 'mysqlTable', postgresql: 'pgTable', sqlite: 'sqliteTable' }; -type Dialect = keyof typeof LUCIA_ADAPTER; +type Dialect = 'mysql' | 'postgresql' | 'sqlite'; let drizzleDialect: Dialect; let schemaPath: string; @@ -43,8 +38,8 @@ export default defineAdder({ homepage: 'https://lucia-next.pages.dev', options, packages: [ - { name: 'lucia', version: '^3.2.0', dev: false }, - { name: '@lucia-auth/adapter-drizzle', version: '^1.1.0', dev: false }, + { name: '@oslojs/crypto', version: '^1.0.1', dev: false }, + { name: '@oslojs/encoding', version: '^1.1.0', dev: false }, // password hashing for demo { name: '@node-rs/argon2', @@ -85,7 +80,7 @@ export default defineAdder({ }, { name: () => schemaPath, - content: ({ content, options }) => { + content: ({ content, options, typescript }) => { const { ast, generateCode } = parseScript(content); const createTable = (name: string) => functions.call(TABLE_TYPE[drizzleDialect], [name]); @@ -138,7 +133,9 @@ export default defineAdder({ userId: common.expressionFromString( `text('user_id').notNull().references(() => user.id)` ), - expiresAt: common.expressionFromString(`integer('expires_at').notNull()`) + expiresAt: common.expressionFromString( + `integer('expires_at', { mode: 'timestamp' }).notNull()` + ) }); } if (drizzleDialect === 'mysql') { @@ -193,54 +190,139 @@ export default defineAdder({ ) }); } - return generateCode(); + + let code = generateCode(); + if (typescript) { + if (!code.includes('export type Session =')) { + code += '\n\nexport type Session = typeof session.$inferSelect;'; + } + if (!code.includes('export type User =')) { + code += '\n\nexport type User = typeof user.$inferSelect;'; + } + } + return code; } }, { name: ({ kit, typescript }) => `${kit?.libDirectory}/server/auth.${typescript ? 'ts' : 'js'}`, - content: ({ content, typescript, options }) => { + content: ({ content, typescript }) => { const { ast, generateCode } = parseScript(content); - const adapter = LUCIA_ADAPTER[drizzleDialect]; - imports.addNamed(ast, '$lib/server/db/schema.js', { user: 'user', session: 'session' }); + imports.addNamespace(ast, '$lib/server/db/schema', 'table'); imports.addNamed(ast, '$lib/server/db', { db: 'db' }); - imports.addNamed(ast, 'lucia', { Lucia: 'Lucia' }); - imports.addNamed(ast, '@lucia-auth/adapter-drizzle', { [adapter]: adapter }); - imports.addNamed(ast, '$app/environment', { dev: 'dev' }); + imports.addNamed(ast, '@oslojs/encoding', { + encodeBase32LowerCaseNoPadding: 'encodeBase32LowerCaseNoPadding', + encodeHexLowerCase: 'encodeHexLowerCase' + }); + imports.addNamed(ast, '@oslojs/crypto/random', { + generateRandomString: 'generateRandomString' + }); + imports.addNamed(ast, '@oslojs/crypto/sha2', { sha256: 'sha256' }); + imports.addNamed(ast, 'drizzle-orm', { eq: 'eq' }); - // adapter - const adapterDecl = common.statementFromString( - `const adapter = new ${adapter}(db, session, user);` - ); - common.addStatement(ast, adapterDecl); - - // lucia export - const luciaInit = common.expressionFromString(` - new Lucia(adapter, { - sessionCookie: { - attributes: { - secure: !dev + const ms = new MagicString(generateCode().trim()); + const [ts] = utils.createPrinter(typescript); + + if (!ms.original.includes('const DAY_IN_MS')) { + ms.append('\n\nconst DAY_IN_MS = 1000 * 60 * 60 * 24;'); + } + if (!ms.original.includes('export const sessionCookieName')) { + ms.append("\n\nexport const sessionCookieName = 'auth-session';"); + } + if (!ms.original.includes('function generateSessionToken')) { + const generateSessionToken = dedent` + ${ts('', '/** @returns {string} */')} + function generateSessionToken()${ts(': string')} { + const bytes = crypto.getRandomValues(new Uint8Array(20)); + const token = encodeBase32LowerCaseNoPadding(bytes); + return token; + }`; + ms.append(`\n\n${generateSessionToken}`); + } + if (!ms.original.includes('function generateId')) { + const generateId = dedent` + ${ts('', '/**')} + ${ts('', ' * @param {number} length')} + ${ts('', ' * @returns {string}')} + ${ts('', ' */')} + export function generateId(length${ts(': number')})${ts(': string')} { + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; + return generateRandomString({ read: (bytes) => crypto.getRandomValues(bytes) }, alphabet, length); + }`; + ms.append(`\n\n${generateId}`); + } + if (!ms.original.includes('async function createSession')) { + const createSession = dedent` + ${ts('', '/** @param {string} userId */')} + export async function createSession(userId${ts(': string')})${ts(': Promise')} { + const token = generateSessionToken(); + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const session${ts(': table.Session')} = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + DAY_IN_MS * 30) + }; + await db.insert(table.session).values(session); + return session; + }`; + ms.append(`\n\n${createSession}`); + } + if (!ms.original.includes('async function invalidateSession')) { + const invalidateSession = dedent` + ${ts('', '/**')} + ${ts('', ' * @param {string} sessionId')} + ${ts('', ' * @returns {Promise}')} + ${ts('', ' */')} + export async function invalidateSession(sessionId${ts(': string')})${ts(': Promise')} { + await db.delete(table.session).where(eq(table.session.id, sessionId)); + }`; + ms.append(`\n\n${invalidateSession}`); + } + if (typescript && !ms.original.includes('export type SessionValidationResult')) { + const sessionType = + 'export type SessionValidationResult = Awaited>;'; + ms.append(`\n\n${sessionType}`); + } + if (!ms.original.includes('async function validateSession')) { + const validateSession = dedent` + ${ts('', '/** @param {string} sessionId */')} + export async function validateSession(sessionId${ts(': string')}) { + const [result] = await db + .select({ + // Adjust user table here to tweak returned data + user: { id: table.user.id, username: table.user.username }, + session: table.session + }) + .from(table.session) + .innerJoin(table.user, eq(table.session.userId, table.user.id)) + .where(eq(table.session.id, sessionId)); + + if (!result) { + return { session: null, user: null }; } - }, - ${options.demo ? 'getUserAttributes: (attributes) => ({ username: attributes.username })' : ''} - })`); - const luciaDecl = variables.declaration(ast, 'const', 'lucia', luciaInit); - exports.namedExport(ast, 'lucia', luciaDecl); - - // module declaration - if (typescript && !/declare module ["']lucia["']/.test(content)) { - const moduleDecl = common.statementFromString(` - declare module 'lucia' { - interface Register { - Lucia: typeof lucia; - // attributes that are already included are omitted - DatabaseUserAttributes: Omit; - DatabaseSessionAttributes: Omit; + const { session, user } = result; + + const sessionExpired = Date.now() >= session.expiresAt.getTime(); + if (sessionExpired) { + await db.delete(table.session).where(eq(table.session.id, session.id)); + return { session: null, user: null }; + } + + const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15; + if (renewSession) { + session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30); + await db + .update(table.session) + .set({ expiresAt: session.expiresAt }) + .where(eq(table.session.id, session.id)); } - }`); - common.addStatement(ast, moduleDecl); + + return { session, user }; + }`; + ms.append(`\n\n${validateSession}`); } - return generateCode(); + + return ms.toString(); } }, { @@ -270,7 +352,8 @@ export default defineAdder({ name: ({ typescript }) => `src/hooks.server.${typescript ? 'ts' : 'js'}`, content: ({ content, typescript }) => { const { ast, generateCode } = parseScript(content); - imports.addNamed(ast, '$lib/server/auth.js', { lucia: 'lucia' }); + imports.addNamespace(ast, '$lib/server/auth.js', 'auth'); + imports.addNamed(ast, '$app/environment', { dev: 'dev' }); kit.addHooksHandle(ast, typescript, 'auth', getAuthHandleContent()); return generateCode(); } @@ -281,25 +364,23 @@ export default defineAdder({ name: ({ kit, typescript }) => `${kit!.routesDirectory}/demo/login/+page.server.${typescript ? 'ts' : 'js'}`, condition: ({ options }) => options.demo, - content({ content, typescript }) { + content({ content, typescript, kit }) { if (content) { - log.warn( - `Existing ${colors.yellow('/demo/login/+page.server.[js|ts]')} file. Could not update.` - ); + const filePath = `${kit!.routesDirectory}/demo/login/+page.server.${typescript ? 'ts' : 'js'}`; + log.warn(`Existing ${colors.yellow(filePath)} file. Could not update.`); return content; } const [ts] = utils.createPrinter(typescript); return dedent` + import { dev } from '$app/environment'; import { fail, redirect } from '@sveltejs/kit'; import { hash, verify } from '@node-rs/argon2'; import { eq } from 'drizzle-orm'; - import { generateId } from 'lucia'; - import { lucia } from '$lib/server/auth'; import { db } from '$lib/server/db'; - import { user } from '$lib/server/db/schema.js'; - ${ts(`import type { Actions, PageServerLoad } from './$types';`)} - + import * as auth from '$lib/server/auth'; + import * as table from '$lib/server/db/schema'; + ${ts(`import type { Actions, PageServerLoad } from './$types';\n`)} export const load${ts(': PageServerLoad')} = async (event) => { if (event.locals.user) { return redirect(302, '/demo'); @@ -314,26 +395,20 @@ export default defineAdder({ const password = formData.get('password'); if (!validateUsername(username)) { - return fail(400, { - message: 'Invalid username', - }); + return fail(400, { message: 'Invalid username' }); } if (!validatePassword(password)) { - return fail(400, { - message: 'Invalid password', - }); + return fail(400, { message: 'Invalid password' }); } const results = await db .select() - .from(user) - .where(eq(user.username, username)); + .from(table.user) + .where(eq(table.user.username, username)); const existingUser = results.at(0); if (!existingUser) { - return fail(400, { - message: 'Incorrect username or password', - }); + return fail(400, { message: 'Incorrect username or password' }); } const validPassword = await verify(existingUser.passwordHash, password, { @@ -343,16 +418,16 @@ export default defineAdder({ parallelism: 1, }); if (!validPassword) { - return fail(400, { - message: 'Incorrect username or password', - }); + return fail(400, { message: 'Incorrect username or password' }); } - const session = await lucia.createSession(existingUser.id, {}); - const sessionCookie = lucia.createSessionCookie(session.id); - event.cookies.set(sessionCookie.name, sessionCookie.value, { + const session = await auth.createSession(existingUser.id); + event.cookies.set(auth.sessionCookieName, session.id, { path: '/', - ...sessionCookie.attributes, + sameSite: 'lax', + httpOnly: true, + expires: session.expiresAt, + secure: !dev }); return redirect(302, '/demo'); @@ -363,16 +438,13 @@ export default defineAdder({ const password = formData.get('password'); if (!validateUsername(username)) { - return fail(400, { - message: 'Invalid username', - }); + return fail(400, { message: 'Invalid username' }); } if (!validatePassword(password)) { - return fail(400, { - message: 'Invalid password', - }); + return fail(400, { message: 'Invalid password' }); } + const userId = auth.generateId(15); const passwordHash = await hash(password, { // recommended minimum parameters memoryCost: 19456, @@ -380,31 +452,26 @@ export default defineAdder({ outputLen: 32, parallelism: 1, }); - const userId = generateId(15); try { - await db.insert(user).values({ - id: userId, - username, - passwordHash, - }); + await db.insert(table.user).values({ id: userId, username, passwordHash }); - const session = await lucia.createSession(userId, {}); - const sessionCookie = lucia.createSessionCookie(session.id); - event.cookies.set(sessionCookie.name, sessionCookie.value, { + const session = await auth.createSession(userId); + event.cookies.set(auth.sessionCookieName, session.id, { path: '/', - ...sessionCookie.attributes, + sameSite: 'lax', + httpOnly: true, + expires: session.expiresAt, + secure: !dev }); } catch (e) { - return fail(500, { - message: 'An error has occurred', - }); + return fail(500, { message: 'An error has occurred' }); } return redirect(302, '/demo'); }, }; - function validateUsername(username${ts(': unknown): username is string', ')')} { + function validateUsername(username${ts(': unknown')})${ts(': username is string')} { return ( typeof username === 'string' && username.length >= 3 && @@ -413,7 +480,7 @@ export default defineAdder({ ); } - function validatePassword(password${ts(': unknown): password is string', ')')} { + function validatePassword(password${ts(': unknown')})${ts(': password is string')} { return ( typeof password === 'string' && password.length >= 6 && @@ -426,9 +493,10 @@ export default defineAdder({ { name: ({ kit }) => `${kit!.routesDirectory}/demo/login/+page.svelte`, condition: ({ options }) => options.demo, - content({ content, typescript }) { + content({ content, typescript, kit }) { if (content) { - log.warn(`Existing ${colors.yellow('/demo/login/+page.svelte')} file. Could not update.`); + const filePath = `${kit!.routesDirectory}/demo/login/+page.svelte`; + log.warn(`Existing ${colors.yellow(filePath)} file. Could not update.`); return content; } @@ -436,8 +504,7 @@ export default defineAdder({ return dedent` @@ -463,27 +530,23 @@ export default defineAdder({ name: ({ kit, typescript }) => `${kit!.routesDirectory}/demo/+page.server.${typescript ? 'ts' : 'js'}`, condition: ({ options }) => options.demo, - content({ content, typescript }) { + content({ content, typescript, kit }) { if (content) { - log.warn( - `Existing ${colors.yellow('/demo/+page.server.[js|ts]')} file. Could not update.` - ); + const filePath = `${kit!.routesDirectory}/demo/+page.server.${typescript ? 'ts' : 'js'}`; + log.warn(`Existing ${colors.yellow(filePath)} file. Could not update.`); return content; } const [ts] = utils.createPrinter(typescript); return dedent` - import { lucia } from '$lib/server/auth'; + import * as auth from '$lib/server/auth'; import { fail, redirect } from '@sveltejs/kit'; - ${ts(`import type { Actions, PageServerLoad } from './$types';`)} - + ${ts(`import type { Actions, PageServerLoad } from './$types';\n`)} export const load${ts(': PageServerLoad')} = async (event) => { if (!event.locals.user) { return redirect(302, '/demo/login'); } - return { - user: event.locals.user, - }; + return { user: event.locals.user }; }; export const actions${ts(': Actions')} = { @@ -491,12 +554,9 @@ export default defineAdder({ if (!event.locals.session) { return fail(401); } - await lucia.invalidateSession(event.locals.session.id); - const sessionCookie = lucia.createBlankSessionCookie(); - event.cookies.set(sessionCookie.name, sessionCookie.value, { - path: '/', - ...sessionCookie.attributes, - }); + await auth.invalidateSession(event.locals.session.id); + event.cookies.delete(auth.sessionCookieName, { path: '/' }); + return redirect(302, '/demo/login'); }, }; @@ -506,9 +566,10 @@ export default defineAdder({ { name: ({ kit }) => `${kit!.routesDirectory}/demo/+page.svelte`, condition: ({ options }) => options.demo, - content({ content, typescript }) { + content({ content, typescript, kit }) { if (content) { - log.warn(`Existing ${colors.yellow('/demo/+page.svelte')} file. Could not update.`); + const filePath = `${kit!.routesDirectory}/demo/+page.svelte`; + log.warn(`Existing ${colors.yellow(filePath)} file. Could not update.`); return content; } @@ -516,8 +577,7 @@ export default defineAdder({ return dedent` @@ -550,21 +610,22 @@ function createLuciaType(name: string): AstTypes.TSInterfaceBody['body'][number] typeAnnotation: { type: 'TSTypeAnnotation', typeAnnotation: { - type: 'TSUnionType', - types: [ - { - type: 'TSImportType', - argument: { type: 'StringLiteral', value: 'lucia' }, - qualifier: { - type: 'Identifier', - // capitalize first letter - name: `${name[0]!.toUpperCase()}${name.slice(1)}` - } - }, - { - type: 'TSNullKeyword' + type: 'TSIndexedAccessType', + objectType: { + type: 'TSImportType', + argument: { type: 'StringLiteral', value: '$lib/server/auth' }, + qualifier: { + type: 'Identifier', + name: 'SessionValidationResult' + } + }, + indexType: { + type: 'TSLiteralType', + literal: { + type: 'StringLiteral', + value: name } - ] + } } } }; @@ -573,25 +634,24 @@ function createLuciaType(name: string): AstTypes.TSInterfaceBody['body'][number] function getAuthHandleContent() { return ` async ({ event, resolve }) => { - const sessionId = event.cookies.get(lucia.sessionCookieName); + const sessionId = event.cookies.get(auth.sessionCookieName); if (!sessionId) { event.locals.user = null; event.locals.session = null; return resolve(event); } - const { session, user } = await lucia.validateSession(sessionId); - if (!session) { - event.cookies.delete(lucia.sessionCookieName, { path: '/' }); - } - - if (session?.fresh) { - const sessionCookie = lucia.createSessionCookie(session.id); - - event.cookies.set(sessionCookie.name, sessionCookie.value, { + const { session, user } = await auth.validateSession(sessionId); + if (session) { + event.cookies.set(auth.sessionCookieName, session.id, { path: '/', - ...sessionCookie.attributes, + sameSite: 'lax', + httpOnly: true, + expires: session.expiresAt, + secure: !dev }); + } else { + event.cookies.delete(auth.sessionCookieName, { path: '/' }); } event.locals.user = user; diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index 149a1b85f..25f4944a0 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -25,7 +25,7 @@ const AddersSchema = v.array(v.string()); const AdderOptionFlagsSchema = v.object({ tailwindcss: v.optional(v.array(v.string())), drizzle: v.optional(v.array(v.string())), - supabase: v.optional(v.array(v.string())), + lucia: v.optional(v.array(v.string())), paraglide: v.optional(v.array(v.string())) }); const OptionsSchema = v.strictObject({ diff --git a/packages/core/tests/js/imports/namespaced-import/output.ts b/packages/core/tests/js/imports/namespaced-import/output.ts new file mode 100644 index 000000000..56eda1b3d --- /dev/null +++ b/packages/core/tests/js/imports/namespaced-import/output.ts @@ -0,0 +1,2 @@ +import * as bar from './some-file'; +import * as foo from 'package'; \ No newline at end of file diff --git a/packages/core/tests/js/imports/namespaced-import/run.ts b/packages/core/tests/js/imports/namespaced-import/run.ts new file mode 100644 index 000000000..f46a38afc --- /dev/null +++ b/packages/core/tests/js/imports/namespaced-import/run.ts @@ -0,0 +1,9 @@ +import { imports, type AstTypes } from '@sveltejs/cli-core/js'; + +export function run({ ast }: { ast: AstTypes.Program }): void { + imports.addNamespace(ast, 'package', 'foo'); + + imports.addNamespace(ast, './some-file', 'bar'); + // adding the same import twice should not produce two imports + imports.addNamespace(ast, './some-file', 'bar'); +} diff --git a/packages/core/tooling/js/imports.ts b/packages/core/tooling/js/imports.ts index 7abd01ad9..a0dd0fcf5 100644 --- a/packages/core/tooling/js/imports.ts +++ b/packages/core/tooling/js/imports.ts @@ -14,6 +14,21 @@ export function addEmpty(ast: AstTypes.Program, importFrom: string): void { addImportIfNecessary(ast, expectedImportDeclaration); } +export function addNamespace(ast: AstTypes.Program, importFrom: string, importAs: string): void { + const expectedImportDeclaration: AstTypes.ImportDeclaration = { + type: 'ImportDeclaration', + source: { type: 'Literal', value: importFrom }, + specifiers: [ + { + type: 'ImportNamespaceSpecifier', + local: { type: 'Identifier', name: importAs } + } + ] + }; + + addImportIfNecessary(ast, expectedImportDeclaration); +} + export function addDefault(ast: AstTypes.Program, importFrom: string, importAs: string): void { const expectedImportDeclaration: AstTypes.ImportDeclaration = { type: 'ImportDeclaration',