feat(api): Automatically register user when connecting with OIDC profile not already existing

This commit is contained in:
Jesse Wierzbinski 2024-06-13 23:05:04 -10:00
parent 70a669a29c
commit 99f14ba114
No known key found for this signature in database
6 changed files with 99 additions and 26 deletions

View file

@ -48,11 +48,12 @@ rules = [
jwt_key = "" jwt_key = ""
# If enabled, Lysand will require users to log in with an OAuth provider # 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 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 # Delete this section if you don't want to use custom OAuth providers
# This is an example configuration # This is an example configuration
# The provider MUST support OpenID Connect with .well-known discovery # The provider MUST support OpenID Connect with .well-known discovery

View file

@ -93,6 +93,7 @@ export const configValidator = z.object({
}), }),
oidc: z.object({ oidc: z.object({
forced: z.boolean().default(false), forced: z.boolean().default(false),
allow_registration: z.boolean().default(true),
providers: z providers: z
.array( .array(
z.object({ z.object({

View file

@ -136,6 +136,14 @@ export class OAuthManager {
}; };
} }
async linkUserInDatabase(userId: string, sub: string): Promise<void> {
await db.insert(OpenIdAccounts).values({
serverId: sub,
issuerId: this.issuer.id,
userId: userId,
});
}
async linkUser( async linkUser(
userId: string, userId: string,
// Return value of automaticOidcFlow // Return value of automaticOidcFlow
@ -182,11 +190,7 @@ export class OAuthManager {
} }
// Link the account // Link the account
await db.insert(OpenIdAccounts).values({ await this.linkUserInDatabase(userId, userInfo.sub);
serverId: userInfo.sub,
issuerId: this.issuer.id,
userId: userId,
});
return response(null, 302, { return response(null, 302, {
Location: `${config.http.base_url}${ Location: `${config.http.base_url}${

View file

@ -436,7 +436,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
header?: string; header?: string;
admin?: boolean; admin?: boolean;
skipPasswordHash?: boolean; skipPasswordHash?: boolean;
}): Promise<User | null> { }): Promise<User> {
const keys = await User.generateKeys(); const keys = await User.generateKeys();
const newUser = ( const newUser = (
@ -472,7 +472,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const finalUser = await User.fromId(newUser.id); const finalUser = await User.fromId(newUser.id);
if (!finalUser) { if (!finalUser) {
return null; throw new Error("Failed to create user");
} }
// Add to Meilisearch // Add to Meilisearch

View file

@ -2,7 +2,7 @@ import { applyConfig, auth, handleZodError, jsonOrForm } from "@/api";
import { jsonResponse, response } from "@/response"; import { jsonResponse, response } from "@/response";
import { tempmailDomains } from "@/tempmail"; import { tempmailDomains } from "@/tempmail";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { eq } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import type { Hono } from "hono"; import type { Hono } from "hono";
import ISO6391 from "iso-639-1"; import ISO6391 from "iso-639-1";
import { z } from "zod"; import { z } from "zod";
@ -30,7 +30,7 @@ export const schemas = {
form: z.object({ form: z.object({
username: z.string(), username: z.string(),
email: z.string().toLowerCase(), email: z.string().toLowerCase(),
password: z.string(), password: z.string().optional(),
agreement: z agreement: z
.string() .string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) .transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
@ -153,7 +153,12 @@ export default (app: Hono) =>
} }
// Check if username is taken // 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({ errors.details.username.push({
error: "ERR_TAKEN", error: "ERR_TAKEN",
description: "is already taken", description: "is already taken",

View file

@ -2,12 +2,13 @@ import { applyConfig, handleZodError } from "@/api";
import { randomString } from "@/math"; import { randomString } from "@/math";
import { errorResponse, response } from "@/response"; import { errorResponse, response } from "@/response";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { and, eq, isNull } from "drizzle-orm";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { SignJWT } from "jose"; import { SignJWT } from "jose";
import { z } from "zod"; import { z } from "zod";
import { TokenType } from "~/database/entities/token"; import { TokenType } from "~/database/entities/token";
import { db } from "~/drizzle/db"; 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 { config } from "~/packages/config-manager";
import { OAuthManager } from "~/packages/database-interface/oauth"; import { OAuthManager } from "~/packages/database-interface/oauth";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
@ -111,7 +112,8 @@ export default (app: Hono) =>
return userInfo; return userInfo;
} }
const { sub } = userInfo.userInfo; const { sub, email, preferred_username, picture } =
userInfo.userInfo;
const flow = userInfo.flow; const flow = userInfo.flow;
// If linking account // If linking account
@ -119,7 +121,7 @@ export default (app: Hono) =>
return await manager.linkUser(user_id, userInfo); return await manager.linkUser(user_id, userInfo);
} }
const userId = ( let userId = (
await db.query.OpenIdAccounts.findFirst({ await db.query.OpenIdAccounts.findFirst({
where: (account, { eq, and }) => where: (account, { eq, and }) =>
and( and(
@ -130,6 +132,65 @@ export default (app: Hono) =>
)?.userId; )?.userId;
if (!userId) { if (!userId) {
// 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( return returnError(
{ {
redirect_uri: flow.application?.redirectUri, redirect_uri: flow.application?.redirectUri,
@ -141,6 +202,7 @@ export default (app: Hono) =>
"No user found with that account", "No user found with that account",
); );
} }
}
const user = await User.fromId(userId); const user = await User.fromId(userId);