feat: move sso and ldap logins into plugins (C2)

This commit is contained in:
LukeGus committed 2026-09-24 18:23:20 -05:00
1 parent 5c18435105
commit 925da4a661
223 files changed
+25778 -6820

No files matched your search

+122 -3
View File
@@ -8,6 +8,7 @@
import type {
PluginAuth,
PluginVerifiedIdentity,
PluginSsh,
PluginSshConnectOptions,
PluginSshHost,
@@ -28,7 +29,12 @@ import {
} from "../hosts/connect/auth-provider-registry.js";
import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interactive.js";
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
import { registerLoginMethod, registerSecondFactor } from "../auth/registry.js";
import {
getLoginMethod,
registerLoginMethod,
registerSecondFactor,
} from "../auth/registry.js";
import type { VerifiedIdentity } from "../auth/types.js";
import type {
MutableConnectConfig,
SshAuthProvider,
@@ -392,6 +398,60 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
};
}
/**
* Turns what a plugin method returned into core's identity. Only the fields
* the SDK documents are copied, so a plugin cannot set core-only extras, and
* its rate limit key is kept apart from core's and other plugins'.
*/
export function toCoreIdentity(
pluginId: string,
identity: PluginVerifiedIdentity,
): VerifiedIdentity {
if (identity.kind === "user") {
return {
kind: "user",
userId: identity.userId,
mfaSatisfied: identity.mfaSatisfied,
password: identity.password,
returnTo: identity.returnTo,
rememberMe: identity.rememberMe,
};
}
return {
kind: "external",
provider: identity.provider,
subject: identity.subject,
email: identity.email,
name: identity.name,
groups: identity.groups,
isAdmin: identity.isAdmin,
allowedUsers: identity.allowedUsers,
mfaSatisfied: identity.mfaSatisfied,
returnTo: identity.returnTo,
rememberMe: identity.rememberMe,
legacyIdentifier: identity.legacy?.identifier,
ssoProviderId:
identity.logoutClaims?.providerId ??
identity.legacy?.providerRowId ??
null,
oidcSub: identity.logoutClaims?.sub ?? null,
oidcSid: identity.logoutClaims?.sid ?? null,
roleSync: identity.roles
? {
desired: [...identity.roles.desired],
managed: [...identity.roles.managed],
}
: undefined,
rateLimitUsername: identity.rateLimitKey
? rateLimitKeyFor(pluginId, identity.rateLimitKey)
: undefined,
};
}
function rateLimitKeyFor(pluginId: string, key: string): string {
return `plugin:${pluginId}:${key}`;
}
export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
const pluginId = manifest.id;
const declared = manifest.capabilities;
@@ -477,13 +537,16 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
callback: method.callback
? async (...args) => {
await granted();
return method.callback!(...args);
return toCoreIdentity(
pluginId,
await method.callback!(...args),
);
}
: undefined,
verify: method.verify
? async (...args) => {
await granted();
return method.verify!(...args);
return toCoreIdentity(pluginId, await method.verify!(...args));
}
: undefined,
});
@@ -550,5 +613,61 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
success: true,
});
},
completeRedirectLogin: async (methodId, req, res) => {
requireDeclared(contributes.loginMethods, methodId, "loginMethods");
await granted();
const method = getLoginMethod(methodId);
if (!method || method.pluginId !== pluginId) {
throw new Error(
`Plugin ${pluginId} has no registered login method "${methodId}"`,
);
}
const { handleRedirectCallback } =
await import("../database/routes/auth-routes.js");
await handleRedirectCallback(method, req as never, res as never);
},
revokeSessions: async (match) => {
await granted();
if (!match.sub && !match.sid) return 0;
const { AuthManager } = await import("../utils/auth-manager.js");
const revoked = await AuthManager.getInstance().revokeSessionsByOidc({
ssoProviderId: match.providerId ?? null,
sub: match.sub ?? null,
sid: match.sid ?? null,
});
await audit(
"auth_sessions_revoked",
`${revoked} session(s) for provider ${match.providerId ?? "none"}`,
{ success: true },
);
return revoked;
},
loginRateLimit: {
isLocked: async (ip, key) => {
await granted();
const { loginRateLimiter } =
await import("../utils/login-rate-limiter.js");
return loginRateLimiter.isLocked(ip, rateLimitKeyFor(pluginId, key));
},
recordFailure: async (ip, key) => {
await granted();
const { loginRateLimiter } =
await import("../utils/login-rate-limiter.js");
loginRateLimiter.recordFailedAttempt(
ip,
rateLimitKeyFor(pluginId, key),
);
},
},
countLinkedUsers: async (provider) => {
await granted();
const { createCurrentUserAuthRepository } =
await import("../database/repositories/factory.js");
return createCurrentUserAuthRepository().countUsersForProvider(provider);
},
};
}
+5
View File
@@ -20,6 +20,7 @@
*/
import { pluginLogger } from "../utils/logger.js";
import { getRequestBaseUrlWithForceHTTPS } from "../utils/request-origin.js";
import { pluginEvents } from "./events.js";
import * as registry from "./registry.js";
import * as serviceRegistry from "./service-registry.js";
@@ -683,6 +684,10 @@ export function createPluginContext(
);
return router as never;
},
baseUrl: (req) =>
getRequestBaseUrlWithForceHTTPS(
req as Parameters<typeof getRequestBaseUrlWithForceHTTPS>[0],
),
},
ws: {
+7 -1
View File
@@ -343,7 +343,13 @@ export class PluginLoader {
// than leave it half-running against a schema that is not there. A
// throw here lands in the catch below, which fails this plugin only.
const { migratePlugin } = await import("./data.js");
await migratePlugin(plugin.id, plugin.dir);
const applied = await migratePlugin(plugin.id, plugin.dir);
// A new table may be where core data is waiting to move.
if (applied.length > 0) {
const { runPluginDataMoves } =
await import("../utils/crypto-migration/plugin-data-moves.js");
await runPluginDataMoves();
}
const handle = createPluginHandle(plugin.id, { activate, deactivate });
const ctx = createPluginContext(plugin.manifest, handle);