feat: add auth extension points and one ssh connect pipeline

This commit is contained in:
LukeGus committed 2026-09-23 00:44:33 -05:00
1 parent d3c209af13
commit d774df8b41
159 files changed
+43243 -7451

No files matched your search

@@ -37,6 +37,7 @@ import { PluginStorageRepository } from "./plugin-storage-repository.js";
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
import { UserAuthRepository } from "./user-auth-repository.js";
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
import { RbacAccessRepository } from "./rbac-access-repository.js";
@@ -380,6 +381,13 @@ export function createCurrentPluginRepository(): PluginRepository {
);
}
export function createCurrentUserAuthRepository(): UserAuthRepository {
return new UserAuthRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("user_auth_repository_write"),
);
}
export function createCurrentPluginPermissionGrantRepository(): PluginPermissionGrantRepository {
return new PluginPermissionGrantRepository(
createCurrentRepositoryContext(),
@@ -0,0 +1,169 @@
import crypto from "crypto";
import { and, eq } from "drizzle-orm";
import { userExternalIdentities, userSecondFactors } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type UserExternalIdentityRecord =
typeof userExternalIdentities.$inferSelect;
export type UserSecondFactorRecord = typeof userSecondFactors.$inferSelect;
/** Subjects are indexed, so they stay within the portable key length. */
export const MAX_EXTERNAL_SUBJECT_LENGTH = 255;
/**
* Keeps a subject within the index length. Longer ones are hashed, which is
* stable, so the same provider answer always finds the same row.
*/
export function normalizeExternalSubject(subject: string): string {
if (subject.length <= MAX_EXTERNAL_SUBJECT_LENGTH) return subject;
return `sha256:${crypto.createHash("sha256").update(subject).digest("hex")}`;
}
/** External sign-in identities and second-factor enrolments. */
export class UserAuthRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findIdentity(
providerId: string,
rawSubject: string,
): Promise<UserExternalIdentityRecord | null> {
const subject = normalizeExternalSubject(rawSubject);
const rows = await this.context.drizzle
.select()
.from(userExternalIdentities)
.where(
and(
eq(userExternalIdentities.providerId, providerId),
eq(userExternalIdentities.subject, subject),
),
)
.limit(1);
return rows[0] ?? null;
}
async listIdentitiesForUser(
userId: string,
): Promise<UserExternalIdentityRecord[]> {
return this.context.drizzle
.select()
.from(userExternalIdentities)
.where(eq(userExternalIdentities.userId, userId));
}
async listAllIdentities(): Promise<UserExternalIdentityRecord[]> {
return this.context.drizzle.select().from(userExternalIdentities);
}
/** Links or moves an identity to a user. Idempotent. */
async linkIdentity(input: {
userId: string;
providerId: string;
subject: string;
email?: string | null;
}): Promise<void> {
const subject = normalizeExternalSubject(input.subject);
const existing = await this.findIdentity(input.providerId, subject);
if (existing) {
if (existing.userId === input.userId) return;
await this.context.drizzle
.update(userExternalIdentities)
.set({ userId: input.userId, email: input.email ?? existing.email })
.where(eq(userExternalIdentities.id, existing.id));
} else {
await this.context.drizzle.insert(userExternalIdentities).values({
userId: input.userId,
providerId: input.providerId,
subject,
email: input.email ?? null,
createdAt: new Date().toISOString(),
});
}
await this.onWrite?.();
}
async unlinkIdentitiesForUser(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(userExternalIdentities)
.where(eq(userExternalIdentities.userId, userId));
const affected = rowsAffected(result);
if (affected > 0) await this.onWrite?.();
return affected;
}
async moveIdentities(fromUserId: string, toUserId: string): Promise<void> {
await this.context.drizzle
.update(userExternalIdentities)
.set({ userId: toUserId })
.where(eq(userExternalIdentities.userId, fromUserId));
await this.onWrite?.();
}
async listSecondFactors(userId: string): Promise<UserSecondFactorRecord[]> {
return this.context.drizzle
.select()
.from(userSecondFactors)
.where(eq(userSecondFactors.userId, userId));
}
async listAllSecondFactors(): Promise<UserSecondFactorRecord[]> {
return this.context.drizzle.select().from(userSecondFactors);
}
async recordSecondFactor(
userId: string,
pluginId: string,
factorId: string,
): Promise<void> {
const rows = await this.context.drizzle
.select()
.from(userSecondFactors)
.where(
and(
eq(userSecondFactors.userId, userId),
eq(userSecondFactors.pluginId, pluginId),
eq(userSecondFactors.factorId, factorId),
),
)
.limit(1);
if (rows[0]) return;
await this.context.drizzle.insert(userSecondFactors).values({
userId,
pluginId,
factorId,
enrolledAt: new Date().toISOString(),
});
await this.onWrite?.();
}
async removeSecondFactor(
userId: string,
pluginId: string,
factorId: string,
): Promise<boolean> {
const result = await this.context.drizzle
.delete(userSecondFactors)
.where(
and(
eq(userSecondFactors.userId, userId),
eq(userSecondFactors.pluginId, pluginId),
eq(userSecondFactors.factorId, factorId),
),
);
const affected = rowsAffected(result) > 0;
if (affected) await this.onWrite?.();
return affected;
}
async clearSecondFactors(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(userSecondFactors)
.where(eq(userSecondFactors.userId, userId));
const affected = rowsAffected(result);
if (affected > 0) await this.onWrite?.();
return affected;
}
}