@enspirit/emb 0.14.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +162 -43
- package/dist/src/cli/abstract/BaseCommand.d.ts +1 -0
- package/dist/src/cli/abstract/BaseCommand.js +23 -4
- package/dist/src/cli/abstract/FlavouredCommand.d.ts +1 -0
- package/dist/src/cli/abstract/KubernetesCommand.d.ts +1 -0
- package/dist/src/cli/commands/components/logs.d.ts +2 -1
- package/dist/src/cli/commands/components/logs.js +21 -24
- package/dist/src/cli/commands/secrets/index.d.ts +14 -0
- package/dist/src/cli/commands/secrets/index.js +71 -0
- package/dist/src/cli/commands/secrets/providers.d.ts +12 -0
- package/dist/src/cli/commands/secrets/providers.js +50 -0
- package/dist/src/cli/commands/secrets/validate.d.ts +18 -0
- package/dist/src/cli/commands/secrets/validate.js +145 -0
- package/dist/src/cli/hooks/init.js +7 -1
- package/dist/src/cli/hooks/postrun.d.ts +7 -0
- package/dist/src/cli/hooks/postrun.js +128 -0
- package/dist/src/config/index.d.ts +10 -1
- package/dist/src/config/index.js +28 -3
- package/dist/src/config/schema.d.ts +7 -4
- package/dist/src/config/schema.json +173 -9
- package/dist/src/context.d.ts +9 -0
- package/dist/src/context.js +19 -0
- package/dist/src/docker/compose/operations/ComposeLogsOperation.d.ts +21 -0
- package/dist/src/docker/compose/operations/ComposeLogsOperation.js +85 -0
- package/dist/src/docker/compose/operations/index.d.ts +1 -0
- package/dist/src/docker/compose/operations/index.js +1 -0
- package/dist/src/docker/resources/DockerImageResource.js +16 -6
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/monorepo/monorepo.js +13 -5
- package/dist/src/monorepo/operations/resources/BuildResourcesOperation.js +1 -2
- package/dist/src/monorepo/operations/tasks/RunTasksOperation.d.ts +1 -1
- package/dist/src/monorepo/operations/tasks/RunTasksOperation.js +1 -1
- package/dist/src/monorepo/plugins/VaultPlugin.d.ts +46 -0
- package/dist/src/monorepo/plugins/VaultPlugin.js +91 -0
- package/dist/src/monorepo/plugins/index.d.ts +1 -0
- package/dist/src/monorepo/plugins/index.js +3 -0
- package/dist/src/monorepo/resources/index.d.ts +1 -0
- package/dist/src/monorepo/resources/index.js +1 -0
- package/dist/src/secrets/SecretDiscovery.d.ts +46 -0
- package/dist/src/secrets/SecretDiscovery.js +82 -0
- package/dist/src/secrets/SecretManager.d.ts +52 -0
- package/dist/src/secrets/SecretManager.js +75 -0
- package/dist/src/secrets/SecretProvider.d.ts +45 -0
- package/dist/src/secrets/SecretProvider.js +38 -0
- package/dist/src/secrets/index.d.ts +3 -0
- package/dist/src/secrets/index.js +3 -0
- package/dist/src/secrets/providers/VaultOidcHelper.d.ts +39 -0
- package/dist/src/secrets/providers/VaultOidcHelper.js +226 -0
- package/dist/src/secrets/providers/VaultProvider.d.ts +74 -0
- package/dist/src/secrets/providers/VaultProvider.js +266 -0
- package/dist/src/secrets/providers/VaultTokenCache.d.ts +60 -0
- package/dist/src/secrets/providers/VaultTokenCache.js +188 -0
- package/dist/src/secrets/providers/index.d.ts +2 -0
- package/dist/src/secrets/providers/index.js +2 -0
- package/dist/src/types.d.ts +2 -0
- package/dist/src/utils/TemplateExpander.d.ts +13 -1
- package/dist/src/utils/TemplateExpander.js +68 -15
- package/oclif.manifest.json +473 -68
- package/package.json +36 -30
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Location where a secret reference was found.
|
|
3
|
+
*/
|
|
4
|
+
export interface SecretLocation {
|
|
5
|
+
/** Component name (if applicable) */
|
|
6
|
+
component?: string;
|
|
7
|
+
/** Field path within the config (e.g., "env.DB_PASSWORD") */
|
|
8
|
+
field: string;
|
|
9
|
+
/** File path where the reference was found */
|
|
10
|
+
file?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A discovered secret reference in the configuration.
|
|
14
|
+
*/
|
|
15
|
+
export interface DiscoveredSecret {
|
|
16
|
+
/** Key within the secret (e.g., "password") */
|
|
17
|
+
key?: string;
|
|
18
|
+
/** Where this reference was found */
|
|
19
|
+
location: SecretLocation;
|
|
20
|
+
/** Original template string (e.g., "${vault:secret/myapp#password}") */
|
|
21
|
+
original: string;
|
|
22
|
+
/** Path to the secret (e.g., "secret/myapp/db") */
|
|
23
|
+
path: string;
|
|
24
|
+
/** Provider name (e.g., "vault") */
|
|
25
|
+
provider: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Discover all secret references in a configuration object.
|
|
29
|
+
*
|
|
30
|
+
* @param config - The configuration object to scan
|
|
31
|
+
* @param location - Base location information
|
|
32
|
+
* @param secretProviders - Set of registered secret provider names to look for
|
|
33
|
+
* @returns Array of discovered secret references
|
|
34
|
+
*/
|
|
35
|
+
export declare function discoverSecrets(config: Record<string, unknown>, location?: Omit<SecretLocation, 'field'>, secretProviders?: Set<string>): DiscoveredSecret[];
|
|
36
|
+
/**
|
|
37
|
+
* Deduplicate secret references by provider+path+key.
|
|
38
|
+
* Keeps track of all locations where each secret is used.
|
|
39
|
+
*/
|
|
40
|
+
export interface AggregatedSecret {
|
|
41
|
+
key?: string;
|
|
42
|
+
locations: SecretLocation[];
|
|
43
|
+
path: string;
|
|
44
|
+
provider: string;
|
|
45
|
+
}
|
|
46
|
+
export declare function aggregateSecrets(secrets: DiscoveredSecret[]): AggregatedSecret[];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Matches ${provider:path#key} patterns for secret providers
|
|
2
|
+
// We're specifically looking for non-env providers (vault, aws, azure, etc.)
|
|
3
|
+
const SECRET_REGEX = /\${(\w+):([\w/.]+(?:-[\w/.]+)*)(?:#([\w-]+))?(?::-[^}]*)?}/g;
|
|
4
|
+
/**
|
|
5
|
+
* Recursively find all secret references in an object.
|
|
6
|
+
*/
|
|
7
|
+
// eslint-disable-next-line max-params
|
|
8
|
+
function findSecretsInValue(value, fieldPath, location, secretProviders, results) {
|
|
9
|
+
if (typeof value === 'string') {
|
|
10
|
+
// Find all secret references in the string
|
|
11
|
+
let match;
|
|
12
|
+
SECRET_REGEX.lastIndex = 0; // Reset regex state
|
|
13
|
+
while ((match = SECRET_REGEX.exec(value)) !== null) {
|
|
14
|
+
const [original, provider, pathWithKey, explicitKey] = match;
|
|
15
|
+
// Only include registered secret providers
|
|
16
|
+
if (!secretProviders.has(provider)) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
// Parse path and key - key can be after # or part of path
|
|
20
|
+
let path = pathWithKey;
|
|
21
|
+
let key = explicitKey;
|
|
22
|
+
// If no explicit key via #, check if path contains #
|
|
23
|
+
if (!key && path.includes('#')) {
|
|
24
|
+
const hashIndex = path.indexOf('#');
|
|
25
|
+
key = path.slice(hashIndex + 1);
|
|
26
|
+
path = path.slice(0, hashIndex);
|
|
27
|
+
}
|
|
28
|
+
results.push({
|
|
29
|
+
provider,
|
|
30
|
+
path,
|
|
31
|
+
key,
|
|
32
|
+
original,
|
|
33
|
+
location: {
|
|
34
|
+
...location,
|
|
35
|
+
field: fieldPath,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else if (Array.isArray(value)) {
|
|
41
|
+
value.forEach((item, index) => {
|
|
42
|
+
findSecretsInValue(item, `${fieldPath}[${index}]`, location, secretProviders, results);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
else if (value !== null && typeof value === 'object') {
|
|
46
|
+
for (const [key, val] of Object.entries(value)) {
|
|
47
|
+
const newPath = fieldPath ? `${fieldPath}.${key}` : key;
|
|
48
|
+
findSecretsInValue(val, newPath, location, secretProviders, results);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Discover all secret references in a configuration object.
|
|
54
|
+
*
|
|
55
|
+
* @param config - The configuration object to scan
|
|
56
|
+
* @param location - Base location information
|
|
57
|
+
* @param secretProviders - Set of registered secret provider names to look for
|
|
58
|
+
* @returns Array of discovered secret references
|
|
59
|
+
*/
|
|
60
|
+
export function discoverSecrets(config, location = {}, secretProviders = new Set()) {
|
|
61
|
+
const results = [];
|
|
62
|
+
findSecretsInValue(config, '', location, secretProviders, results);
|
|
63
|
+
return results;
|
|
64
|
+
}
|
|
65
|
+
export function aggregateSecrets(secrets) {
|
|
66
|
+
const map = new Map();
|
|
67
|
+
for (const secret of secrets) {
|
|
68
|
+
const id = `${secret.provider}:${secret.path}#${secret.key || ''}`;
|
|
69
|
+
if (map.has(id)) {
|
|
70
|
+
map.get(id).locations.push(secret.location);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
map.set(id, {
|
|
74
|
+
provider: secret.provider,
|
|
75
|
+
path: secret.path,
|
|
76
|
+
key: secret.key,
|
|
77
|
+
locations: [secret.location],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return [...map.values()];
|
|
82
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { AbstractSecretProvider, SecretReference } from './SecretProvider.js';
|
|
2
|
+
/**
|
|
3
|
+
* Type for async source functions compatible with TemplateExpander.
|
|
4
|
+
*/
|
|
5
|
+
export type AsyncSecretSource = (key: string) => Promise<unknown>;
|
|
6
|
+
/**
|
|
7
|
+
* Manages secret providers and creates template sources for them.
|
|
8
|
+
*/
|
|
9
|
+
export declare class SecretManager {
|
|
10
|
+
private providers;
|
|
11
|
+
/**
|
|
12
|
+
* Register a secret provider.
|
|
13
|
+
* @param name Provider name (e.g., 'vault', 'op')
|
|
14
|
+
* @param provider The provider instance
|
|
15
|
+
*/
|
|
16
|
+
register(name: string, provider: AbstractSecretProvider): void;
|
|
17
|
+
/**
|
|
18
|
+
* Get a registered provider by name.
|
|
19
|
+
* @param name Provider name
|
|
20
|
+
* @returns The provider instance or undefined if not found
|
|
21
|
+
*/
|
|
22
|
+
get(name: string): AbstractSecretProvider | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Check if a provider is registered.
|
|
25
|
+
* @param name Provider name
|
|
26
|
+
*/
|
|
27
|
+
has(name: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Get all registered provider names.
|
|
30
|
+
*/
|
|
31
|
+
getProviderNames(): string[];
|
|
32
|
+
/**
|
|
33
|
+
* Connect all registered providers.
|
|
34
|
+
*/
|
|
35
|
+
connectAll(): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Disconnect all registered providers.
|
|
38
|
+
*/
|
|
39
|
+
disconnectAll(): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Parse a secret reference string into a SecretReference object.
|
|
42
|
+
* Format: "path/to/secret#key" or "path/to/secret"
|
|
43
|
+
* @param refString The reference string to parse
|
|
44
|
+
*/
|
|
45
|
+
parseReference(refString: string): SecretReference;
|
|
46
|
+
/**
|
|
47
|
+
* Create an async source function for use with TemplateExpander.
|
|
48
|
+
* @param providerName The name of the provider to use
|
|
49
|
+
* @returns An async function that resolves secrets
|
|
50
|
+
*/
|
|
51
|
+
createSource(providerName: string): AsyncSecretSource;
|
|
52
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manages secret providers and creates template sources for them.
|
|
3
|
+
*/
|
|
4
|
+
export class SecretManager {
|
|
5
|
+
providers = new Map();
|
|
6
|
+
/**
|
|
7
|
+
* Register a secret provider.
|
|
8
|
+
* @param name Provider name (e.g., 'vault', 'op')
|
|
9
|
+
* @param provider The provider instance
|
|
10
|
+
*/
|
|
11
|
+
register(name, provider) {
|
|
12
|
+
if (this.providers.has(name)) {
|
|
13
|
+
throw new Error(`Secret provider '${name}' is already registered`);
|
|
14
|
+
}
|
|
15
|
+
this.providers.set(name, provider);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Get a registered provider by name.
|
|
19
|
+
* @param name Provider name
|
|
20
|
+
* @returns The provider instance or undefined if not found
|
|
21
|
+
*/
|
|
22
|
+
get(name) {
|
|
23
|
+
return this.providers.get(name);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Check if a provider is registered.
|
|
27
|
+
* @param name Provider name
|
|
28
|
+
*/
|
|
29
|
+
has(name) {
|
|
30
|
+
return this.providers.has(name);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Get all registered provider names.
|
|
34
|
+
*/
|
|
35
|
+
getProviderNames() {
|
|
36
|
+
return [...this.providers.keys()];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Connect all registered providers.
|
|
40
|
+
*/
|
|
41
|
+
async connectAll() {
|
|
42
|
+
await Promise.all([...this.providers.values()].map((p) => p.connect()));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Disconnect all registered providers.
|
|
46
|
+
*/
|
|
47
|
+
async disconnectAll() {
|
|
48
|
+
await Promise.all([...this.providers.values()].map((p) => p.disconnect()));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Parse a secret reference string into a SecretReference object.
|
|
52
|
+
* Format: "path/to/secret#key" or "path/to/secret"
|
|
53
|
+
* @param refString The reference string to parse
|
|
54
|
+
*/
|
|
55
|
+
parseReference(refString) {
|
|
56
|
+
const [path, key] = refString.split('#');
|
|
57
|
+
return { path, key };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Create an async source function for use with TemplateExpander.
|
|
61
|
+
* @param providerName The name of the provider to use
|
|
62
|
+
* @returns An async function that resolves secrets
|
|
63
|
+
*/
|
|
64
|
+
createSource(providerName) {
|
|
65
|
+
return async (key) => {
|
|
66
|
+
const provider = this.get(providerName);
|
|
67
|
+
if (!provider) {
|
|
68
|
+
throw new Error(`Secret provider '${providerName}' not found. ` +
|
|
69
|
+
`Available providers: ${this.getProviderNames().join(', ') || 'none'}`);
|
|
70
|
+
}
|
|
71
|
+
const ref = this.parseReference(key);
|
|
72
|
+
return provider.get(ref);
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference to a secret in a provider.
|
|
3
|
+
*/
|
|
4
|
+
export interface SecretReference {
|
|
5
|
+
/** Optional field within the secret */
|
|
6
|
+
key?: string;
|
|
7
|
+
/** Path to the secret, e.g., "secret/data/myapp/db" */
|
|
8
|
+
path: string;
|
|
9
|
+
/** Optional version of the secret */
|
|
10
|
+
version?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Abstract base class for secret providers.
|
|
14
|
+
* Implementations should handle specific secret management systems
|
|
15
|
+
* (e.g., HashiCorp Vault, 1Password).
|
|
16
|
+
*/
|
|
17
|
+
export declare abstract class AbstractSecretProvider<C = unknown> {
|
|
18
|
+
protected config: C;
|
|
19
|
+
protected cache: Map<string, Record<string, unknown>>;
|
|
20
|
+
constructor(config: C);
|
|
21
|
+
/**
|
|
22
|
+
* Connect to the secret provider and authenticate.
|
|
23
|
+
*/
|
|
24
|
+
abstract connect(): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Disconnect from the secret provider and clean up resources.
|
|
27
|
+
*/
|
|
28
|
+
abstract disconnect(): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Fetch a secret from the provider.
|
|
31
|
+
* @param ref Reference to the secret
|
|
32
|
+
* @returns The secret data as a key-value record
|
|
33
|
+
*/
|
|
34
|
+
abstract fetchSecret(ref: SecretReference): Promise<Record<string, unknown>>;
|
|
35
|
+
/**
|
|
36
|
+
* Get a secret value, using cache if available.
|
|
37
|
+
* @param ref Reference to the secret
|
|
38
|
+
* @returns The secret value (entire record if no key specified, or specific field value)
|
|
39
|
+
*/
|
|
40
|
+
get(ref: SecretReference): Promise<unknown>;
|
|
41
|
+
/**
|
|
42
|
+
* Clear the cache.
|
|
43
|
+
*/
|
|
44
|
+
clearCache(): void;
|
|
45
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract base class for secret providers.
|
|
3
|
+
* Implementations should handle specific secret management systems
|
|
4
|
+
* (e.g., HashiCorp Vault, 1Password).
|
|
5
|
+
*/
|
|
6
|
+
export class AbstractSecretProvider {
|
|
7
|
+
config;
|
|
8
|
+
cache = new Map();
|
|
9
|
+
constructor(config) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Get a secret value, using cache if available.
|
|
14
|
+
* @param ref Reference to the secret
|
|
15
|
+
* @returns The secret value (entire record if no key specified, or specific field value)
|
|
16
|
+
*/
|
|
17
|
+
async get(ref) {
|
|
18
|
+
const cacheKey = `${ref.path}:${ref.version || 'latest'}`;
|
|
19
|
+
if (!this.cache.has(cacheKey)) {
|
|
20
|
+
this.cache.set(cacheKey, await this.fetchSecret(ref));
|
|
21
|
+
}
|
|
22
|
+
const cached = this.cache.get(cacheKey);
|
|
23
|
+
if (ref.key) {
|
|
24
|
+
if (!(ref.key in cached)) {
|
|
25
|
+
const availableKeys = Object.keys(cached).join(', ') || 'none';
|
|
26
|
+
throw new Error(`Key '${ref.key}' not found in secret '${ref.path}'. Available keys: ${availableKeys}`);
|
|
27
|
+
}
|
|
28
|
+
return cached[ref.key];
|
|
29
|
+
}
|
|
30
|
+
return cached;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Clear the cache.
|
|
34
|
+
*/
|
|
35
|
+
clearCache() {
|
|
36
|
+
this.cache.clear();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for the OIDC login flow.
|
|
3
|
+
*/
|
|
4
|
+
export interface OidcLoginOptions {
|
|
5
|
+
/** Vault namespace (optional) */
|
|
6
|
+
namespace?: string;
|
|
7
|
+
/** Local port for the callback server (default: 8250) */
|
|
8
|
+
port?: number;
|
|
9
|
+
/** OIDC role to authenticate as (optional, uses default role if omitted) */
|
|
10
|
+
role?: string;
|
|
11
|
+
/** Timeout in milliseconds for the login flow (default: 120000 = 2 minutes) */
|
|
12
|
+
timeout?: number;
|
|
13
|
+
/** Vault server address */
|
|
14
|
+
vaultAddress: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Result of an OIDC login.
|
|
18
|
+
*/
|
|
19
|
+
export interface OidcLoginResult {
|
|
20
|
+
/** The Vault client token */
|
|
21
|
+
token: string;
|
|
22
|
+
/** Token TTL in seconds */
|
|
23
|
+
ttlSeconds: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Perform an interactive OIDC login with Vault.
|
|
27
|
+
*
|
|
28
|
+
* This function:
|
|
29
|
+
* 1. Starts a local HTTP server to receive the callback
|
|
30
|
+
* 2. Requests an OIDC auth URL from Vault
|
|
31
|
+
* 3. Opens the user's browser to the auth URL
|
|
32
|
+
* 4. Waits for the callback with the Vault token
|
|
33
|
+
* 5. Returns the token and TTL
|
|
34
|
+
*
|
|
35
|
+
* @param options - OIDC login options
|
|
36
|
+
* @returns The Vault client token and TTL
|
|
37
|
+
* @throws VaultError if the login fails
|
|
38
|
+
*/
|
|
39
|
+
export declare function performOidcLogin(options: OidcLoginOptions): Promise<OidcLoginResult>;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/* eslint-disable n/no-unsupported-features/node-builtins -- fetch is stable in Node 20+ */
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { URL, URLSearchParams } from 'node:url';
|
|
5
|
+
import { VaultError } from './VaultProvider.js';
|
|
6
|
+
/**
|
|
7
|
+
* Perform an interactive OIDC login with Vault.
|
|
8
|
+
*
|
|
9
|
+
* This function:
|
|
10
|
+
* 1. Starts a local HTTP server to receive the callback
|
|
11
|
+
* 2. Requests an OIDC auth URL from Vault
|
|
12
|
+
* 3. Opens the user's browser to the auth URL
|
|
13
|
+
* 4. Waits for the callback with the Vault token
|
|
14
|
+
* 5. Returns the token and TTL
|
|
15
|
+
*
|
|
16
|
+
* @param options - OIDC login options
|
|
17
|
+
* @returns The Vault client token and TTL
|
|
18
|
+
* @throws VaultError if the login fails
|
|
19
|
+
*/
|
|
20
|
+
export async function performOidcLogin(options) {
|
|
21
|
+
const { vaultAddress, role, namespace, timeout = 120_000 } = options;
|
|
22
|
+
const port = options.port ?? 8250;
|
|
23
|
+
const callbackUrl = `http://localhost:${port}/oidc/callback`;
|
|
24
|
+
// Generate a random state and nonce for security
|
|
25
|
+
const state = randomBytes(16).toString('hex');
|
|
26
|
+
const nonce = randomBytes(16).toString('hex');
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
let timeoutId;
|
|
29
|
+
let server;
|
|
30
|
+
const cleanup = () => {
|
|
31
|
+
if (timeoutId) {
|
|
32
|
+
clearTimeout(timeoutId);
|
|
33
|
+
timeoutId = undefined;
|
|
34
|
+
}
|
|
35
|
+
if (server) {
|
|
36
|
+
server.close();
|
|
37
|
+
server = undefined;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
// Set up timeout
|
|
41
|
+
timeoutId = setTimeout(() => {
|
|
42
|
+
cleanup();
|
|
43
|
+
reject(new VaultError('OIDC login timed out. Please try again.', 'VAULT_AUTH_ERROR'));
|
|
44
|
+
}, timeout);
|
|
45
|
+
// Create the callback server
|
|
46
|
+
server = createServer(async (req, res) => {
|
|
47
|
+
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
48
|
+
if (url.pathname === '/oidc/callback') {
|
|
49
|
+
// Check for errors in callback
|
|
50
|
+
const error = url.searchParams.get('error');
|
|
51
|
+
const errorDescription = url.searchParams.get('error_description');
|
|
52
|
+
if (error) {
|
|
53
|
+
sendHtmlResponse(res, 'Login Failed', `<p>Error: ${error}</p><p>${errorDescription || ''}</p>`);
|
|
54
|
+
cleanup();
|
|
55
|
+
reject(new VaultError(`OIDC login failed: ${error} - ${errorDescription || 'Unknown error'}`, 'VAULT_AUTH_ERROR'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// Extract the code from the callback
|
|
59
|
+
const code = url.searchParams.get('code');
|
|
60
|
+
const returnedState = url.searchParams.get('state');
|
|
61
|
+
if (!code) {
|
|
62
|
+
sendHtmlResponse(res, 'Login Failed', '<p>No authorization code received.</p>');
|
|
63
|
+
cleanup();
|
|
64
|
+
reject(new VaultError('OIDC login failed: No authorization code received', 'VAULT_AUTH_ERROR'));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Note: We don't validate state client-side because Vault generates
|
|
68
|
+
// its own state (prefixed with 'st_') regardless of what we send.
|
|
69
|
+
// Vault validates the state internally when we call the callback endpoint.
|
|
70
|
+
try {
|
|
71
|
+
// Exchange the code for a Vault token
|
|
72
|
+
// Pass the returned state/nonce from Keycloak (which are Vault's values)
|
|
73
|
+
const returnedNonce = url.searchParams.get('nonce') || nonce;
|
|
74
|
+
const result = await exchangeCodeForToken({
|
|
75
|
+
vaultAddress,
|
|
76
|
+
role,
|
|
77
|
+
namespace,
|
|
78
|
+
code,
|
|
79
|
+
state: returnedState || state,
|
|
80
|
+
nonce: returnedNonce,
|
|
81
|
+
});
|
|
82
|
+
sendHtmlResponse(res, 'Login Successful', '<p>You have been authenticated. You may close this window.</p>');
|
|
83
|
+
cleanup();
|
|
84
|
+
resolve(result);
|
|
85
|
+
}
|
|
86
|
+
catch (error_) {
|
|
87
|
+
const errorMessage = error_ instanceof Error ? error_.message : 'Unknown error';
|
|
88
|
+
sendHtmlResponse(res, 'Login Failed', `<p>Failed to complete authentication: ${errorMessage}</p>`);
|
|
89
|
+
cleanup();
|
|
90
|
+
reject(error_);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
res.writeHead(404);
|
|
95
|
+
res.end('Not Found');
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
server.on('error', (err) => {
|
|
99
|
+
cleanup();
|
|
100
|
+
reject(new VaultError(`Failed to start callback server: ${err.message}`, 'VAULT_AUTH_ERROR'));
|
|
101
|
+
});
|
|
102
|
+
server.listen(port, 'localhost', async () => {
|
|
103
|
+
try {
|
|
104
|
+
// Get the OIDC auth URL from Vault
|
|
105
|
+
const authUrl = await getOidcAuthUrl({
|
|
106
|
+
vaultAddress,
|
|
107
|
+
role,
|
|
108
|
+
namespace,
|
|
109
|
+
redirectUri: callbackUrl,
|
|
110
|
+
state,
|
|
111
|
+
nonce,
|
|
112
|
+
});
|
|
113
|
+
// Open the browser
|
|
114
|
+
const open = (await import('open')).default;
|
|
115
|
+
await open(authUrl);
|
|
116
|
+
console.log('Opening browser for authentication...');
|
|
117
|
+
console.log(`If the browser doesn't open, navigate to:\n${authUrl}`);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
cleanup();
|
|
121
|
+
reject(error);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Get the OIDC auth URL from Vault.
|
|
128
|
+
*/
|
|
129
|
+
async function getOidcAuthUrl(options) {
|
|
130
|
+
const { vaultAddress, role, namespace, redirectUri, state, nonce } = options;
|
|
131
|
+
const url = new URL('/v1/auth/oidc/oidc/auth_url', vaultAddress);
|
|
132
|
+
const headers = {
|
|
133
|
+
'Content-Type': 'application/json',
|
|
134
|
+
};
|
|
135
|
+
if (namespace) {
|
|
136
|
+
headers['X-Vault-Namespace'] = namespace;
|
|
137
|
+
}
|
|
138
|
+
// Build the request body
|
|
139
|
+
// Vault API uses snake_case for these parameters
|
|
140
|
+
/* eslint-disable camelcase */
|
|
141
|
+
const body = {
|
|
142
|
+
redirect_uri: redirectUri,
|
|
143
|
+
state,
|
|
144
|
+
nonce,
|
|
145
|
+
};
|
|
146
|
+
if (role) {
|
|
147
|
+
body.role = role;
|
|
148
|
+
}
|
|
149
|
+
/* eslint-enable camelcase */
|
|
150
|
+
const response = await fetch(url.toString(), {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers,
|
|
153
|
+
body: JSON.stringify(body),
|
|
154
|
+
});
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
const data = (await response.json().catch(() => ({})));
|
|
157
|
+
const errorMessage = data.errors?.join(', ') || `HTTP ${response.status}`;
|
|
158
|
+
throw new VaultError(`Failed to get OIDC auth URL: ${errorMessage}`, 'VAULT_AUTH_ERROR', response.status);
|
|
159
|
+
}
|
|
160
|
+
const data = (await response.json());
|
|
161
|
+
const authUrl = data.data?.auth_url;
|
|
162
|
+
if (!authUrl) {
|
|
163
|
+
throw new VaultError('Vault did not return an OIDC auth URL', 'VAULT_AUTH_ERROR');
|
|
164
|
+
}
|
|
165
|
+
return authUrl;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Exchange the authorization code for a Vault token.
|
|
169
|
+
*/
|
|
170
|
+
async function exchangeCodeForToken(options) {
|
|
171
|
+
const { vaultAddress, role, namespace, code, state, nonce } = options;
|
|
172
|
+
const url = new URL('/v1/auth/oidc/oidc/callback', vaultAddress);
|
|
173
|
+
const params = new URLSearchParams({
|
|
174
|
+
code,
|
|
175
|
+
state,
|
|
176
|
+
nonce,
|
|
177
|
+
});
|
|
178
|
+
if (role) {
|
|
179
|
+
params.set('role', role);
|
|
180
|
+
}
|
|
181
|
+
const headers = {
|
|
182
|
+
'Content-Type': 'application/json',
|
|
183
|
+
};
|
|
184
|
+
if (namespace) {
|
|
185
|
+
headers['X-Vault-Namespace'] = namespace;
|
|
186
|
+
}
|
|
187
|
+
const response = await fetch(`${url.toString()}?${params.toString()}`, {
|
|
188
|
+
method: 'GET',
|
|
189
|
+
headers,
|
|
190
|
+
});
|
|
191
|
+
if (!response.ok) {
|
|
192
|
+
const data = (await response.json().catch(() => ({})));
|
|
193
|
+
const errorMessage = data.errors?.join(', ') || `HTTP ${response.status}`;
|
|
194
|
+
throw new VaultError(`Failed to exchange code for token: ${errorMessage}`, 'VAULT_AUTH_ERROR', response.status);
|
|
195
|
+
}
|
|
196
|
+
const data = (await response.json());
|
|
197
|
+
const token = data.auth?.client_token;
|
|
198
|
+
if (!token) {
|
|
199
|
+
throw new VaultError('Vault did not return a client token', 'VAULT_AUTH_ERROR');
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
token,
|
|
203
|
+
ttlSeconds: data.auth?.lease_duration || 3600,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Send an HTML response to the browser.
|
|
208
|
+
*/
|
|
209
|
+
function sendHtmlResponse(res, title, body) {
|
|
210
|
+
const html = `<!DOCTYPE html>
|
|
211
|
+
<html>
|
|
212
|
+
<head>
|
|
213
|
+
<title>${title}</title>
|
|
214
|
+
<style>
|
|
215
|
+
body { font-family: sans-serif; padding: 40px; text-align: center; }
|
|
216
|
+
h1 { color: #333; }
|
|
217
|
+
</style>
|
|
218
|
+
</head>
|
|
219
|
+
<body>
|
|
220
|
+
<h1>${title}</h1>
|
|
221
|
+
${body}
|
|
222
|
+
</body>
|
|
223
|
+
</html>`;
|
|
224
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
225
|
+
res.end(html);
|
|
226
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { AbstractSecretProvider, SecretReference } from '../SecretProvider.js';
|
|
2
|
+
/**
|
|
3
|
+
* Authentication configuration for HashiCorp Vault.
|
|
4
|
+
*/
|
|
5
|
+
export type VaultAuthConfig = {
|
|
6
|
+
method: 'approle';
|
|
7
|
+
roleId: string;
|
|
8
|
+
secretId: string;
|
|
9
|
+
} | {
|
|
10
|
+
method: 'jwt';
|
|
11
|
+
role: string;
|
|
12
|
+
jwt: string;
|
|
13
|
+
} | {
|
|
14
|
+
method: 'kubernetes';
|
|
15
|
+
role: string;
|
|
16
|
+
} | {
|
|
17
|
+
method: 'oidc';
|
|
18
|
+
role?: string;
|
|
19
|
+
port?: number;
|
|
20
|
+
} | {
|
|
21
|
+
method: 'token';
|
|
22
|
+
token: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Configuration for the Vault provider.
|
|
26
|
+
*/
|
|
27
|
+
export interface VaultProviderConfig {
|
|
28
|
+
/** Vault server address (defaults to VAULT_ADDR env var) */
|
|
29
|
+
address: string;
|
|
30
|
+
/** Authentication configuration */
|
|
31
|
+
auth: VaultAuthConfig;
|
|
32
|
+
/** Vault namespace (optional, defaults to VAULT_NAMESPACE env var) */
|
|
33
|
+
namespace?: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Error class for Vault-specific errors.
|
|
37
|
+
*/
|
|
38
|
+
export declare class VaultError extends Error {
|
|
39
|
+
code: string;
|
|
40
|
+
statusCode?: number | undefined;
|
|
41
|
+
constructor(message: string, code: string, statusCode?: number | undefined);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* HashiCorp Vault secret provider.
|
|
45
|
+
* Supports KV v2 secrets engine.
|
|
46
|
+
*/
|
|
47
|
+
export declare class VaultProvider extends AbstractSecretProvider<VaultProviderConfig> {
|
|
48
|
+
private token;
|
|
49
|
+
connect(): Promise<void>;
|
|
50
|
+
disconnect(): Promise<void>;
|
|
51
|
+
fetchSecret(ref: SecretReference): Promise<Record<string, unknown>>;
|
|
52
|
+
/**
|
|
53
|
+
* Normalize a path for the appropriate secrets engine.
|
|
54
|
+
* - KV v2: Insert '/data/' after the mount point
|
|
55
|
+
* - 1Password Connect: Use path as-is (contains /vaults/ and /items/)
|
|
56
|
+
* - Other engines: Use path as-is
|
|
57
|
+
*/
|
|
58
|
+
private normalizeKvPath;
|
|
59
|
+
private buildHeaders;
|
|
60
|
+
private loginAppRole;
|
|
61
|
+
private loginKubernetes;
|
|
62
|
+
/**
|
|
63
|
+
* Authenticate using JWT (non-interactive).
|
|
64
|
+
* Suitable for CI/CD pipelines where a JWT is provided externally.
|
|
65
|
+
*/
|
|
66
|
+
private loginJwt;
|
|
67
|
+
/**
|
|
68
|
+
* Authenticate using OIDC (interactive browser flow).
|
|
69
|
+
* Opens a browser for the user to authenticate with Keycloak/OIDC provider.
|
|
70
|
+
*/
|
|
71
|
+
private loginOidc;
|
|
72
|
+
private verifyToken;
|
|
73
|
+
private parseErrorResponse;
|
|
74
|
+
}
|