diff --git a/config/config.example.toml b/config/config.example.toml index 979c1bf9..6011f1cb 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -48,11 +48,12 @@ rules = [ jwt_key = "" # If enabled, Lysand will require users to log in with an OAuth provider -# Note that registering with an OAuth provider is not supported yet, so -# this will lock out users who are not already registered or who do not have -# an OAuth account linked forced = false +# Allow registration with OAuth providers +# Overriden by the signups.registration setting +allow_registration = true + # Delete this section if you don't want to use custom OAuth providers # This is an example configuration # The provider MUST support OpenID Connect with .well-known discovery diff --git a/packages/config-manager/config.type.ts b/packages/config-manager/config.type.ts index 6f2726af..574f330d 100644 --- a/packages/config-manager/config.type.ts +++ b/packages/config-manager/config.type.ts @@ -93,6 +93,7 @@ export const configValidator = z.object({ }), oidc: z.object({ forced: z.boolean().default(false), + allow_registration: z.boolean().default(true), providers: z .array( z.object({ diff --git a/packages/database-interface/oauth.ts b/packages/database-interface/oauth.ts index 0af1fd95..fcf3296d 100644 --- a/packages/database-interface/oauth.ts +++ b/packages/database-interface/oauth.ts @@ -136,6 +136,14 @@ export class OAuthManager { }; } + async linkUserInDatabase(userId: string, sub: string): Promise { + await db.insert(OpenIdAccounts).values({ + serverId: sub, + issuerId: this.issuer.id, + userId: userId, + }); + } + async linkUser( userId: string, // Return value of automaticOidcFlow @@ -182,11 +190,7 @@ export class OAuthManager { } // Link the account - await db.insert(OpenIdAccounts).values({ - serverId: userInfo.sub, - issuerId: this.issuer.id, - userId: userId, - }); + await this.linkUserInDatabase(userId, userInfo.sub); return response(null, 302, { Location: `${config.http.base_url}${ diff --git a/packages/database-interface/user.ts b/packages/database-interface/user.ts index c8dbf6e8..1c02d370 100644 --- a/packages/database-interface/user.ts +++ b/packages/database-interface/user.ts @@ -436,7 +436,7 @@ export class User extends BaseInterface { header?: string; admin?: boolean; skipPasswordHash?: boolean; - }): Promise { + }): Promise { const keys = await User.generateKeys(); const newUser = ( @@ -472,7 +472,7 @@ export class User extends BaseInterface { const finalUser = await User.fromId(newUser.id); if (!finalUser) { - return null; + throw new Error("Failed to create user"); } // Add to Meilisearch diff --git a/server/api/api/v1/accounts/index.ts b/server/api/api/v1/accounts/index.ts index 9ce5bd79..61df5630 100644 --- a/server/api/api/v1/accounts/index.ts +++ b/server/api/api/v1/accounts/index.ts @@ -2,7 +2,7 @@ import { applyConfig, auth, handleZodError, jsonOrForm } from "@/api"; import { jsonResponse, response } from "@/response"; import { tempmailDomains } from "@/tempmail"; import { zValidator } from "@hono/zod-validator"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import type { Hono } from "hono"; import ISO6391 from "iso-639-1"; import { z } from "zod"; @@ -30,7 +30,7 @@ export const schemas = { form: z.object({ username: z.string(), email: z.string().toLowerCase(), - password: z.string(), + password: z.string().optional(), agreement: z .string() .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) @@ -153,7 +153,12 @@ export default (app: Hono) => } // Check if username is taken - if (await User.fromSql(eq(Users.username, username))) { + if ( + await User.fromSql( + and(eq(Users.username, username)), + isNull(Users.instanceId), + ) + ) { errors.details.username.push({ error: "ERR_TAKEN", description: "is already taken", diff --git a/server/api/oauth/sso/:issuer/callback/index.ts b/server/api/oauth/sso/:issuer/callback/index.ts index cc75f2aa..b34f507e 100644 --- a/server/api/oauth/sso/:issuer/callback/index.ts +++ b/server/api/oauth/sso/:issuer/callback/index.ts @@ -2,12 +2,13 @@ import { applyConfig, handleZodError } from "@/api"; import { randomString } from "@/math"; import { errorResponse, response } from "@/response"; import { zValidator } from "@hono/zod-validator"; +import { and, eq, isNull } from "drizzle-orm"; import type { Hono } from "hono"; import { SignJWT } from "jose"; import { z } from "zod"; import { TokenType } from "~/database/entities/token"; import { db } from "~/drizzle/db"; -import { RolePermissions, Tokens } from "~/drizzle/schema"; +import { RolePermissions, Tokens, Users } from "~/drizzle/schema"; import { config } from "~/packages/config-manager"; import { OAuthManager } from "~/packages/database-interface/oauth"; import { User } from "~/packages/database-interface/user"; @@ -111,7 +112,8 @@ export default (app: Hono) => return userInfo; } - const { sub } = userInfo.userInfo; + const { sub, email, preferred_username, picture } = + userInfo.userInfo; const flow = userInfo.flow; // If linking account @@ -119,7 +121,7 @@ export default (app: Hono) => return await manager.linkUser(user_id, userInfo); } - const userId = ( + let userId = ( await db.query.OpenIdAccounts.findFirst({ where: (account, { eq, and }) => and( @@ -130,16 +132,76 @@ export default (app: Hono) => )?.userId; if (!userId) { - return returnError( - { - redirect_uri: flow.application?.redirectUri, - client_id: flow.application?.clientId, - response_type: "code", - scope: flow.application?.scopes, - }, - "invalid_request", - "No user found with that account", - ); + // Register new user + if ( + config.signups.registration && + config.oidc.allow_registration + ) { + let username = + preferred_username ?? + email?.split("@")[0] ?? + randomString(8, "hex"); + + const usernameValidator = z + .string() + .regex(/^[a-z0-9_]+$/) + .min(3) + .max(config.validation.max_username_size) + .refine( + (value) => + !config.validation.username_blacklist.includes( + value, + ), + ) + .refine((value) => + config.filters.username.some((filter) => + value.match(filter), + ), + ) + .refine( + async (value) => + !(await User.fromSql( + and( + eq(Users.username, value), + isNull(Users.instanceId), + ), + )), + ); + + try { + await usernameValidator.parseAsync(username); + } catch { + username = randomString(8, "hex"); + } + + const doesEmailExist = email + ? !!(await User.fromSql(eq(Users.email, email))) + : false; + + // Create new user + const user = await User.fromDataLocal({ + email: doesEmailExist ? undefined : email, + username, + avatar: picture, + password: undefined, + }); + + // Link account + await manager.linkUserInDatabase(user.id, sub); + + userId = user.id; + } else { + return returnError( + { + redirect_uri: flow.application?.redirectUri, + client_id: flow.application?.clientId, + response_type: "code", + scope: flow.application?.scopes, + }, + "invalid_request", + "No user found with that account", + ); + } } const user = await User.fromId(userId);