mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
feat(api): ✨ Automatically register user when connecting with OIDC profile not already existing
This commit is contained in:
parent
70a669a29c
commit
99f14ba114
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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}${
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue