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 = ""
# 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

View file

@ -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({

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(
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}${

View file

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

View file

@ -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",

View file

@ -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,6 +132,65 @@ export default (app: Hono) =>
)?.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(
{
redirect_uri: flow.application?.redirectUri,
@ -141,6 +202,7 @@ export default (app: Hono) =>
"No user found with that account",
);
}
}
const user = await User.fromId(userId);