feat: move totp and passkeys into plugins (C1)

This commit is contained in:
LukeGus committed 2026-09-24 16:57:24 -05:00
1 parent f4f26a9b8b
commit 5c18435105
235 files changed
+22545 -5610

No files matched your search

+8 -1
View File
@@ -24,6 +24,8 @@ interface ActorStore {
userId: string;
/** Where the identity came from, for the audit line. */
source: "request" | "asUser" | "service";
/** The session a request came in on, so core can spare it when revoking. */
sessionId?: string;
}
const storage = new AsyncLocalStorage<ActorStore>();
@@ -32,8 +34,13 @@ export function runAsActor<T>(
userId: string,
source: ActorStore["source"],
fn: () => T,
sessionId?: string,
): T {
return storage.run({ userId, source }, fn);
return storage.run({ userId, source, sessionId }, fn);
}
export function getActorSessionId(): string | undefined {
return storage.getStore()?.sessionId;
}
export function getActor(): string | undefined {
+16 -3
View File
@@ -18,7 +18,7 @@ import {
PluginSshInteractionError,
} from "@termix/plugin-sdk/backend";
import { assertCapability } from "./permissions.js";
import { getActor } from "./actor.js";
import { getActor, getActorSessionId } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import {
getSshAuthProvider,
@@ -511,13 +511,26 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
recordEnrollment: async (userId, factorId) => {
requireDeclared(contributes.secondFactors, factorId, "secondFactors");
await granted();
const { createCurrentUserAuthRepository } =
await import("../database/repositories/factory.js");
const { assertSecondFactorEnrollmentAllowed } =
await import("../auth/core-auth.js");
assertSecondFactorEnrollmentAllowed();
const {
createCurrentSessionRepository,
createCurrentTrustedDeviceRepository,
createCurrentUserAuthRepository,
} = await import("../database/repositories/factory.js");
await createCurrentUserAuthRepository().recordSecondFactor(
userId,
pluginId,
factorId,
);
// A new factor signs the user out everywhere else and forgets trusted
// devices, so nothing that skipped it before keeps skipping it.
await createCurrentSessionRepository().revokeAllForUser(
userId,
getActor() === userId ? getActorSessionId() : undefined,
);
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
await audit("auth_factor_enrolled", `${factorId} for ${userId}`, {
success: true,
});
+20
View File
@@ -600,6 +600,26 @@ export function createPluginContext(
{ action: "secret_delete", details: () => "deleted a secret" },
),
seal: async (value) => {
await assertCapability(pluginId, "secrets:own", declared);
const { encryptSystemSecret } =
await import("../utils/system-secret-crypto.js");
return encryptSystemSecret(value);
},
unseal: async (sealed) => {
await assertCapability(pluginId, "secrets:own", declared);
const { decryptSystemSecret, isSystemEncrypted } =
await import("../utils/system-secret-crypto.js");
// Only values seal() wrote: a plain string must not pass as sealed.
if (!sealed || !isSystemEncrypted(sealed)) return null;
try {
return await decryptSystemSecret(sealed);
} catch {
return null;
}
},
offer: (key, resolve) => {
const entry = manifest.providesSecret?.find(
(candidate) => candidate.key === key,
+5 -2
View File
@@ -204,12 +204,15 @@ export function createPluginRouter({
// After auth, so the actor is the user core authenticated rather than
// whatever the request claimed.
outer.use((req: Request, _res: Response, next: NextFunction) => {
const userId = (req as Request & { userId?: string }).userId;
const { userId, sessionId } = req as Request & {
userId?: string;
sessionId?: string;
};
if (!userId) {
next();
return;
}
runAsActor(userId, "request", () => next());
runAsActor(userId, "request", () => next(), sessionId);
});
outer.use(inner);