feat(auth): include authentication in workspace and tht diagnostics
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve } from "node:path";
|
||||
import { loadConfig, type AppConfig } from "../config.js";
|
||||
import { secretValue } from "../config/secret-bundle.js";
|
||||
import { createAuthentikGroupCatalog } from "./authentik-group-catalog.js";
|
||||
import { createCurrentLocalUserRegistryResolver, type LocalUserRegistry } from "./local-registry.js";
|
||||
import { createOidcProtocol, OidcDeviceFlowUnavailableError, type OidcProtocol } from "./oidc-client.js";
|
||||
import { createAuthDiagnoser, type AuthDiagnoser, type AuthDiagnostic, type AuthDiagnostics } from "./diagnostics.js";
|
||||
import type { LoadedAuthConfig } from "./types.js";
|
||||
|
||||
const AUTH_SECRET_REFERENCES = ["THT_OIDC_CLIENT_SECRET", "THT_AUTHENTIK_API_TOKEN"] as const;
|
||||
|
||||
function configuredSecretValues(config: AppConfig): readonly string[] {
|
||||
const values: string[] = [];
|
||||
for (const reference of AUTH_SECRET_REFERENCES) {
|
||||
try {
|
||||
const value = secretValue(config, reference);
|
||||
if (value !== undefined) values.push(value);
|
||||
} catch {
|
||||
// The fixed report below is the only externally-visible failure surface.
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export interface ConfiguredAuthDiagnoserOptions {
|
||||
localUserRegistry?: (loaded: LoadedAuthConfig) => LocalUserRegistry | undefined;
|
||||
oidcProtocol?: (loaded: LoadedAuthConfig) => OidcProtocol | undefined;
|
||||
}
|
||||
|
||||
/** Builds the one shared auth diagnostic implementation used by app routes and the one-shot CLI. */
|
||||
export function createConfiguredAuthDiagnoser(
|
||||
config: AppConfig,
|
||||
options: ConfiguredAuthDiagnoserOptions = {},
|
||||
): AuthDiagnoser {
|
||||
const localResolver = options.localUserRegistry === undefined
|
||||
? createCurrentLocalUserRegistryResolver()
|
||||
: undefined;
|
||||
const secretValues = (): ReadonlyMap<string, string> => {
|
||||
const values = new Map<string, string>();
|
||||
for (const reference of AUTH_SECRET_REFERENCES) {
|
||||
try {
|
||||
const value = secretValue(config, reference);
|
||||
if (value !== undefined) values.set(reference, value);
|
||||
} catch {
|
||||
// The shared diagnoser emits the fixed missing-secret diagnostic below.
|
||||
}
|
||||
}
|
||||
return values;
|
||||
};
|
||||
const loaded = (): LoadedAuthConfig | undefined => {
|
||||
try { return config.authentication?.current(); } catch { return undefined; }
|
||||
};
|
||||
return {
|
||||
async inspect(request): Promise<AuthDiagnostics> {
|
||||
const current = loaded();
|
||||
const protocol = current?.value.mode === "oidc"
|
||||
? options.oidcProtocol?.(current) ?? (() => {
|
||||
try {
|
||||
const clientSecret = secretValues().get("THT_OIDC_CLIENT_SECRET");
|
||||
if (!clientSecret) return undefined;
|
||||
return createOidcProtocol({
|
||||
issuer: current.value.oidc.issuer,
|
||||
clientId: current.value.oidc.clientId,
|
||||
clientSecret,
|
||||
callbackUrl: new URL("/api/auth/oidc/callback", current.value.publicUrl).href,
|
||||
scopes: current.value.oidc.scopes,
|
||||
groupsClaim: current.value.oidc.groupsClaim,
|
||||
});
|
||||
} catch { return undefined; }
|
||||
})()
|
||||
: undefined;
|
||||
const groupCatalog = current?.value.mode === "oidc" ? (() => {
|
||||
try {
|
||||
const token = secretValues().get("THT_AUTHENTIK_API_TOKEN");
|
||||
return token === undefined ? undefined : createAuthentikGroupCatalog({
|
||||
baseUrl: current.value.groupCatalog.baseUrl,
|
||||
apiToken: token,
|
||||
});
|
||||
} catch { return undefined; }
|
||||
})() : undefined;
|
||||
const report = await createAuthDiagnoser({
|
||||
authMode: config.authMode,
|
||||
authStateRoot: config.authStateRoot,
|
||||
authentication: config.authentication,
|
||||
secrets: secretValues(),
|
||||
localUserRegistry: current?.value.mode === "local"
|
||||
? options.localUserRegistry?.(current) ?? localResolver?.resolve(current)
|
||||
: undefined,
|
||||
oidcProtocol: protocol,
|
||||
groupCatalog,
|
||||
}).inspect(request);
|
||||
if (!request.interactive || !report.ready) return report;
|
||||
if (current?.value.mode !== "oidc" || !protocol?.verifyDeviceFlow || !request.presentDeviceCode) {
|
||||
return {
|
||||
ready: false,
|
||||
mode: report.mode,
|
||||
checks: [{
|
||||
level: "error",
|
||||
code: "oidc_device_flow_unavailable",
|
||||
message: "Interactive authentication diagnostics require OIDC device authorization.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
try {
|
||||
const identity = await protocol.verifyDeviceFlow(
|
||||
request.signal ?? AbortSignal.timeout(10 * 60_000), request.presentDeviceCode,
|
||||
);
|
||||
// Exact names only: unrelated provider groups are neither emitted nor retained.
|
||||
const mappedRoles = new Set<string>();
|
||||
for (const group of identity.groups) {
|
||||
for (const role of current.value.authorization.groupRoles[group] ?? []) mappedRoles.add(role);
|
||||
}
|
||||
void mappedRoles;
|
||||
return report;
|
||||
} catch (error) {
|
||||
return {
|
||||
ready: false,
|
||||
mode: "oidc",
|
||||
checks: [{
|
||||
level: "error",
|
||||
code: error instanceof OidcDeviceFlowUnavailableError
|
||||
? "oidc_device_flow_unavailable"
|
||||
: "oidc_groups_claim_invalid",
|
||||
message: error instanceof OidcDeviceFlowUnavailableError
|
||||
? "OIDC device authorization is unavailable."
|
||||
: "The OIDC device-flow identity could not be validated.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface DiagnosticCommandDependencies {
|
||||
diagnoser: AuthDiagnoser;
|
||||
secretValues?: readonly string[];
|
||||
stdout: (line: string) => void;
|
||||
stderr: (line: string) => void;
|
||||
}
|
||||
|
||||
function genericFailure(): AuthDiagnostics {
|
||||
return {
|
||||
ready: false,
|
||||
mode: "none",
|
||||
checks: [{ level: "error", code: "auth_config_invalid", message: "Authentication configuration is unavailable." }],
|
||||
};
|
||||
}
|
||||
|
||||
function redact(value: string, secrets: readonly string[]): string {
|
||||
let result = value;
|
||||
for (const secret of [...secrets].filter(Boolean).sort((left, right) => right.length - left.length)) {
|
||||
result = result.replaceAll(secret, "[REDACTED]");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function redactedReport(report: AuthDiagnostics, secrets: readonly string[]): AuthDiagnostics {
|
||||
return {
|
||||
...report,
|
||||
checks: report.checks.map((check): AuthDiagnostic => ({
|
||||
...check,
|
||||
message: redact(check.message, secrets),
|
||||
...(check.field === undefined ? {} : { field: redact(check.field, secrets) }),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function parseArguments(args: readonly string[]): { json: true; interactive: boolean } | undefined {
|
||||
let json = false;
|
||||
let interactive = false;
|
||||
for (const arg of args) {
|
||||
if (arg === "--json" && !json) json = true;
|
||||
else if (arg === "--interactive" && !interactive) interactive = true;
|
||||
else return undefined;
|
||||
}
|
||||
return json ? { json: true, interactive } : undefined;
|
||||
}
|
||||
|
||||
/** A bounded machine command: stdout receives exactly one final report and no progress text. */
|
||||
export async function runDiagnosticCommand(
|
||||
args: readonly string[],
|
||||
dependencies: DiagnosticCommandDependencies,
|
||||
): Promise<number> {
|
||||
const options = parseArguments(args);
|
||||
if (!options) {
|
||||
dependencies.stderr("usage: diagnostic-command.js --json [--interactive]");
|
||||
return 2;
|
||||
}
|
||||
let report: AuthDiagnostics;
|
||||
try {
|
||||
report = await dependencies.diagnoser.inspect({
|
||||
live: true,
|
||||
...(options.interactive ? {
|
||||
interactive: true,
|
||||
presentDeviceCode: (uri: string, code: string) => dependencies.stderr(`Open ${uri} and enter code ${code}`),
|
||||
} : {}),
|
||||
});
|
||||
} catch {
|
||||
report = genericFailure();
|
||||
}
|
||||
const safe = redactedReport(report, dependencies.secretValues ?? []);
|
||||
dependencies.stdout(`${JSON.stringify(safe)}\n`);
|
||||
return safe.ready ? 0 : 1;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let config: AppConfig | undefined;
|
||||
try { config = loadConfig(process.env); } catch { /* turn startup faults into the closed report below */ }
|
||||
const exitCode = await runDiagnosticCommand(process.argv.slice(2), {
|
||||
diagnoser: config ? createConfiguredAuthDiagnoser(config) : { inspect: async () => genericFailure() },
|
||||
...(config ? { secretValues: configuredSecretValues(config) } : {}),
|
||||
stdout: (line) => process.stdout.write(line),
|
||||
stderr: (line) => process.stderr.write(`${line}\n`),
|
||||
});
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
void main();
|
||||
}
|
||||
@@ -14,7 +14,13 @@ export type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics } from "./grou
|
||||
const LIVE_DIAGNOSTIC_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface AuthDiagnoser {
|
||||
inspect(options: { live: boolean; interactive?: boolean; signal?: AbortSignal }): Promise<AuthDiagnostics>;
|
||||
inspect(options: {
|
||||
live: boolean;
|
||||
interactive?: boolean;
|
||||
signal?: AbortSignal;
|
||||
/** Device-code presentation is transient operator output, never persisted diagnostic state. */
|
||||
presentDeviceCode?: (uri: string, code: string) => void;
|
||||
}): Promise<AuthDiagnostics>;
|
||||
}
|
||||
|
||||
export interface AuthDiagnoserDependencies {
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
calculatePKCECodeChallenge,
|
||||
customFetch,
|
||||
discovery,
|
||||
initiateDeviceAuthorization,
|
||||
pollDeviceAuthorizationGrant,
|
||||
type Configuration,
|
||||
type CustomFetch,
|
||||
} from "openid-client";
|
||||
@@ -57,6 +59,14 @@ export class OidcIssuerMismatchError extends OidcProtocolError {
|
||||
}
|
||||
}
|
||||
|
||||
/** Device authorization is optional OIDC metadata and must never fall back to a browser flow. */
|
||||
export class OidcDeviceFlowUnavailableError extends OidcProtocolError {
|
||||
constructor() {
|
||||
super("oidc_device_flow_unavailable");
|
||||
this.name = "OidcDeviceFlowUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface OidcProtocolOptions {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
@@ -77,6 +87,7 @@ const DEFAULT_HTTP_TIMEOUT_MS = 5_000;
|
||||
const MAX_HTTP_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_JWKS_TIMEOUT_MS = 5_000;
|
||||
const MAX_JWKS_TIMEOUT_MS = 30_000;
|
||||
const MAX_DEVICE_FLOW_TIMEOUT_MS = 10 * 60_000;
|
||||
const text = (value: unknown, maximum = 2048): value is string =>
|
||||
typeof value === "string" && value.length > 0 && value.length <= maximum && !/\p{Cc}/u.test(value);
|
||||
|
||||
@@ -599,5 +610,35 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
},
|
||||
async verifyDeviceFlow(signal, present) {
|
||||
let config: Configuration;
|
||||
try {
|
||||
config = await configuration();
|
||||
const endpoint = config.serverMetadata().device_authorization_endpoint;
|
||||
httpsEndpoint(endpoint);
|
||||
} catch (error) {
|
||||
if (error instanceof OidcIssuerMismatchError || error instanceof OidcProviderUnavailableError) throw error;
|
||||
throw new OidcDeviceFlowUnavailableError();
|
||||
}
|
||||
const deadline = AbortSignal.timeout(MAX_DEVICE_FLOW_TIMEOUT_MS);
|
||||
const deviceSignal = AbortSignal.any([signal, deadline]);
|
||||
try {
|
||||
deviceSignal.throwIfAborted();
|
||||
const device = await initiateDeviceAuthorization(config, { scope: options.scopes.join(" ") });
|
||||
if (!text(device.verification_uri, 2048) || !text(device.user_code, 256)) {
|
||||
throw new OidcDeviceFlowUnavailableError();
|
||||
}
|
||||
const verificationUri = httpsEndpoint(device.verification_uri);
|
||||
present(verificationUri.href, device.user_code);
|
||||
const tokens = await pollDeviceAuthorizationGrant(config, device, undefined, { signal: deviceSignal });
|
||||
await verifyIdTokenSignature(tokens.id_token, config, transport, jwksTimeoutMs);
|
||||
const claims = tokens.claims();
|
||||
if (!claims || Array.isArray(claims)) throw new OidcProtocolError();
|
||||
return identityFromClaims(claims as Record<string, unknown>, options);
|
||||
} catch (error) {
|
||||
if (error instanceof OidcDeviceFlowUnavailableError) throw error;
|
||||
throw protocolFailure(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user