diff --git a/CHANGELOG.md b/CHANGELOG.md index 84903a9ca..85f8ed5fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog +## v1.0.0-3 + + +### 🏡 Chore + +- Google svg ([58c32b9](https://github.com/undb-io/undb/commit/58c32b9)) + +### ❤️ Contributors + +- Nichenqin ([@nichenqin](http://github.com/nichenqin)) + ## v1.0.0-2 diff --git a/apps/backend/drizzle/0000_certain_millenium_guard.sql b/apps/backend/drizzle/0000_marvelous_ben_grimm.sql similarity index 98% rename from apps/backend/drizzle/0000_certain_millenium_guard.sql rename to apps/backend/drizzle/0000_marvelous_ben_grimm.sql index 22d6761ee..588e25c49 100644 --- a/apps/backend/drizzle/0000_certain_millenium_guard.sql +++ b/apps/backend/drizzle/0000_marvelous_ben_grimm.sql @@ -97,7 +97,9 @@ CREATE TABLE `undb_session` ( `id` text PRIMARY KEY NOT NULL, `user_id` text NOT NULL, `expires_at` integer NOT NULL, - FOREIGN KEY (`user_id`) REFERENCES `undb_user`(`id`) ON UPDATE no action ON DELETE no action + `space_id` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `undb_user`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`space_id`) REFERENCES `undb_space`(`id`) ON UPDATE no action ON DELETE no action ); --> statement-breakpoint CREATE TABLE `undb_share` ( diff --git a/apps/backend/drizzle/meta/0000_snapshot.json b/apps/backend/drizzle/meta/0000_snapshot.json index cbee97b9f..4eebd9c12 100644 --- a/apps/backend/drizzle/meta/0000_snapshot.json +++ b/apps/backend/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "c5657173-e544-40d2-b74c-40eeb7532b85", + "id": "4b64dd29-a974-4cec-9c55-837e0e0acfde", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "undb_api_token": { @@ -795,6 +795,13 @@ "primaryKey": false, "notNull": true, "autoincrement": false + }, + "space_id": { + "name": "space_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false } }, "indexes": {}, @@ -811,6 +818,19 @@ ], "onDelete": "no action", "onUpdate": "no action" + }, + "undb_session_space_id_undb_space_id_fk": { + "name": "undb_session_space_id_undb_space_id_fk", + "tableFrom": "undb_session", + "tableTo": "undb_space", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index 232ba0924..067b64837 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1722858288654, - "tag": "0000_certain_millenium_guard", + "when": 1722921064579, + "tag": "0000_marvelous_ben_grimm", "breakpoints": true } ] diff --git a/apps/backend/src/constants/index.ts b/apps/backend/src/constants/index.ts deleted file mode 100644 index 7a6f2cb5c..000000000 --- a/apps/backend/src/constants/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./space.constant" diff --git a/apps/backend/src/constants/space.constant.ts b/apps/backend/src/constants/space.constant.ts deleted file mode 100644 index 3207c6317..000000000 --- a/apps/backend/src/constants/space.constant.ts +++ /dev/null @@ -1 +0,0 @@ -export const SPACE_ID_COOKIE_NAME = "undb-space-id" diff --git a/apps/backend/src/modules/auth/auth.provider.ts b/apps/backend/src/modules/auth/auth.provider.ts new file mode 100644 index 000000000..a0d015fff --- /dev/null +++ b/apps/backend/src/modules/auth/auth.provider.ts @@ -0,0 +1,52 @@ +import { LibSQLAdapter } from "@lucia-auth/adapter-sqlite" +import { container, inject } from "@undb/di" +import { sqlite } from "@undb/persistence" +import { Lucia } from "lucia" + +export const LUCIA_PROVIDER = Symbol.for("LUCIA_PROVIDER") + +const adapter = new LibSQLAdapter(sqlite, { + user: "undb_user", + session: "undb_session", +}) + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: Bun.env.NODE_ENV === "PRODUCTION", // set `Secure` flag in HTTPS + }, + }, + getSessionAttributes: (attributes) => { + return { + spaceId: attributes.space_id, + } + }, + getUserAttributes: (attributes) => { + return { + emailVerified: Boolean(attributes.email_verified), + email: attributes.email, + username: attributes.username, + avatar: attributes.avatar, + } + }, +}) + +declare module "lucia" { + interface Register { + Lucia: typeof lucia + DatabaseSessionAttributes: DatabaseSessionAttributes + DatabaseUserAttributes: { + email: string + email_verified: boolean + username: string + avatar?: string + } + } + interface DatabaseSessionAttributes { + space_id: string + } +} + +container.register(LUCIA_PROVIDER, { useValue: lucia }) + +export const injectLucia = () => inject(LUCIA_PROVIDER) diff --git a/apps/backend/src/modules/auth/auth.ts b/apps/backend/src/modules/auth/auth.ts index bb73fc508..2e6856168 100644 --- a/apps/backend/src/modules/auth/auth.ts +++ b/apps/backend/src/modules/auth/auth.ts @@ -1,7 +1,5 @@ -import { LibSQLAdapter } from "@lucia-auth/adapter-sqlite" import { type IInvitationQueryRepository, - ISpaceMemberRole, type ISpaceMemberService, injectInvitationQueryRepository, injectSpaceMemberService, @@ -10,62 +8,27 @@ import { AcceptInvitationCommand } from "@undb/commands" import type { ContextMember } from "@undb/context" import { executionContext, setContextValue } from "@undb/context/server" import { CommandBus } from "@undb/cqrs" -import { inject } from "@undb/di" +import { container, inject, singleton } from "@undb/di" import { Some } from "@undb/domain" import { env } from "@undb/env" import { type IMailService, injectMailService } from "@undb/mail" -import { type IQueryBuilder, getCurrentTransaction, injectQueryBuilder, sqlite } from "@undb/persistence" +import { type IQueryBuilder, getCurrentTransaction, injectQueryBuilder } from "@undb/persistence" import { type ISpaceService, injectSpaceService } from "@undb/space" -import { GitHub } from "arctic" import { Context, Elysia, t } from "elysia" import type { Session, User } from "lucia" import { Lucia, generateIdFromEntropySize } from "lucia" import { TimeSpan, createDate, isWithinExpirationDate } from "oslo" -import { serializeCookie } from "oslo/cookie" import { alphabet, generateRandomString } from "oslo/crypto" -import { OAuth2RequestError, generateState } from "oslo/oauth2" -import { singleton } from "tsyringe" +import { omit } from "radash" import { v7 } from "uuid" -import { SPACE_ID_COOKIE_NAME } from "../../constants" import { withTransaction } from "../../db" - -export const github = new GitHub(env.GITHUB_CLIENT_ID!, env.GITHUB_CLIENT_SECRET!) - -const adapter = new LibSQLAdapter(sqlite, { - user: "undb_user", - session: "undb_session", -}) +import { injectLucia } from "./auth.provider" +import { OAuth } from "./oauth/oauth" const getUsernameFromEmail = (email: string): string => { return email.split("@")[0] } -export const lucia = new Lucia(adapter, { - sessionCookie: { - attributes: { - secure: Bun.env.NODE_ENV === "PRODUCTION", // set `Secure` flag in HTTPS - }, - }, - getUserAttributes: (attributes) => { - return { - emailVerified: attributes.email_verified, - email: attributes.email, - username: attributes.username, - } - }, -}) - -declare module "lucia" { - interface Register { - Lucia: typeof lucia - DatabaseUserAttributes: { - email: string - email_verified: boolean - username: string - } - } -} - @singleton() export class Auth { constructor( @@ -81,6 +44,8 @@ export class Auth { private readonly spaceService: ISpaceService, @injectMailService() private readonly mailService: IMailService, + @injectLucia() + private readonly lucia: Lucia, ) {} async #generateEmailVerificationCode(userId: string, email: string): Promise { @@ -131,7 +96,7 @@ export class Auth { }> => { // use headers instead of Cookie API to prevent type coercion const cookieHeader = context.request.headers.get("Cookie") ?? "" - const sessionId = lucia.readSessionCookie(cookieHeader) + const sessionId = this.lucia.readSessionCookie(cookieHeader) if (!sessionId) { return { @@ -141,16 +106,16 @@ export class Auth { } } - const { session, user } = await lucia.validateSession(sessionId) + const { session, user } = await this.lucia.validateSession(sessionId) if (session && session.fresh) { - const sessionCookie = lucia.createSessionCookie(session.id) + const sessionCookie = this.lucia.createSessionCookie(session.id) context.cookie[sessionCookie.name].set({ value: sessionCookie.value, ...sessionCookie.attributes, }) } if (!session) { - const sessionCookie = lucia.createBlankSessionCookie() + const sessionCookie = this.lucia.createBlankSessionCookie() context.cookie[sessionCookie.name].set({ value: sessionCookie.value, ...sessionCookie.attributes, @@ -158,8 +123,7 @@ export class Auth { } const userId = user?.id! - // TODO: move to other file - const spaceId = context.cookie[SPACE_ID_COOKIE_NAME]?.value + const spaceId = session?.spaceId const space = await this.spaceService.setSpaceContext(setContextValue, { spaceId }) const member = space @@ -173,6 +137,7 @@ export class Auth { username: user!.username, email: user!.email, emailVerified: user!.emailVerified, + avatar: user!.avatar, }) return { user, @@ -183,7 +148,9 @@ export class Auth { } route() { + const oauth = container.resolve(OAuth) return new Elysia() + .use(oauth.route()) .get("/api/me", (ctx) => { const store = executionContext.getStore() const user = store?.user @@ -197,14 +164,12 @@ export class Auth { const member = store?.member - return { user, member } + return { user: omit(user, ["emailVerified"]), member } }) .post( "/api/signup", async (ctx) => { let { email, password, invitationId, username } = ctx.body - const hasUser = !!(await this.queryBuilder.selectFrom("undb_user").selectAll().executeTakeFirst()) - let role: ISpaceMemberRole = "owner" if (invitationId) { const invitation = await this.invitationRepository.findOneById(invitationId) if (invitation.isNone()) { @@ -214,13 +179,10 @@ export class Auth { if (invitation.unwrap().email !== email) { throw new Error("Invalid email") } - - role = invitation.unwrap().role - } else { - role = !hasUser ? "owner" : "admin" } let userId: string + let spaceId: string = "" const user = await this.queryBuilder .selectFrom("undb_user") @@ -239,6 +201,8 @@ export class Auth { } userId = user.id + const space = (await this.spaceService.getSpace({ userId })).expect("User should have a space") + spaceId = space.id.value } else { userId = generateIdFromEntropySize(10) // 16 characters long const passwordHash = await Bun.password.hash(password) @@ -269,7 +233,8 @@ export class Auth { const space = await this.spaceService.createPersonalSpace() await this.spaceMemberService.createMember(userId, space.id.value, "owner") - ctx.cookie[SPACE_ID_COOKIE_NAME].set({ value: space.id.value }) + + spaceId = space.id.value if (env.UNDB_VERIFY_EMAIL) { const verificationCode = await this.#generateEmailVerificationCode(userId, email) @@ -287,8 +252,8 @@ export class Auth { }) } - const session = await lucia.createSession(userId, {}) - const sessionCookie = lucia.createSessionCookie(session.id) + const session = await this.lucia.createSession(userId, { space_id: spaceId }) + const sessionCookie = this.lucia.createSessionCookie(session.id) response.headers.set("Set-Cookie", sessionCookie.serialize()) return response @@ -328,21 +293,14 @@ export class Auth { }) } - const session = await lucia.createSession(user.id, {}) - const sessionCookie = lucia.createSessionCookie(session.id) - - const spaceId = ctx.cookie[SPACE_ID_COOKIE_NAME]?.value - let space = await this.spaceService.getSpace({ spaceId }) - if (space.isNone()) { - space = await this.spaceService.getSpace({ userId: user.id }) - if (space.isSome()) { - ctx.cookie[SPACE_ID_COOKIE_NAME].set({ value: space.unwrap().id.value }) - } else { - space = Some(await this.spaceService.createPersonalSpace()) - await this.spaceMemberService.createMember(user.id, space.unwrap().id.value, "owner") - ctx.cookie[SPACE_ID_COOKIE_NAME].set({ value: space.unwrap().id.value }) - } + let space = await this.spaceService.getSpace({ userId: user.id }) + if (space.isSome()) { + } else { + space = Some(await this.spaceService.createPersonalSpace()) + await this.spaceMemberService.createMember(user.id, space.unwrap().id.value, "owner") } + const session = await this.lucia.createSession(user.id, { space_id: space.unwrap().id.value }) + const sessionCookie = this.lucia.createSessionCookie(session.id) return new Response(null, { status: 302, @@ -361,7 +319,7 @@ export class Auth { }, ) .post("/api/logout", (ctx) => { - const sessionCookie = lucia.createBlankSessionCookie() + const sessionCookie = this.lucia.createBlankSessionCookie() const response = new Response() response.headers.set("Set-Cookie", sessionCookie.serialize()) return response @@ -370,7 +328,7 @@ export class Auth { "/api/email-verification", async (ctx) => { const cookieHeader = ctx.request.headers.get("Cookie") ?? "" - const sessionId = lucia.readSessionCookie(cookieHeader) + const sessionId = this.lucia.readSessionCookie(cookieHeader) if (!sessionId) { return new Response(null, { @@ -378,7 +336,7 @@ export class Auth { }) } - const { user } = await lucia.validateSession(sessionId) + const { user, session: validatedSession } = await this.lucia.validateSession(sessionId) if (!user) { return new Response(null, { status: 401, @@ -398,7 +356,7 @@ export class Auth { throw new Error("Invalid code") } - await lucia.invalidateUserSessions(user.id) + await this.lucia.invalidateUserSessions(user.id) await this.queryBuilder .updateTable("undb_user") .set("email_verified", true) @@ -406,8 +364,8 @@ export class Auth { .execute() }) - const session = await lucia.createSession(user.id, {}) - const sessionCookie = lucia.createSessionCookie(session.id) + const session = await this.lucia.createSession(user.id, { space_id: validatedSession.spaceId }) + const sessionCookie = this.lucia.createSessionCookie(session.id) return new Response(null, { status: 302, headers: { @@ -423,173 +381,6 @@ export class Auth { }), }, ) - .get("/login/github", async (ctx) => { - const state = generateState() - const url = await github.createAuthorizationURL(state, { scopes: ["user:email"] }) - return new Response(null, { - status: 302, - headers: { - Location: url.toString(), - "Set-Cookie": serializeCookie("github_oauth_state", state, { - httpOnly: true, - secure: Bun.env.NODE_ENV === "production", - maxAge: 60 * 10, // 10 minutes - path: "/", - }), - }, - }) - }) - .get("/login/github/callback", async (ctx) => { - const stateCookie = ctx.cookie["github_oauth_state"]?.value ?? null - - const url = new URL(ctx.request.url) - const state = url.searchParams.get("state") - const code = url.searchParams.get("code") - - // verify state - if (!state || !stateCookie || !code || stateCookie !== state) { - return new Response(null, { - status: 400, - }) - } - - try { - const tokens = await github.validateAuthorizationCode(code) - const githubUserResponse = await fetch("https://api.github.com/user", { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - const githubUserResult: GitHubUserResult = await githubUserResponse.json() - - const existingUser = await this.queryBuilder - .selectFrom("undb_oauth_account") - .selectAll() - .where((eb) => - eb.and([ - eb.eb("provider_id", "=", "github"), - eb.eb("provider_user_id", "=", githubUserResult.id.toString()), - ]), - ) - .executeTakeFirst() - - if (existingUser) { - const session = await lucia.createSession(existingUser.user_id, {}) - const sessionCookie = lucia.createSessionCookie(session.id) - return new Response(null, { - status: 302, - headers: { - Location: "/", - "Set-Cookie": sessionCookie.serialize(), - }, - }) - } - - const emailsResponse = await fetch("https://api.github.com/user/emails", { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - const emails: GithubEmail[] = await emailsResponse.json() - - const primaryEmail = emails.find((email) => email.primary) ?? null - if (!primaryEmail) { - return new Response("No primary email address", { - status: 400, - }) - } - if (!primaryEmail.verified) { - return new Response("Unverified email", { - status: 400, - }) - } - - const existingGithubUser = await this.queryBuilder - .selectFrom("undb_user") - .selectAll() - .where("undb_user.email", "=", primaryEmail.email) - .executeTakeFirst() - - if (existingGithubUser) { - const spaceId = ctx.cookie[SPACE_ID_COOKIE_NAME].value - if (!spaceId) { - await this.spaceService.setSpaceContext(setContextValue, { userId: existingGithubUser.id }) - } - - await this.queryBuilder - .insertInto("undb_oauth_account") - .values({ - provider_id: "github", - provider_user_id: githubUserResult.id.toString(), - user_id: existingGithubUser.id, - }) - .execute() - - const session = await lucia.createSession(existingGithubUser.id, {}) - const sessionCookie = lucia.createSessionCookie(session.id) - return new Response(null, { - status: 302, - headers: { - Location: "/", - "Set-Cookie": sessionCookie.serialize(), - }, - }) - } - const userId = generateIdFromEntropySize(10) // 16 characters long - await withTransaction(this.queryBuilder)(async () => { - const tx = getCurrentTransaction() - await tx - .insertInto("undb_user") - .values({ - id: userId, - username: githubUserResult.login, - email: "", - password: "", - email_verified: false, - }) - .execute() - - setContextValue("user", { - userId, - username: githubUserResult.login, - email: "", - emailVerified: false, - }) - - await tx - .insertInto("undb_oauth_account") - .values({ - user_id: userId, - provider_id: "github", - provider_user_id: githubUserResult.id.toString(), - }) - .execute() - const space = await this.spaceService.createPersonalSpace() - await this.spaceMemberService.createMember(userId, space.id.value, "owner") - ctx.cookie[SPACE_ID_COOKIE_NAME].set({ value: space.id.value }) - }) - const session = await lucia.createSession(userId, {}) - const sessionCookie = lucia.createSessionCookie(session.id) - return new Response(null, { - status: 302, - headers: { - Location: "/", - "Set-Cookie": sessionCookie.serialize(), - }, - }) - } catch (e) { - console.log(e) - if (e instanceof OAuth2RequestError) { - // bad verification code, invalid credentials, etc - return new Response(null, { - status: 400, - }) - } - return new Response(null, { - status: 500, - }) - } - }) .get( "/invitation/:invitationId/accept", async (ctx) => { @@ -609,13 +400,3 @@ export class Auth { ) } } -interface GitHubUserResult { - id: number - login: string // username -} - -interface GithubEmail { - email: string - primary: boolean - verified: boolean -} diff --git a/apps/backend/src/modules/auth/oauth/github.provider.ts b/apps/backend/src/modules/auth/oauth/github.provider.ts new file mode 100644 index 000000000..6fd98aa53 --- /dev/null +++ b/apps/backend/src/modules/auth/oauth/github.provider.ts @@ -0,0 +1,13 @@ +import { container, inject, instanceCachingFactory } from "@undb/di" +import { env } from "@undb/env" +import { GitHub } from "arctic" + +export const GITHUB_PROVIDER = Symbol.for("GITHUB_PROVIDER") + +container.register(GITHUB_PROVIDER, { + useFactory: instanceCachingFactory(() => { + return new GitHub(env.GITHUB_CLIENT_ID!, env.GITHUB_CLIENT_SECRET!) + }), +}) + +export const injectGithubProvider = () => inject(GITHUB_PROVIDER) diff --git a/apps/backend/src/modules/auth/oauth/github.ts b/apps/backend/src/modules/auth/oauth/github.ts new file mode 100644 index 000000000..20f139b42 --- /dev/null +++ b/apps/backend/src/modules/auth/oauth/github.ts @@ -0,0 +1,212 @@ +import { type ISpaceMemberService, injectSpaceMemberService } from "@undb/authz" +import { setContextValue } from "@undb/context/server" +import { singleton } from "@undb/di" +import { type IQueryBuilder, getCurrentTransaction, injectQueryBuilder } from "@undb/persistence" +import { type ISpaceService, injectSpaceService } from "@undb/space" +import { GitHub } from "arctic" +import { Elysia } from "elysia" +import { type Lucia, generateIdFromEntropySize } from "lucia" +import { serializeCookie } from "oslo/cookie" +import { OAuth2RequestError, generateState } from "oslo/oauth2" +import { withTransaction } from "../../../db" +import { injectLucia } from "../auth.provider" +import { injectGithubProvider } from "./github.provider" + +@singleton() +export class GithubOAuth { + constructor( + @injectSpaceMemberService() + private spaceMemberService: ISpaceMemberService, + @injectQueryBuilder() + private readonly queryBuilder: IQueryBuilder, + @injectSpaceService() + private readonly spaceService: ISpaceService, + @injectGithubProvider() + private readonly github: GitHub, + @injectLucia() + private readonly lucia: Lucia, + ) {} + + route() { + return new Elysia() + .get("/login/github", async (ctx) => { + const state = generateState() + const url = await this.github.createAuthorizationURL(state, { scopes: ["user:email"] }) + return new Response(null, { + status: 302, + headers: { + Location: url.toString(), + "Set-Cookie": serializeCookie("github_oauth_state", state, { + httpOnly: true, + secure: Bun.env.NODE_ENV === "production", + maxAge: 60 * 10, // 10 minutes + path: "/", + }), + }, + }) + }) + .get("/login/github/callback", async (ctx) => { + const stateCookie = ctx.cookie["github_oauth_state"]?.value ?? null + + const url = new URL(ctx.request.url) + const state = url.searchParams.get("state") + const code = url.searchParams.get("code") + + // verify state + if (!state || !stateCookie || !code || stateCookie !== state) { + return new Response(null, { + status: 400, + }) + } + + try { + const tokens = await this.github.validateAuthorizationCode(code) + const githubUserResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + const githubUserResult: GitHubUserResult = await githubUserResponse.json() + + const existingUser = await this.queryBuilder + .selectFrom("undb_oauth_account") + .selectAll() + .where((eb) => + eb.and([ + eb.eb("provider_id", "=", "github"), + eb.eb("provider_user_id", "=", githubUserResult.id.toString()), + ]), + ) + .executeTakeFirst() + + if (existingUser) { + const space = (await this.spaceService.getSpace({ userId: existingUser.user_id })).expect("space not found") + const session = await this.lucia.createSession(existingUser.user_id, { space_id: space.id.value }) + const sessionCookie = this.lucia.createSessionCookie(session.id) + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize(), + }, + }) + } + + const emailsResponse = await fetch("https://api.github.com/user/emails", { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + const emails: GithubEmail[] = await emailsResponse.json() + + const primaryEmail = emails.find((email) => email.primary) ?? null + if (!primaryEmail) { + return new Response("No primary email address", { + status: 400, + }) + } + if (!primaryEmail.verified) { + return new Response("Unverified email", { + status: 400, + }) + } + + const existingGithubUser = await this.queryBuilder + .selectFrom("undb_user") + .selectAll() + .where("undb_user.email", "=", primaryEmail.email) + .executeTakeFirst() + + if (existingGithubUser) { + const space = await this.spaceService.setSpaceContext(setContextValue, { userId: existingGithubUser.id }) + + await this.queryBuilder + .insertInto("undb_oauth_account") + .values({ + provider_id: "github", + provider_user_id: githubUserResult.id.toString(), + user_id: existingGithubUser.id, + }) + .execute() + + const session = await this.lucia.createSession(existingGithubUser.id, { space_id: space.id.value }) + const sessionCookie = this.lucia.createSessionCookie(session.id) + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize(), + }, + }) + } + const userId = generateIdFromEntropySize(10) // 16 characters long + const space = await withTransaction(this.queryBuilder)(async () => { + const tx = getCurrentTransaction() + await tx + .insertInto("undb_user") + .values({ + id: userId, + username: githubUserResult.login, + email: primaryEmail.email, + avatar: githubUserResult.avatar_url, + password: "", + email_verified: true, + }) + .execute() + + setContextValue("user", { + userId, + username: githubUserResult.login, + email: primaryEmail.email, + emailVerified: true, + avatar: githubUserResult.avatar_url, + }) + + await tx + .insertInto("undb_oauth_account") + .values({ + user_id: userId, + provider_id: "github", + provider_user_id: githubUserResult.id.toString(), + }) + .execute() + const space = await this.spaceService.createPersonalSpace() + await this.spaceMemberService.createMember(userId, space.id.value, "owner") + + return space + }) + const session = await this.lucia.createSession(userId, { space_id: space.id.value }) + const sessionCookie = this.lucia.createSessionCookie(session.id) + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize(), + }, + }) + } catch (e) { + console.log(e) + if (e instanceof OAuth2RequestError) { + // bad verification code, invalid credentials, etc + return new Response(null, { + status: 400, + }) + } + return new Response(null, { + status: 500, + }) + } + }) + } +} +interface GitHubUserResult { + id: number + login: string // username + avatar_url: string +} + +interface GithubEmail { + email: string + primary: boolean + verified: boolean +} diff --git a/apps/backend/src/modules/auth/oauth/google.provider.ts b/apps/backend/src/modules/auth/oauth/google.provider.ts new file mode 100644 index 000000000..b386f602c --- /dev/null +++ b/apps/backend/src/modules/auth/oauth/google.provider.ts @@ -0,0 +1,14 @@ +import { container, inject, instanceCachingFactory } from "@undb/di" +import { env } from "@undb/env" +import { Google } from "arctic" + +export const GOOGLE_PROVIDER = Symbol.for("GOOGLE_PROVIDER") + +container.register(GOOGLE_PROVIDER, { + useFactory: instanceCachingFactory(() => { + const redirectURL = new URL("/login/google/callback", env.UNDB_BASE_URL) + return new Google(env.GOOGLE_CLIENT_ID!, env.GOOGLE_CLIENT_SECRET!, redirectURL.toString()) + }), +}) + +export const injectGoogleProvider = () => inject(GOOGLE_PROVIDER) diff --git a/apps/backend/src/modules/auth/oauth/google.ts b/apps/backend/src/modules/auth/oauth/google.ts new file mode 100644 index 000000000..0400cb030 --- /dev/null +++ b/apps/backend/src/modules/auth/oauth/google.ts @@ -0,0 +1,201 @@ +import { type ISpaceMemberService, injectSpaceMemberService } from "@undb/authz" +import { setContextValue } from "@undb/context/server" +import { singleton } from "@undb/di" +import { type IQueryBuilder, getCurrentTransaction, injectQueryBuilder } from "@undb/persistence" +import { type ISpaceService, injectSpaceService } from "@undb/space" +import { Google, generateCodeVerifier } from "arctic" +import { env } from "bun" +import { Elysia } from "elysia" +import { type Lucia, generateIdFromEntropySize } from "lucia" +import { OAuth2RequestError, generateState } from "oslo/oauth2" +import { withTransaction } from "../../../db" +import { injectLucia } from "../auth.provider" +import { injectGoogleProvider } from "./google.provider" + +@singleton() +export class GoogleOAuth { + constructor( + @injectSpaceMemberService() + private spaceMemberService: ISpaceMemberService, + @injectQueryBuilder() + private readonly queryBuilder: IQueryBuilder, + @injectSpaceService() + private readonly spaceService: ISpaceService, + @injectGoogleProvider() + private readonly google: Google, + @injectLucia() + private readonly lucia: Lucia, + ) {} + + route() { + return new Elysia() + .get("/login/google", async (ctx) => { + const state = generateState() + const codeVerifier = generateCodeVerifier() + const url = await this.google.createAuthorizationURL(state, codeVerifier, { + scopes: ["email", "profile"], + }) + + ctx.cookie["state"].set({ + value: state, + secure: env.NODE_ENV === "production", + httpOnly: true, + maxAge: 60 * 10, + path: "/", + }) + + ctx.cookie["code_verifier"].set({ + value: codeVerifier, + secure: env.NODE_ENV === "production", + httpOnly: true, + maxAge: 60 * 10, + path: "/", + }) + + return ctx.redirect(url.toString(), 302) + }) + .get("/login/google/callback", async (ctx) => { + const storedState = ctx.cookie["state"]?.value ?? null + const storedCodeVerifier = ctx.cookie["code_verifier"]?.value ?? null + + const url = new URL(ctx.request.url) + const state = url.searchParams.get("state") + const code = url.searchParams.get("code") + + // verify state + if (!code || !storedState || !storedCodeVerifier || state !== storedState) { + return new Response(null, { + status: 400, + }) + } + + try { + const tokens = await this.google.validateAuthorizationCode(code, storedCodeVerifier) + const googleUserResponse = await fetch("https://www.googleapis.com/oauth2/v1/userinfo", { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + const googleUserResult: GoogleUserResult = await googleUserResponse.json() + + const existingUser = await this.queryBuilder + .selectFrom("undb_oauth_account") + .selectAll() + .where((eb) => + eb.and([ + eb.eb("provider_id", "=", "google"), + eb.eb("provider_user_id", "=", googleUserResult.id.toString()), + ]), + ) + .executeTakeFirst() + + if (existingUser) { + const space = (await this.spaceService.getSpace({ userId: existingUser.user_id })).expect("Space not found") + const session = await this.lucia.createSession(existingUser.user_id, { space_id: space.id.value }) + const sessionCookie = this.lucia.createSessionCookie(session.id) + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize(), + }, + }) + } + const existingGoogleUser = await this.queryBuilder + .selectFrom("undb_user") + .selectAll() + .where("undb_user.email", "=", googleUserResult.email) + .executeTakeFirst() + + if (existingGoogleUser) { + const space = await this.spaceService.setSpaceContext(setContextValue, { userId: existingGoogleUser.id }) + + await this.queryBuilder + .insertInto("undb_oauth_account") + .values({ + provider_id: "google", + provider_user_id: googleUserResult.id.toString(), + user_id: existingGoogleUser.id, + }) + .execute() + + const session = await this.lucia.createSession(existingGoogleUser.id, { space_id: space.id.value }) + const sessionCookie = this.lucia.createSessionCookie(session.id) + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize(), + }, + }) + } + const userId = generateIdFromEntropySize(10) // 16 characters long + const space = await withTransaction(this.queryBuilder)(async () => { + const tx = getCurrentTransaction() + await tx + .insertInto("undb_user") + .values({ + id: userId, + username: googleUserResult.name, + avatar: googleUserResult.picture, + email: googleUserResult.email, + password: "", + email_verified: true, + }) + .execute() + + setContextValue("user", { + userId, + username: googleUserResult.name, + email: googleUserResult.email, + emailVerified: true, + avatar: googleUserResult.picture, + }) + + await tx + .insertInto("undb_oauth_account") + .values({ + user_id: userId, + provider_id: "google", + provider_user_id: googleUserResult.id.toString(), + }) + .execute() + const space = await this.spaceService.createPersonalSpace() + await this.spaceMemberService.createMember(userId, space.id.value, "owner") + + return space + }) + const session = await this.lucia.createSession(userId, { space_id: space.id.value }) + const sessionCookie = this.lucia.createSessionCookie(session.id) + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize(), + }, + }) + } catch (e) { + console.log(e) + if (e instanceof OAuth2RequestError) { + // bad verification code, invalid credentials, etc + return new Response(null, { + status: 400, + }) + } + return new Response(null, { + status: 500, + }) + } + }) + } +} + +interface GoogleUserResult { + id: number + email: string + verified_email: boolean + name: string + given_name: string + picture: string + locale: string +} diff --git a/apps/backend/src/modules/auth/oauth/oauth.ts b/apps/backend/src/modules/auth/oauth/oauth.ts new file mode 100644 index 000000000..7b856f88f --- /dev/null +++ b/apps/backend/src/modules/auth/oauth/oauth.ts @@ -0,0 +1,13 @@ +import { container, singleton } from "@undb/di" +import Elysia from "elysia" +import { GithubOAuth } from "./github" +import { GoogleOAuth } from "./google" + +@singleton() +export class OAuth { + public route() { + const github = container.resolve(GithubOAuth) + const google = container.resolve(GoogleOAuth) + return new Elysia().use(github.route()).use(google.route()) + } +} diff --git a/apps/backend/src/modules/space/space.module.ts b/apps/backend/src/modules/space/space.module.ts index dbd6e5cab..c9f2379d6 100644 --- a/apps/backend/src/modules/space/space.module.ts +++ b/apps/backend/src/modules/space/space.module.ts @@ -1,17 +1,47 @@ import { singleton } from "@undb/di" +import { injectSpaceService, type ISpaceService } from "@undb/space" import Elysia, { t } from "elysia" -import { SPACE_ID_COOKIE_NAME } from "../../constants" +import { type Lucia } from "lucia" +import { injectLucia } from "../auth/auth.provider" @singleton() export class SpaceModule { + constructor( + @injectLucia() + private readonly lucia: Lucia, + @injectSpaceService() + private readonly spaceService: ISpaceService, + ) {} public route() { return new Elysia().get( "/api/spaces/:spaceId/goto", async (ctx) => { const { spaceId } = ctx.params - ctx.cookie[SPACE_ID_COOKIE_NAME].value = spaceId + const space = (await this.spaceService.getSpace({ spaceId })).expect("Space not found") - return new Response() + const cookieHeader = ctx.request.headers.get("Cookie") ?? "" + const sessionId = this.lucia.readSessionCookie(cookieHeader) + + if (!sessionId) { + return new Response("Unauthorized", { status: 401 }) + } + + const { session, user } = await this.lucia.validateSession(sessionId) + if (!user) { + return new Response(null, { + status: 401, + }) + } + await this.lucia.invalidateUserSessions(user.id) + const updatedSession = await this.lucia.createSession(user.id, { space_id: space.id.value }) + const sessionCookie = this.lucia.createSessionCookie(updatedSession.id) + return new Response(null, { + status: 302, + headers: { + Location: "/", + "Set-Cookie": sessionCookie.serialize(), + }, + }) }, { params: t.Object({ diff --git a/apps/backend/src/modules/web/web.tsx b/apps/backend/src/modules/web/web.tsx index a27722c0e..68e00b6ee 100644 --- a/apps/backend/src/modules/web/web.tsx +++ b/apps/backend/src/modules/web/web.tsx @@ -14,7 +14,7 @@ export class Web { .get("/s/*", () => index) .get("/bases/*", () => index) .get("/account/*", () => index) - .get("/members/*", () => index) + .get("/members", () => index) .get("/login", () => index) .get("/signup", () => index) } diff --git a/apps/frontend/schema.graphql b/apps/frontend/schema.graphql index 7d5cd99a2..bb667da87 100644 --- a/apps/frontend/schema.graphql +++ b/apps/frontend/schema.graphql @@ -165,6 +165,7 @@ type Table { } type User { + avatar: String email: String! id: ID! username: String! diff --git a/apps/frontend/src/lib/components/blocks/member/member-menu.svelte b/apps/frontend/src/lib/components/blocks/member/member-menu.svelte index 1c5ed43b4..9f1a46836 100644 --- a/apps/frontend/src/lib/components/blocks/member/member-menu.svelte +++ b/apps/frontend/src/lib/components/blocks/member/member-menu.svelte @@ -6,7 +6,7 @@ import { createMutation } from "@tanstack/svelte-query" import { goto } from "$app/navigation" - export let user: { username: string; userId: string } + export let user: { avatar: string | null; username: string; userId: string } const logoutMutation = createMutation({ mutationFn: () => fetch("/api/logout", { method: "POST" }), @@ -24,7 +24,7 @@ + diff --git a/apps/frontend/src/routes/(auth)/signup/+page.svelte b/apps/frontend/src/routes/(auth)/signup/+page.svelte index 686c08638..21eb29ca8 100644 --- a/apps/frontend/src/routes/(auth)/signup/+page.svelte +++ b/apps/frontend/src/routes/(auth)/signup/+page.svelte @@ -7,6 +7,7 @@ import { Label } from "$lib/components/ui/label/index.js" import Logo from "$lib/images/logo.svg" import Github from "$lib/images/github.svg" + import Google from "$lib/images/Google.svg" import { createMutation } from "@tanstack/svelte-query" import { z } from "@undb/zod" import { defaults, superForm } from "sveltekit-superforms" @@ -129,7 +130,6 @@ @@ -164,9 +164,13 @@
+
diff --git a/apps/frontend/src/routes/(authed)/members/+layout.gql b/apps/frontend/src/routes/(authed)/members/+layout.gql index 2589b7ebf..c327e9697 100644 --- a/apps/frontend/src/routes/(authed)/members/+layout.gql +++ b/apps/frontend/src/routes/(authed)/members/+layout.gql @@ -6,6 +6,7 @@ query GetMembers($q: String) { id email username + avatar } } } diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 3b6172ca8..82fb8d5e4 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -42,6 +42,10 @@ export default defineConfig({ target: "http://0.0.0.0:4728", changeOrigin: true, }, + "/login/google": { + target: "http://0.0.0.0:4728", + changeOrigin: true, + }, "/graphql": { target: "http://0.0.0.0:4728", changeOrigin: true, diff --git a/package.json b/package.json index 449bd894e..eb0c45ced 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "undb", - "version": "1.0.0-2", + "version": "1.0.0-3", "private": true, "scripts": { "build": "NODE_ENV=production bun --bun turbo build", diff --git a/packages/context/src/context.type.ts b/packages/context/src/context.type.ts index f0e39d5bb..9046bbaad 100644 --- a/packages/context/src/context.type.ts +++ b/packages/context/src/context.type.ts @@ -3,6 +3,7 @@ interface ContextUser { username?: string email?: string emailVerified?: boolean + avatar?: string } export interface ContextMember { diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 7274a4cf1..4f5bd0f06 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -32,6 +32,9 @@ export const env = createEnv({ GITHUB_CLIENT_ID: z.string().optional(), GITHUB_CLIENT_SECRET: z.string().optional(), + + GOOGLE_CLIENT_ID: z.string().optional(), + GOOGLE_CLIENT_SECRET: z.string().optional(), }, runtimeEnv: import.meta.env, emptyStringAsUndefined: true, diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 965b88230..48c12e888 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -206,6 +206,7 @@ export class Graphql { id: ID! email: String! username: String! + avatar: String } type Space { diff --git a/packages/persistence/src/tables.ts b/packages/persistence/src/tables.ts index 2c78979a5..5e63c65e3 100644 --- a/packages/persistence/src/tables.ts +++ b/packages/persistence/src/tables.ts @@ -202,6 +202,9 @@ export const sessionTable = sqliteTable("session", { .notNull() .references(() => users.id), expiresAt: integer("expires_at").notNull(), + spaceId: text("space_id") + .notNull() + .references(() => space.id), }) export const webhook = sqliteTable( diff --git a/turbo.json b/turbo.json index bb8174468..8121cb3ea 100644 --- a/turbo.json +++ b/turbo.json @@ -2,7 +2,7 @@ "$schema": "https://turbo.build/schema.json", "globalDependencies": ["**/.env.*local", ".env"], "ui": "stream", - "globalEnv": ["LOG_LEVEL", "AXIOM_TOKEN", "AXIOM_DATASET", "UNDB_*", "GITHUB_*"], + "globalEnv": ["LOG_LEVEL", "AXIOM_TOKEN", "AXIOM_DATASET", "UNDB_*", "GITHUB_*", "GOOGLE_*"], "tasks": { "build": { "cache": false,