diff --git a/package.json b/package.json index f4ec1383600febcd493bf20a6911e22b7b350eb9..16142e8280803f66a40f8d862132a98cbd79ec67 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,12 @@ "!dist/**/*.spec.*" ], "peerDependencies": { - "@lucia-auth/adapter-sqlite": "^3.0.1", + "@lucia-auth/adapter-drizzle": "1", + "@lucia-auth/adapter-sqlite": "3", "@sveltejs/kit": "2", - "better-sqlite3": "^9.4.0 || ^11.0.0", - "lucia": "^3.2.0", + "better-sqlite3": "8 || 9", + "drizzle-orm": "0.33", + "lucia": "3", "svelte": "4" }, "peerDependenciesMeta": { @@ -51,6 +53,12 @@ }, "better-sqlite3": { "optional": true + }, + "@lucia-auth/adapter-drizzle": { + "optional": true + }, + "drizzle-orm": { + "optional": true } }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f97fa93d687935247c5b356d337714aac330f4bd..e1fcdc53c39f8d053c54c27e468bd19639dfd270 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,15 @@ importers: .: dependencies: + '@lucia-auth/adapter-drizzle': + specifier: '1' + version: 1.1.0(drizzle-orm@0.33.0(@types/better-sqlite3@7.6.9)(better-sqlite3@9.6.0))(lucia@3.2.0) better-sqlite3: - specifier: ^9.4.0 || ^11.0.0 - version: 11.0.0 + specifier: 8 || 9 + version: 9.6.0 + drizzle-orm: + specifier: '0.33' + version: 0.33.0(@types/better-sqlite3@7.6.9)(better-sqlite3@9.6.0) openid-client: specifier: ^5.6.4 version: 5.6.4 @@ -20,7 +26,7 @@ importers: devDependencies: '@lucia-auth/adapter-sqlite': specifier: ^3.0.1 - version: 3.0.1(better-sqlite3@11.0.0)(lucia@3.2.0) + version: 3.0.1(better-sqlite3@9.6.0)(lucia@3.2.0) '@sveltejs/adapter-node': specifier: ^5.0.0 version: 5.0.1(@sveltejs/kit@2.5.0(@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.0.12(@types/node@20.14.0)))(svelte@4.2.10)(vite@5.0.12(@types/node@20.14.0))) @@ -308,6 +314,12 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@lucia-auth/adapter-drizzle@1.1.0': + resolution: {integrity: sha512-iCTnZWvfI5lLZOdUHZYiXA1jaspIFEeo2extLxQ3DjP3uOVys7IPwBi7zezLIRu9dhro4H4Kji+7gSYyjcef2A==} + peerDependencies: + drizzle-orm: '>= 0.29 <1' + lucia: 3.x + '@lucia-auth/adapter-sqlite@3.0.1': resolution: {integrity: sha512-bzr8+HALrbiYMb/+oL1SAnjbgFqlPs/Kj4lO57t/VvbXzmbpQEKk5Nv6hMpvWSkGAR9LbxYeQAtecikpKZVB0w==} peerDependencies: @@ -789,8 +801,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - better-sqlite3@11.0.0: - resolution: {integrity: sha512-1NnNhmT3EZTsKtofJlMox1jkMxdedILury74PwUbQBjWgo4tL4kf7uTAjU55mgQwjdzqakSTjkf+E1imrFwjnA==} + better-sqlite3@9.6.0: + resolution: {integrity: sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==} binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} @@ -930,6 +942,95 @@ packages: devalue@4.3.2: resolution: {integrity: sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==} + drizzle-orm@0.33.0: + resolution: {integrity: sha512-SHy72R2Rdkz0LEq0PSG/IdvnT3nGiWuRk+2tXZQ90GVq/XQhpCzu/EFT3V2rox+w8MlkBQxifF8pCStNYnERfA==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=3' + '@electric-sql/pglite': '>=0.1.1' + '@libsql/client': '*' + '@neondatabase/serverless': '>=0.1' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/react': '>=18' + '@types/sql.js': '*' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=13.2.0' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + react: '>=18' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/react': + optional: true + '@types/sql.js': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + react: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} @@ -2005,11 +2106,16 @@ snapshots: '@jridgewell/resolve-uri': 3.1.1 '@jridgewell/sourcemap-codec': 1.5.0 - '@lucia-auth/adapter-sqlite@3.0.1(better-sqlite3@11.0.0)(lucia@3.2.0)': + '@lucia-auth/adapter-drizzle@1.1.0(drizzle-orm@0.33.0(@types/better-sqlite3@7.6.9)(better-sqlite3@9.6.0))(lucia@3.2.0)': + dependencies: + drizzle-orm: 0.33.0(@types/better-sqlite3@7.6.9)(better-sqlite3@9.6.0) + lucia: 3.2.0 + + '@lucia-auth/adapter-sqlite@3.0.1(better-sqlite3@9.6.0)(lucia@3.2.0)': dependencies: lucia: 3.2.0 optionalDependencies: - better-sqlite3: 11.0.0 + better-sqlite3: 9.6.0 '@node-rs/argon2-android-arm-eabi@1.7.0': optional: true @@ -2470,7 +2576,7 @@ snapshots: base64-js@1.5.1: {} - better-sqlite3@11.0.0: + better-sqlite3@9.6.0: dependencies: bindings: 1.5.0 prebuild-install: 7.1.1 @@ -2601,6 +2707,11 @@ snapshots: devalue@4.3.2: {} + drizzle-orm@0.33.0(@types/better-sqlite3@7.6.9)(better-sqlite3@9.6.0): + optionalDependencies: + '@types/better-sqlite3': 7.6.9 + better-sqlite3: 9.6.0 + end-of-stream@1.4.4: dependencies: once: 1.4.0 diff --git a/src/app.d.ts b/src/app.d.ts index d5b5ecf20e7be0f9ceaf79d2abbfdc54e65bf651..663d8612f2db4a8322092fd200ee1261ad5ca492 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,12 +1,9 @@ // See https://kit.svelte.dev/docs/types#app -import { Locals as ILocals } from "$lib/types.ts"; +import { AIDCLocals } from "@arise/aidc-sveltekit/types.ts"; declare global { namespace App { - interface Locals extends ILocals { - user: import("lucia").User | null; - session: import("lucia").Session | null; - } + interface Locals extends AIDCLocals {} } } diff --git a/src/auth.ts b/src/auth.ts index 9c41a2f67d2016d4a5c7548a207ca91b082f2ec6..f91758a65a5444e2ba9d6e68b8d2b26e95d1edce 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,13 +1,13 @@ import { env } from "$env/dynamic/private"; -import { AriseIdConnect } from "$lib/index.js"; -import { sqliteMemoryAdapter } from "$lib/adapters/sqlite-memory.js"; +import { AriseIdConnect } from "@arise/aidc-sveltekit"; +import { adapter } from "@arise/aidc-sveltekit/adapters/sqlite-memory-drizzle.js"; export const aidc = await AriseIdConnect.init({ client_id: env.AIDC_CLIENT_ID!, client_secret: env.AIDC_CLIENT_SECRET!, scope: "openid offline profile", - adapter: sqliteMemoryAdapter, + adapter, // issuer: "http://localhost:4444/.well-known/openid-configuration" }); -sqliteMemoryAdapter.initDatabase(); +adapter.initDatabase(); diff --git a/src/lib/adapters/abstract.ts b/src/lib/adapters/abstract.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc74134d18139680f2ae801f6e85f65c438bd0fa --- /dev/null +++ b/src/lib/adapters/abstract.ts @@ -0,0 +1,54 @@ +import { dev } from "$app/environment"; +import type { Empty } from "$lib/types.js"; +import type { MaybePromise } from "@sveltejs/kit"; +import type { + Adapter, + RegisteredDatabaseSessionAttributes, + RegisteredDatabaseUserAttributes, + Session, + SessionCookieOptions, + TimeSpan, + UserId, +} from "lucia"; +import { Lucia } from "lucia"; +import type { TokenSet, UserinfoResponse } from "openid-client"; + +export abstract class LuciaAdapter< + _SessionAttributes extends Empty = Empty, + _UserAttributes extends Empty = Empty, +> extends Lucia<_SessionAttributes, _UserAttributes> { + constructor( + adapter: Adapter, + options?: { + sessionExpiresIn?: TimeSpan; + sessionCookie?: SessionCookieOptions; + getSessionAttributes?: ( + databaseSessionAttributes: RegisteredDatabaseSessionAttributes, + ) => _SessionAttributes; + getUserAttributes?: ( + databaseUserAttributes: RegisteredDatabaseUserAttributes, + ) => _UserAttributes; + }, + ) { + super(adapter, { + sessionCookie: { + attributes: { + secure: !dev, + }, + name: "aidc_session", + }, + ...options, + }); + } + + abstract getUserId(subject: string): MaybePromise<string | undefined>; + abstract newUser( + subject: string, + userId: string, + claims: UserinfoResponse, + ): MaybePromise<string>; + abstract newSession(userId: UserId, tokenSet: TokenSet): Promise<Session>; + abstract getIdToken( + session: Session | null, + ): MaybePromise<string | undefined>; +} diff --git a/src/lib/adapters/sqlite-memory-drizzle.ts b/src/lib/adapters/sqlite-memory-drizzle.ts new file mode 100644 index 0000000000000000000000000000000000000000..26d923ed7f51f8d1d4607e06cb6b58d75db16799 --- /dev/null +++ b/src/lib/adapters/sqlite-memory-drizzle.ts @@ -0,0 +1,113 @@ +import { LuciaAdapter } from "./abstract.js"; +import type { IdTokenClaims, TokenSet, UserinfoResponse } from "openid-client"; +import { DrizzleSQLiteAdapter } from "@lucia-auth/adapter-drizzle"; +import sqlite from "better-sqlite3"; +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { + drizzle, + type BetterSQLite3Database, +} from "drizzle-orm/better-sqlite3"; +import { eq, sql } from "drizzle-orm"; +import type { Session, UserId } from "lucia"; +import type { MaybePromise } from "@sveltejs/kit"; +import type { Empty } from "../types.js"; + +const userTable = sqliteTable("user", { + id: text("id").primaryKey(), + subject: text("subject").notNull().unique(), + claims: text("claims").notNull(), +}); +const sessionTable = sqliteTable("session", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => userTable.id), + expiresAt: integer("expires_at").notNull(), + idToken: text("id_token").notNull(), +}); + +class SqliteDrizzleAdapter extends LuciaAdapter<Empty, User> { + db: BetterSQLite3Database; + + constructor() { + const sqliteDB = sqlite(":memory:"); + const db = drizzle(sqliteDB); + + const adapter = new DrizzleSQLiteAdapter(db, sessionTable, userTable); + + super(adapter, { + getUserAttributes(attributes) { + return { + subject: attributes.subject, + claims: JSON.parse(attributes.claims), + }; + }, + }); + + this.db = db; + } + + initDatabase() { + this.db.run(sql`CREATE TABLE IF NOT EXISTS user ( + id TEXT NOT NULL PRIMARY KEY, + subject TEXT NOT NULL UNIQUE, + claims TEXT NOT NULL + )`); + + this.db.run(sql`CREATE TABLE IF NOT EXISTS session ( + id TEXT NOT NULL PRIMARY KEY, + expires_at INTEGER NOT NULL, + user_id TEXT NOT NULL, + id_token TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES user(id) + )`); + } + + async getUserId(subject: string) { + const user = await this.db + .select() + .from(userTable) + .where((user) => eq(user.subject, subject)); + + return user.at(0)?.id; + } + + async newUser(subject: string, userId: string, claims: IdTokenClaims) { + await this.db + .insert(userTable) + .values([{ id: userId, subject, claims: JSON.stringify(claims) }]); + return userId; + } + + newSession( + userId: UserId, + tokenSet: TokenSet & { id_token: string }, + ): Promise<Session> { + return this.createSession(userId, { + idToken: tokenSet.id_token, + }); + } + + getIdToken(session: Session): MaybePromise<string | undefined> { + return session.idToken; + } +} + +export const adapter = new SqliteDrizzleAdapter(); + +interface User { + subject: string; + claims: UserinfoResponse; +} +interface ISession { + idToken: string; +} + +declare module "@arise/aidc-sveltekit" { + interface Types { + User: User; + Session: ISession; + DatabaseUserAttributes: typeof userTable.$inferSelect; + DatabaseSessionAttributes: typeof sessionTable.$inferSelect; + } +} diff --git a/src/lib/adapters/sqlite-memory.ts b/src/lib/adapters/sqlite-memory.ts index b9c63ed4153f6f664bbf3f0244a9184842ec1606..4114ce1a5122bd3b67f829f631b6c8b45b7b9df7 100644 --- a/src/lib/adapters/sqlite-memory.ts +++ b/src/lib/adapters/sqlite-memory.ts @@ -1,18 +1,14 @@ import { BetterSqlite3Adapter } from "@lucia-auth/adapter-sqlite"; import sqlite from "better-sqlite3"; import type { Database as SqLiteConnection } from "better-sqlite3"; -import { - LuciaAdapter, - type DefaultSessionAttributes, - type DefaultUserAttributes, -} from "../lucia.js"; -import type { IdTokenClaims, UserinfoResponse } from "openid-client"; +import { LuciaAdapter } from "./abstract.js"; +import type { IdTokenClaims, TokenSet, UserinfoResponse } from "openid-client"; +import type { UserId, Session } from "lucia"; +import type { MaybePromise } from "@sveltejs/kit"; -class SqliteMemoryLuciaAdapter< - UserInfo extends Record<string, never> = Record<string, never>, -> extends LuciaAdapter< - DefaultSessionAttributes, - Omit<DatabaseUser<UserInfo>, "id"> +class SqliteMemoryLuciaAdapter extends LuciaAdapter< + Record<never, never>, + User > { db: SqLiteConnection; @@ -58,30 +54,49 @@ class SqliteMemoryLuciaAdapter< return user?.id; } - createUser(subject: string, userId: string, claims: IdTokenClaims) { + newUser(subject: string, userId: string, claims: IdTokenClaims) { this.db .prepare("INSERT INTO user (id, subject, claims) VALUES (?, ?, ?)") .run(userId, subject, JSON.stringify(claims)); return userId; } + + newSession( + userId: UserId, + tokenSet: TokenSet & { id_token: string }, + ): Promise<Session> { + return this.createSession(userId, { + idToken: tokenSet.id_token, + }); + } + + getIdToken(session: Session): MaybePromise<string | undefined> { + return session.idToken; + } } -export const sqliteMemoryAdapter = new SqliteMemoryLuciaAdapter(); +export const adapter = new SqliteMemoryLuciaAdapter(); -export interface DatabaseUser<T extends Record<string, never>> - extends DefaultUserAttributes { +interface DatabaseUser { id: string; subject: string; - claims: UserinfoResponse<T>; + claims: string; } -export interface DatabaseSession extends DefaultSessionAttributes { - id: string; - id_token: string; +interface User { + subject: string; + claims: UserinfoResponse; +} +interface DatabaseSession { + idToken: string; } -declare module "../index.js" { - interface Foo { - Session: DefaultSessionAttributes; - User: Omit<DatabaseUser<Record<never, never>>, "id">; +declare module "@arise/aidc-sveltekit" { + interface Types { + Session: DatabaseSession; + User: User; + // À supprimer si vous copiez-collez ce code + // @ts-expect-error - Tous les adapters redéfinissent ces attributs + DatabaseSessionAttributes: DatabaseSession; + DatabaseUserAttributes: DatabaseUser; } } diff --git a/src/lib/aidc_wip.ts b/src/lib/aidc_wip.ts new file mode 100644 index 0000000000000000000000000000000000000000..71975f481935dcd212c29abbbdbc9c133007f374 --- /dev/null +++ b/src/lib/aidc_wip.ts @@ -0,0 +1,14 @@ +// const test = "user:id email openid" as const; + +// const groups = { +// email: ["user:email"], +// } as const satisfies Record<string, string[]>; + +// type Split<T> = T extends `${infer F} ${infer R}` ? F | Split<R> : T; + +// type Obj<T extends string> = { +// [K in T]: string; +// }; + +// type X = Split<typeof test>; +// type Y = Obj<X>; diff --git a/src/lib/handler.ts b/src/lib/handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..276ca3f0adad340e5d3cc81dfd1cc4a510dfb55a --- /dev/null +++ b/src/lib/handler.ts @@ -0,0 +1,215 @@ +import { redirect, type Handle, error, type RequestEvent } from "@sveltejs/kit"; +import { sequence } from "@sveltejs/kit/hooks"; +import { generateId, type Session } from "lucia"; +import type { Client, TokenSet } from "openid-client"; +import { Issuer, errors, generators } from "openid-client"; +import { SEE_OTHER } from "readable-http-codes"; +import { + method, + route, + setLuciaCookie, + setTempCookie, + addBasePath, +} from "./helpers.js"; +import type { Config, CookieNames, Empty, Paths } from "./types.js"; + +export class AriseIdConnect< + SessionAttributes extends Empty, + UserAttributes extends Empty, +> { + readonly client: Client; + readonly paths: Paths; + protected cookieNames: CookieNames; + + constructor( + readonly config: Config<SessionAttributes, UserAttributes>, + issuer: Issuer, + ) { + this.client = new issuer.Client({ + ...config, + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code", "id_token"], + }); + + this.cookieNames = { + oauthState: config.cookieNames?.oauthState ?? "aidc_state", + oauthCodeVerifier: + config.cookieNames?.oauthCodeVerifier ?? "aidc_code_verifier", + }; + + this.paths = { + home: addBasePath(config.paths?.home ?? "/"), + callback: addBasePath(config.paths?.callback ?? "/auth/callback"), + logoutCallback: addBasePath(config.paths?.logoutCallback ?? "/"), + login: addBasePath(config.paths?.login ?? "/auth/login"), + logout: addBasePath(config.paths?.logout ?? "/auth/logout"), + }; + } + + static async init< + Session extends Record<never, never>, + User extends Record<never, never>, + >(config: Config<Session, User>): Promise<AriseIdConnect<Session, User>> { + const issuer = await Issuer.discover( + config.issuer || + "https://oidc.iiens.net/.well-known/openid-configuration", + ); + const aidc = new AriseIdConnect(config, issuer); + + return aidc; + } + + protected async getTokens(event: RequestEvent): Promise<TokenSet> { + const state = event.cookies.get(this.cookieNames.oauthState) ?? null; + const codeVerifier = + event.cookies.get(this.cookieNames.oauthCodeVerifier) ?? null; + const params = this.client.callbackParams(event.url.toString()); + + if (!codeVerifier || !state) { + redirect(SEE_OTHER, this.paths.login); + } + + const redirectURI = new URL(this.paths.callback, event.url).toString(); + try { + const tokenSet = await this.client.callback(redirectURI, params, { + code_verifier: codeVerifier, + state: state, + }); + + return tokenSet; + } catch (err) { + if (err instanceof errors.OPError) { + if (err.error === "access_denied") { + if (this.config.on?.accessDenied) { + return this.config.on.accessDenied(event, err); + } + + redirect(SEE_OTHER, this.paths.home); + } + + error(500, err); + } else if (err instanceof errors.RPError) { + error(500, err); + } + + throw err; + } + } + + protected loginHandler: Handle = ({ event }) => { + const codeVerifier = generators.codeVerifier(); + const state = generators.state(); + // const nonce = generators.nonce(); + + const redirectURI = new URL(this.paths.callback, event.url).toString(); + const authorizationUrl = this.client.authorizationUrl({ + response_type: "code", + scope: this.config.scope, + code_challenge: generators.codeChallenge(codeVerifier), + code_challenge_method: "S256", + redirect_uri: redirectURI, + state, + // nonce, + }); + + setTempCookie(event, this.cookieNames.oauthState, state); + setTempCookie(event, this.cookieNames.oauthCodeVerifier, codeVerifier); + + redirect(SEE_OTHER, authorizationUrl); + }; + + protected logoutHandler: Handle = async ({ event }) => { + if (!event.locals.session) { + redirect(SEE_OTHER, this.paths.home); + } + + const { adapter: lucia } = this.config; + + const { session } = await lucia.validateSession(event.locals.session.id); + + const postLogoutRedirectURI = new URL(this.paths.logoutCallback, event.url); + const idToken = await lucia.getIdToken(session); + const endSessionUrl = this.client.endSessionUrl({ + post_logout_redirect_uri: postLogoutRedirectURI.toString(), + id_token_hint: idToken, + }); + + if (this.config.on?.logout) { + await this.config.on.logout(event); + } + + await lucia.invalidateSession(event.locals.session.id); + + setLuciaCookie(event, lucia.createBlankSessionCookie()); + + redirect(SEE_OTHER, endSessionUrl); + }; + + protected callbackHandler: Handle = async ({ event }) => { + const tokenSet = await this.getTokens(event); + + if (!tokenSet.id_token) { + throw new Error("No id_token in tokenSet"); + } + + const claims = tokenSet.claims(); + const { sub } = claims; + const lucia = this.config.adapter; + const existingUserId = await lucia.getUserId(sub); + + let userId = existingUserId ?? generateId(15); + + if (existingUserId === undefined) { + userId = await lucia.newUser(sub, userId, claims); + } + + const session = await lucia.newSession(userId, tokenSet); + setLuciaCookie(event, lucia.createSessionCookie(session.id)); + + if (this.config.on?.login) { + return this.config.on.login(event, tokenSet.claims()); + } + + redirect(SEE_OTHER, this.paths.home); + }; + + protected sessionHandler: Handle = async ({ event, resolve }) => { + const lucia = this.config.adapter; + + const sessionId = event.cookies.get(lucia.sessionCookieName); + if (!sessionId) { + event.locals.user = null; + event.locals.session = null; + return resolve(event); + } + + const { session, user } = await lucia.validateSession(sessionId); + + if (session && session.fresh) { + setLuciaCookie(event, lucia.createSessionCookie(session.id)); + } + if (!session) { + setLuciaCookie(event, lucia.createBlankSessionCookie()); + } + + event.locals.user = user; + event.locals.session = session as (SessionAttributes & Session) | null; + return resolve(event); + }; + + protected miscHandler: Handle = ({ event, resolve }) => { + event.locals.authPaths = this.paths; + + return resolve(event); + }; + + handler(): Handle { + return sequence( + this.miscHandler.bind(this), + route(this.paths.login, this.loginHandler.bind(this)), + route(this.paths.logout, method("POST", this.logoutHandler.bind(this))), + route(this.paths.callback, this.callbackHandler.bind(this)), + this.sessionHandler.bind(this), + ); + } +} diff --git a/src/lib/index.ts b/src/lib/index.ts index f065c6b849fd1cd0a35ed3bfcaeddfe323ecc243..46bb6a26496210f063798da155a11e792fcb9a55 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,254 +1,50 @@ -import { redirect, type Handle, error, type RequestEvent } from "@sveltejs/kit"; -import { sequence } from "@sveltejs/kit/hooks"; -import { generateId, Lucia, type Session } from "lucia"; -import type { Client, TokenSet } from "openid-client"; -import { Issuer, errors, generators } from "openid-client"; -import { SEE_OTHER } from "readable-http-codes"; -import { - method, - route, - setLuciaCookie, - setTempCookie, - addBasePath, -} from "./helpers.js"; -import type { Config, CookieNames, Paths } from "./types.js"; -import type { - DefaultSessionAttributes, - DefaultUserAttributes, - // LuciaAdapter, -} from "./lucia.js"; - -interface InternalUser { - id: string; - subject: string; - claims: string; -} - -export interface DatabaseSession extends DefaultSessionAttributes { - id: string; - id_token: string; -} +import { Lucia } from "lucia"; +import type { Empty } from "./types.js"; +export { AriseIdConnect } from "./handler.js"; // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface Foo {} +export interface Types {} -interface Bar< - User extends Record<never, never>, - Session extends Record<never, never>, +interface RegisterTypes< + Session extends Empty, + User extends Empty, + DatabaseSessionAttributes extends Empty, + DatabaseUserAttributes extends Empty, > { - User: User; Session: Session; + User: User; + DatabaseSessionAttributes: DatabaseSessionAttributes; + DatabaseUserAttributes: DatabaseUserAttributes; } declare module "lucia" { interface Register { - Lucia: Foo extends Bar<infer _User, infer _Session> + Lucia: Types extends RegisterTypes< + infer _Session, + infer _User, + Empty, + Empty + > ? Lucia<_Session, _User> : never; - DatabaseUserAttributes: Omit<InternalUser, "id">; - DatabaseSessionAttributes: Omit<DatabaseSession, "id">; - } -} - -export class AriseIdConnect< - SessionAttributes extends DefaultSessionAttributes, - UserAttributes extends DefaultUserAttributes, -> { - readonly client: Client; - readonly paths: Paths; - protected cookieNames: CookieNames; - - constructor( - readonly config: Config<SessionAttributes, UserAttributes>, - issuer: Issuer, - ) { - this.client = new issuer.Client({ - ...config, - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code", "id_token"], - }); - - this.cookieNames = { - oauthState: config.cookieNames?.oauthState ?? "aidc_state", - oauthCodeVerifier: - config.cookieNames?.oauthCodeVerifier ?? "aidc_code_verifier", - }; - - this.paths = { - home: addBasePath(config.paths?.home ?? "/"), - callback: addBasePath(config.paths?.callback ?? "/auth/callback"), - logoutCallback: addBasePath(config.paths?.logoutCallback ?? "/"), - login: addBasePath(config.paths?.login ?? "/auth/login"), - logout: addBasePath(config.paths?.logout ?? "/auth/logout"), - }; - } - - static async init< - Session extends DefaultSessionAttributes, - User extends DefaultUserAttributes, - >(config: Config<Session, User>): Promise<AriseIdConnect<Session, User>> { - const issuer = await Issuer.discover( - config.issuer || - "https://oidc.iiens.net/.well-known/openid-configuration", - ); - const aidc = new AriseIdConnect(config, issuer); - - return aidc; - } - - protected async getTokens(event: RequestEvent): Promise<TokenSet> { - const state = event.cookies.get(this.cookieNames.oauthState) ?? null; - const codeVerifier = - event.cookies.get(this.cookieNames.oauthCodeVerifier) ?? null; - const params = this.client.callbackParams(event.url.toString()); - - if (!codeVerifier || !state) { - redirect(SEE_OTHER, this.paths.login); - } - - const redirectURI = new URL(this.paths.callback, event.url).toString(); - try { - const tokenSet = await this.client.callback(redirectURI, params, { - code_verifier: codeVerifier, - state: state, - }); - - return tokenSet; - } catch (err) { - if (err instanceof errors.OPError) { - if (err.error === "access_denied") { - if (this.config.on?.accessDenied) { - return this.config.on.accessDenied(event, err); - } - - redirect(SEE_OTHER, this.paths.home); - } - - error(500, err); - } else if (err instanceof errors.RPError) { - error(500, err); - } - - throw err; - } - } - - protected loginHandler: Handle = ({ event }) => { - const codeVerifier = generators.codeVerifier(); - const state = generators.state(); - // const nonce = generators.nonce(); - - const redirectURI = new URL(this.paths.callback, event.url).toString(); - const authorizationUrl = this.client.authorizationUrl({ - response_type: "code", - scope: this.config.scope, - code_challenge: generators.codeChallenge(codeVerifier), - code_challenge_method: "S256", - redirect_uri: redirectURI, - state, - // nonce, - }); - - setTempCookie(event, this.cookieNames.oauthState, state); - setTempCookie(event, this.cookieNames.oauthCodeVerifier, codeVerifier); - - redirect(SEE_OTHER, authorizationUrl); - }; - - protected logoutHandler: Handle = async ({ event }) => { - if (!event.locals.session) { - redirect(SEE_OTHER, this.paths.home); - } - - const { adapter: lucia } = this.config; - - const { session } = await lucia.validateSession(event.locals.session.id); - const typedSession = session as SessionAttributes | null; - - const postLogoutRedirectURI = new URL(this.paths.logoutCallback, event.url); - const endSessionUrl = this.client.endSessionUrl({ - post_logout_redirect_uri: postLogoutRedirectURI.toString(), - id_token_hint: typedSession?.id_token, - }); - - if (this.config.on?.logout) { - await this.config.on.logout(event); - } - - await lucia.invalidateSession(event.locals.session.id); - - setLuciaCookie(event, lucia.createBlankSessionCookie()); - - redirect(SEE_OTHER, endSessionUrl); - }; - - protected callbackHandler: Handle = async ({ event }) => { - const tokenSet = await this.getTokens(event); - - if (!tokenSet.id_token) { - throw new Error("No id_token in tokenSet"); - } - - const claims = tokenSet.claims(); - const { sub } = claims; - const { adapter: wrapper } = this.config; - const existingUserId = await wrapper.getUserId(sub); - - let userId = existingUserId ?? generateId(15); - - if (existingUserId === undefined) { - userId = await wrapper.createUser(sub, userId, claims); - } - - const session = await wrapper.createSession(userId, { - id_token: tokenSet.id_token, - }); - setLuciaCookie(event, wrapper.createSessionCookie(session.id)); - - if (this.config.on?.login) { - return this.config.on.login(event, tokenSet.claims()); - } - - redirect(SEE_OTHER, this.paths.home); - }; - - protected sessionHandler: Handle = async ({ event, resolve }) => { - const { adapter: lucia } = this.config; - - const sessionId = event.cookies.get(lucia.sessionCookieName); - if (!sessionId) { - event.locals.user = null; - event.locals.session = null; - return resolve(event); - } - - const { session, user } = await lucia.validateSession(sessionId); - - if (session && session.fresh) { - setLuciaCookie(event, lucia.createSessionCookie(session.id)); - } - if (!session) { - setLuciaCookie(event, lucia.createBlankSessionCookie()); - } - - event.locals.user = user; - event.locals.session = session as (SessionAttributes & Session) | null; - return resolve(event); - }; - - protected miscHandler: Handle = ({ event, resolve }) => { - event.locals.authPaths = this.paths; - - return resolve(event); - }; - - handler(): Handle { - return sequence( - this.miscHandler.bind(this), - route(this.paths.login, this.loginHandler.bind(this)), - route(this.paths.logout, method("POST", this.logoutHandler.bind(this))), - route(this.paths.callback, this.callbackHandler.bind(this)), - this.sessionHandler.bind(this), - ); + DatabaseUserAttributes: Types extends RegisterTypes< + Empty, + Empty, + Empty, + infer _DatabaseUserAttributes + > + ? _DatabaseUserAttributes + : never; + DatabaseSessionAttributes: Types extends RegisterTypes< + Empty, + Empty, + infer _DatabaseSessionAttributes, + Empty + > + ? Omit< + _DatabaseSessionAttributes, + "id" | "user_id" | "userId" | "expires_at" | "expiresAt" + > + : never; } } diff --git a/src/lib/lucia.ts b/src/lib/lucia.ts index 2763c0c4439ecc06b7fcb7ca083bf0b87b2ab1e0..5dfb4a7c30734e3adc8e03c0c2521b5a2d934b06 100644 --- a/src/lib/lucia.ts +++ b/src/lib/lucia.ts @@ -1,61 +1 @@ -import { dev } from "$app/environment"; -import type { MaybePromise } from "@sveltejs/kit"; -import type { - Adapter, - RegisteredDatabaseSessionAttributes, - RegisteredDatabaseUserAttributes, - SessionCookieOptions, - TimeSpan, -} from "lucia"; -import { Lucia } from "lucia"; -import type { UserinfoResponse } from "openid-client"; - -export interface DatabaseUser { - id: string; -} -export interface DatabaseSession { - id: string; - id_token: string; -} - -export type DefaultUserAttributes = Record<never, never>; -export interface DefaultSessionAttributes { - id_token: string; -} - -export abstract class LuciaAdapter< - _SessionAttributes extends - DefaultSessionAttributes = DefaultSessionAttributes, - _UserAttributes extends DefaultUserAttributes = DefaultUserAttributes, -> extends Lucia<_SessionAttributes, _UserAttributes> { - constructor( - adapter: Adapter, - options?: { - sessionExpiresIn?: TimeSpan; - sessionCookie?: SessionCookieOptions; - getSessionAttributes?: ( - databaseSessionAttributes: RegisteredDatabaseSessionAttributes, - ) => _SessionAttributes; - getUserAttributes?: ( - databaseUserAttributes: RegisteredDatabaseUserAttributes, - ) => _UserAttributes; - }, - ) { - super(adapter, { - sessionCookie: { - attributes: { - secure: !dev, - }, - name: "aidc_session", - }, - ...options, - }); - } - - abstract getUserId(subject: string): MaybePromise<string | undefined>; - abstract createUser( - subject: string, - userId: string, - claims: UserinfoResponse, - ): MaybePromise<string>; -} +export * from "lucia"; diff --git a/src/lib/types.ts b/src/lib/types.ts index 3b4cd2531b8100c3b1b2cb940c423f20e2d6ae1d..c7aab1a843ecd407b3f2a91e7dc0343165f510e9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,14 +1,12 @@ import type { MaybePromise, RequestEvent } from "@sveltejs/kit"; import type { ClientMetadata, UserinfoResponse, errors } from "openid-client"; -import type { - DefaultSessionAttributes, - DefaultUserAttributes, - LuciaAdapter, -} from "./lucia.js"; +import type { LuciaAdapter } from "./adapters/abstract.js"; + +export type Empty = Record<never, never>; export interface Config< - Session extends DefaultSessionAttributes, - User extends DefaultUserAttributes, + Session extends Record<never, never>, + User extends Record<never, never>, > extends ClientMetadata { client_secret: string; scope: string; @@ -36,6 +34,8 @@ export type CookieNames = { oauthCodeVerifier: string; }; -export interface Locals { +export interface AIDCLocals { authPaths: Paths; + user: import("lucia").User | null; + session: import("lucia").Session | null; } diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 0cc8fd13fec638095a30c70d4c33abb57ccad467..c465c92b7b6e7e18958b1491b34d00bf1eea0ade 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -1,7 +1,6 @@ import { redirect } from "@sveltejs/kit"; -type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; + export async function load(event) { - type User = Expand<import("lucia").User>; if (event.locals.user) { return redirect(302, "/"); } diff --git a/svelte.config.js b/svelte.config.js index acec4fd1e7fb55e71bc39e4d6d3af315e7fc9177..9174400aead6d01ad46444994baf0e0016481e4a 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -12,6 +12,11 @@ const config = { // If your environment is not supported or you settled on a specific environment, switch out the adapter. // See https://kit.svelte.dev/docs/adapters for more information about adapters. adapter: adapter(), + + alias: { + "@arise/aidc-sveltekit": "src/lib/index.js", + "@arise/aidc-sveltekit/*": "src/lib/*", + }, }, };