axvault 1.0.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/LICENSE +21 -0
- package/README.md +213 -0
- package/bin/axvault +2 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +108 -0
- package/dist/commands/credential.d.ts +13 -0
- package/dist/commands/credential.js +113 -0
- package/dist/commands/init.d.ts +8 -0
- package/dist/commands/init.js +50 -0
- package/dist/commands/key.d.ts +21 -0
- package/dist/commands/key.js +157 -0
- package/dist/commands/serve.d.ts +12 -0
- package/dist/commands/serve.js +93 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +44 -0
- package/dist/db/client.d.ts +13 -0
- package/dist/db/client.js +38 -0
- package/dist/db/migrations.d.ts +12 -0
- package/dist/db/migrations.js +96 -0
- package/dist/db/repositories/api-keys.d.ts +42 -0
- package/dist/db/repositories/api-keys.js +86 -0
- package/dist/db/repositories/audit-log.d.ts +37 -0
- package/dist/db/repositories/audit-log.js +58 -0
- package/dist/db/repositories/credentials.d.ts +48 -0
- package/dist/db/repositories/credentials.js +79 -0
- package/dist/db/types.d.ts +44 -0
- package/dist/db/types.js +4 -0
- package/dist/handlers/delete-credential.d.ts +15 -0
- package/dist/handlers/delete-credential.js +50 -0
- package/dist/handlers/get-credential.d.ts +21 -0
- package/dist/handlers/get-credential.js +143 -0
- package/dist/handlers/list-credentials.d.ts +12 -0
- package/dist/handlers/list-credentials.js +29 -0
- package/dist/handlers/put-credential.d.ts +15 -0
- package/dist/handlers/put-credential.js +94 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +14 -0
- package/dist/lib/encryption.d.ts +17 -0
- package/dist/lib/encryption.js +38 -0
- package/dist/lib/format.d.ts +92 -0
- package/dist/lib/format.js +216 -0
- package/dist/middleware/auth.d.ts +21 -0
- package/dist/middleware/auth.js +50 -0
- package/dist/middleware/validate-parameters.d.ts +10 -0
- package/dist/middleware/validate-parameters.js +26 -0
- package/dist/refresh/check-refresh.d.ts +40 -0
- package/dist/refresh/check-refresh.js +83 -0
- package/dist/refresh/log-refresh.d.ts +17 -0
- package/dist/refresh/log-refresh.js +35 -0
- package/dist/refresh/refresh-manager.d.ts +51 -0
- package/dist/refresh/refresh-manager.js +132 -0
- package/dist/server/routes.d.ts +12 -0
- package/dist/server/routes.js +44 -0
- package/dist/server/server.d.ts +18 -0
- package/dist/server/server.js +106 -0
- package/package.json +93 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encryption utilities for credential storage.
|
|
3
|
+
*
|
|
4
|
+
* Wraps axshared encryption functions with vault-specific configuration.
|
|
5
|
+
*/
|
|
6
|
+
import { decrypt, encrypt } from "axshared";
|
|
7
|
+
/** Validate encryption key is configured */
|
|
8
|
+
function getEncryptionKey() {
|
|
9
|
+
const key = process.env["AXVAULT_ENCRYPTION_KEY"];
|
|
10
|
+
if (!key || key.length < 32) {
|
|
11
|
+
throw new Error("AXVAULT_ENCRYPTION_KEY must be set to at least 32 characters");
|
|
12
|
+
}
|
|
13
|
+
return key;
|
|
14
|
+
}
|
|
15
|
+
/** Encrypt credential data for storage */
|
|
16
|
+
function encryptCredential(data) {
|
|
17
|
+
const encryptionKey = getEncryptionKey();
|
|
18
|
+
const plaintext = JSON.stringify(data);
|
|
19
|
+
const encrypted = encrypt(plaintext, encryptionKey);
|
|
20
|
+
return {
|
|
21
|
+
encryptedData: encrypted.ciphertext,
|
|
22
|
+
salt: encrypted.salt,
|
|
23
|
+
iv: encrypted.iv,
|
|
24
|
+
authTag: encrypted.tag,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Decrypt credential data from storage */
|
|
28
|
+
function decryptCredential(credential) {
|
|
29
|
+
const encryptionKey = getEncryptionKey();
|
|
30
|
+
const decrypted = decrypt({
|
|
31
|
+
ciphertext: credential.encryptedData,
|
|
32
|
+
salt: credential.salt,
|
|
33
|
+
iv: credential.iv,
|
|
34
|
+
tag: credential.authTag,
|
|
35
|
+
}, encryptionKey);
|
|
36
|
+
return JSON.parse(decrypted);
|
|
37
|
+
}
|
|
38
|
+
export { decryptCredential, encryptCredential };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared formatting utilities for CLI output.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Strip control characters and ANSI escape sequences to prevent terminal
|
|
6
|
+
* injection and preserve TSV structure.
|
|
7
|
+
*
|
|
8
|
+
* Removes:
|
|
9
|
+
* - 7-bit ANSI/CSI escape sequences (ESC [ ... final-byte)
|
|
10
|
+
* - 8-bit C1 CSI sequences (0x9B ... final-byte)
|
|
11
|
+
* - C0 controls (0x00-0x1F) including ESC, tab, newline
|
|
12
|
+
* - DEL (0x7F)
|
|
13
|
+
* - C1 controls (0x80-0x9F)
|
|
14
|
+
*/
|
|
15
|
+
export declare function sanitizeForTsv(value: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Format relative time from a Date.
|
|
18
|
+
* Returns human-readable strings like "just now", "5m ago", "2h ago".
|
|
19
|
+
*
|
|
20
|
+
* This helper is intended for past events (e.g., "last updated"), so future
|
|
21
|
+
* dates are treated as unexpected and return the generic string "in the future"
|
|
22
|
+
* instead of using granular units. For precise future-oriented formatting
|
|
23
|
+
* (e.g., "in 2h", "in 3d"), use {@link formatExpiresAt}.
|
|
24
|
+
*/
|
|
25
|
+
export declare function formatRelativeTime(date: Date | undefined): string;
|
|
26
|
+
/**
|
|
27
|
+
* Check if a string contains control characters (would be changed by sanitization).
|
|
28
|
+
*/
|
|
29
|
+
export declare function containsControlChars(value: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Validate API key ID format (k_ + 12 hex chars).
|
|
32
|
+
*/
|
|
33
|
+
export declare function isValidKeyId(id: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Format a single API key row for TSV output.
|
|
36
|
+
*/
|
|
37
|
+
export declare function formatKeyRow(key: {
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
readAccess: string[];
|
|
41
|
+
writeAccess: string[];
|
|
42
|
+
lastUsedAt?: Date;
|
|
43
|
+
}): string;
|
|
44
|
+
/**
|
|
45
|
+
* Parse comma-separated access list into array of entries.
|
|
46
|
+
*
|
|
47
|
+
* Returns object with:
|
|
48
|
+
* - `entries`: parsed entries if valid, undefined if invalid
|
|
49
|
+
* - `error`: error type if invalid ('control-chars' or 'invalid-format')
|
|
50
|
+
*
|
|
51
|
+
* Control characters are checked BEFORE trimming to prevent silent bypass
|
|
52
|
+
* (e.g., "\n*" would otherwise become "*" after trim).
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseAccessList(value: string | undefined): {
|
|
55
|
+
entries: string[];
|
|
56
|
+
error?: never;
|
|
57
|
+
} | {
|
|
58
|
+
entries?: never;
|
|
59
|
+
error: "control-chars" | "invalid-format";
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Get error message for access list parsing error.
|
|
63
|
+
*/
|
|
64
|
+
export declare function getAccessListErrorMessage(error: "control-chars" | "invalid-format"): string;
|
|
65
|
+
/**
|
|
66
|
+
* Normalize access list by collapsing to ['*'] if wildcard is present with other entries.
|
|
67
|
+
* Returns the normalized list and an optional warning message.
|
|
68
|
+
*/
|
|
69
|
+
export declare function normalizeAccessList(access: string[], optionName?: string): {
|
|
70
|
+
normalized: string[];
|
|
71
|
+
warning: string | undefined;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Format access list for display.
|
|
75
|
+
* Returns "(none)" for empty, "*" if wildcard present, otherwise comma-joined.
|
|
76
|
+
*/
|
|
77
|
+
export declare function formatAccessList(access: string[]): string;
|
|
78
|
+
/**
|
|
79
|
+
* Format optional date for JSON output.
|
|
80
|
+
* Returns ISO string or null for consistent JSON schema.
|
|
81
|
+
*/
|
|
82
|
+
export declare function formatDateForJson(date: Date | undefined): string | null;
|
|
83
|
+
/**
|
|
84
|
+
* Extract error message from unknown error value.
|
|
85
|
+
* Returns the error's message if it's an Error, otherwise stringifies it.
|
|
86
|
+
*/
|
|
87
|
+
export declare function getErrorMessage(error: unknown): string;
|
|
88
|
+
/**
|
|
89
|
+
* Format expiration time relative to now.
|
|
90
|
+
* Returns "(never)" for no expiration, or relative time like "in 2h", "expired 5m ago".
|
|
91
|
+
*/
|
|
92
|
+
export declare function formatExpiresAt(date: Date | undefined): string;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared formatting utilities for CLI output.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Strip control characters and ANSI escape sequences to prevent terminal
|
|
6
|
+
* injection and preserve TSV structure.
|
|
7
|
+
*
|
|
8
|
+
* Removes:
|
|
9
|
+
* - 7-bit ANSI/CSI escape sequences (ESC [ ... final-byte)
|
|
10
|
+
* - 8-bit C1 CSI sequences (0x9B ... final-byte)
|
|
11
|
+
* - C0 controls (0x00-0x1F) including ESC, tab, newline
|
|
12
|
+
* - DEL (0x7F)
|
|
13
|
+
* - C1 controls (0x80-0x9F)
|
|
14
|
+
*/
|
|
15
|
+
export function sanitizeForTsv(value) {
|
|
16
|
+
/* eslint-disable no-control-regex */
|
|
17
|
+
return (value
|
|
18
|
+
// Strip 7-bit ANSI CSI sequences: ESC [ <params> <intermediate> <final>
|
|
19
|
+
// Parameters: 0x30-0x3F, Intermediate: 0x20-0x2F, Final: 0x40-0x7E
|
|
20
|
+
.replaceAll(/\u001B\[[0-?]*[ -/]*[@-~]/gu, "")
|
|
21
|
+
// Strip 8-bit C1 CSI sequences: 0x9B <params> <intermediate> <final>
|
|
22
|
+
.replaceAll(/\u009B[0-?]*[ -/]*[@-~]/gu, "")
|
|
23
|
+
// Strip C0 controls (0x00-0x1F), DEL (0x7F), and C1 controls (0x80-0x9F)
|
|
24
|
+
.replaceAll(/[\u0000-\u001F\u007F-\u009F]/gu, ""));
|
|
25
|
+
/* eslint-enable no-control-regex */
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Format relative time from a Date.
|
|
29
|
+
* Returns human-readable strings like "just now", "5m ago", "2h ago".
|
|
30
|
+
*
|
|
31
|
+
* This helper is intended for past events (e.g., "last updated"), so future
|
|
32
|
+
* dates are treated as unexpected and return the generic string "in the future"
|
|
33
|
+
* instead of using granular units. For precise future-oriented formatting
|
|
34
|
+
* (e.g., "in 2h", "in 3d"), use {@link formatExpiresAt}.
|
|
35
|
+
*/
|
|
36
|
+
export function formatRelativeTime(date) {
|
|
37
|
+
if (!date)
|
|
38
|
+
return "never";
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const diff = now - date.getTime();
|
|
41
|
+
if (diff < 0)
|
|
42
|
+
return "in the future";
|
|
43
|
+
const seconds = Math.floor(diff / 1000);
|
|
44
|
+
if (seconds < 60)
|
|
45
|
+
return "just now";
|
|
46
|
+
const minutes = Math.floor(seconds / 60);
|
|
47
|
+
if (minutes < 60)
|
|
48
|
+
return `${minutes}m ago`;
|
|
49
|
+
const hours = Math.floor(minutes / 60);
|
|
50
|
+
if (hours < 24)
|
|
51
|
+
return `${hours}h ago`;
|
|
52
|
+
const days = Math.floor(hours / 24);
|
|
53
|
+
return `${days}d ago`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Check if a string contains control characters (would be changed by sanitization).
|
|
57
|
+
*/
|
|
58
|
+
export function containsControlChars(value) {
|
|
59
|
+
return sanitizeForTsv(value) !== value;
|
|
60
|
+
}
|
|
61
|
+
/** API key ID format: k_ followed by 12 hex characters */
|
|
62
|
+
const KEY_ID_PATTERN = /^k_[0-9a-f]{12}$/u;
|
|
63
|
+
/**
|
|
64
|
+
* Validate API key ID format (k_ + 12 hex chars).
|
|
65
|
+
*/
|
|
66
|
+
export function isValidKeyId(id) {
|
|
67
|
+
return KEY_ID_PATTERN.test(id);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Format a single API key row for TSV output.
|
|
71
|
+
*/
|
|
72
|
+
export function formatKeyRow(key) {
|
|
73
|
+
const id = sanitizeForTsv(key.id);
|
|
74
|
+
const name = sanitizeForTsv(key.name);
|
|
75
|
+
const readAccess = sanitizeForTsv(formatAccessList(key.readAccess));
|
|
76
|
+
const writeAccess = sanitizeForTsv(formatAccessList(key.writeAccess));
|
|
77
|
+
const lastUsed = formatRelativeTime(key.lastUsedAt);
|
|
78
|
+
return `${id}\t${name}\t${readAccess}\t${writeAccess}\t${lastUsed}`;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Validate access list entry format.
|
|
82
|
+
* Valid formats:
|
|
83
|
+
* - "*" (full wildcard granting access to all credentials)
|
|
84
|
+
* - "agent/name" (exactly one slash, both parts non-empty, no wildcards)
|
|
85
|
+
*
|
|
86
|
+
* Partial wildcards like "claude/*" are rejected because the authorization
|
|
87
|
+
* logic uses exact matching, not pattern matching.
|
|
88
|
+
*/
|
|
89
|
+
function isValidAccessEntry(entry) {
|
|
90
|
+
if (entry === "*")
|
|
91
|
+
return true;
|
|
92
|
+
const parts = entry.split("/");
|
|
93
|
+
const agent = parts[0];
|
|
94
|
+
const name = parts[1];
|
|
95
|
+
return (parts.length === 2 &&
|
|
96
|
+
agent !== undefined &&
|
|
97
|
+
agent.length > 0 &&
|
|
98
|
+
!agent.includes("*") &&
|
|
99
|
+
name !== undefined &&
|
|
100
|
+
name.length > 0 &&
|
|
101
|
+
!name.includes("*"));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Parse comma-separated access list into array of entries.
|
|
105
|
+
*
|
|
106
|
+
* Returns object with:
|
|
107
|
+
* - `entries`: parsed entries if valid, undefined if invalid
|
|
108
|
+
* - `error`: error type if invalid ('control-chars' or 'invalid-format')
|
|
109
|
+
*
|
|
110
|
+
* Control characters are checked BEFORE trimming to prevent silent bypass
|
|
111
|
+
* (e.g., "\n*" would otherwise become "*" after trim).
|
|
112
|
+
*/
|
|
113
|
+
export function parseAccessList(value) {
|
|
114
|
+
if (!value)
|
|
115
|
+
return { entries: [] };
|
|
116
|
+
const rawEntries = value.split(",");
|
|
117
|
+
// Check for control characters in raw entries before trimming
|
|
118
|
+
if (rawEntries.some((entry) => containsControlChars(entry))) {
|
|
119
|
+
return { error: "control-chars" };
|
|
120
|
+
}
|
|
121
|
+
const entries = rawEntries
|
|
122
|
+
.map((item) => item.trim())
|
|
123
|
+
.filter((item) => item.length > 0);
|
|
124
|
+
// Validate entry format: must be "*" or "agent/name"
|
|
125
|
+
if (!entries.every((entry) => isValidAccessEntry(entry))) {
|
|
126
|
+
return { error: "invalid-format" };
|
|
127
|
+
}
|
|
128
|
+
return { entries };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Get error message for access list parsing error.
|
|
132
|
+
*/
|
|
133
|
+
export function getAccessListErrorMessage(error) {
|
|
134
|
+
if (error === "control-chars") {
|
|
135
|
+
return "Access list contains control characters.";
|
|
136
|
+
}
|
|
137
|
+
return 'Invalid access list entry format. Entries must be "*" or "agent/name" (e.g., claude/work).';
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Normalize access list by collapsing to ['*'] if wildcard is present with other entries.
|
|
141
|
+
* Returns the normalized list and an optional warning message.
|
|
142
|
+
*/
|
|
143
|
+
export function normalizeAccessList(access, optionName) {
|
|
144
|
+
if (access.includes("*") && access.length > 1) {
|
|
145
|
+
const warning = optionName
|
|
146
|
+
? `--${optionName} '*' grants full access; ignoring other entries`
|
|
147
|
+
: "'*' grants full access; ignoring other entries";
|
|
148
|
+
return { normalized: ["*"], warning };
|
|
149
|
+
}
|
|
150
|
+
return { normalized: access, warning: undefined };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Format access list for display.
|
|
154
|
+
* Returns "(none)" for empty, "*" if wildcard present, otherwise comma-joined.
|
|
155
|
+
*/
|
|
156
|
+
export function formatAccessList(access) {
|
|
157
|
+
if (access.length === 0)
|
|
158
|
+
return "(none)";
|
|
159
|
+
if (access.includes("*"))
|
|
160
|
+
return "*";
|
|
161
|
+
return access.join(", ");
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Format optional date for JSON output.
|
|
165
|
+
* Returns ISO string or null for consistent JSON schema.
|
|
166
|
+
*/
|
|
167
|
+
export function formatDateForJson(date) {
|
|
168
|
+
// eslint-disable-next-line unicorn/no-null
|
|
169
|
+
return date?.toISOString() ?? null;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Extract error message from unknown error value.
|
|
173
|
+
* Returns the error's message if it's an Error, otherwise stringifies it.
|
|
174
|
+
*/
|
|
175
|
+
export function getErrorMessage(error) {
|
|
176
|
+
return error instanceof Error ? error.message : String(error);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Format expiration time relative to now.
|
|
180
|
+
* Returns "(never)" for no expiration, or relative time like "in 2h", "expired 5m ago".
|
|
181
|
+
*/
|
|
182
|
+
export function formatExpiresAt(date) {
|
|
183
|
+
if (!date)
|
|
184
|
+
return "(never)";
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
const diff = date.getTime() - now;
|
|
187
|
+
// Already expired
|
|
188
|
+
if (diff < 0) {
|
|
189
|
+
const elapsed = Math.abs(diff);
|
|
190
|
+
const seconds = Math.floor(elapsed / 1000);
|
|
191
|
+
if (seconds < 60)
|
|
192
|
+
return "expired just now";
|
|
193
|
+
const minutes = Math.floor(seconds / 60);
|
|
194
|
+
if (minutes < 60)
|
|
195
|
+
return `expired ${minutes}m ago`;
|
|
196
|
+
const hours = Math.floor(minutes / 60);
|
|
197
|
+
if (hours < 24)
|
|
198
|
+
return `expired ${hours}h ago`;
|
|
199
|
+
const days = Math.floor(hours / 24);
|
|
200
|
+
return `expired ${days}d ago`;
|
|
201
|
+
}
|
|
202
|
+
// Future expiration
|
|
203
|
+
const seconds = Math.floor(diff / 1000);
|
|
204
|
+
if (seconds === 0)
|
|
205
|
+
return "expires soon";
|
|
206
|
+
if (seconds < 60)
|
|
207
|
+
return `in ${seconds}s`;
|
|
208
|
+
const minutes = Math.floor(seconds / 60);
|
|
209
|
+
if (minutes < 60)
|
|
210
|
+
return `in ${minutes}m`;
|
|
211
|
+
const hours = Math.floor(minutes / 60);
|
|
212
|
+
if (hours < 24)
|
|
213
|
+
return `in ${hours}h`;
|
|
214
|
+
const days = Math.floor(hours / 24);
|
|
215
|
+
return `in ${days}d`;
|
|
216
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API key authentication middleware.
|
|
3
|
+
*
|
|
4
|
+
* Validates Bearer token and attaches API key record to request.
|
|
5
|
+
*/
|
|
6
|
+
import type { NextFunction, Request, Response } from "express";
|
|
7
|
+
import type Database from "better-sqlite3";
|
|
8
|
+
import { type ApiKeyRecord } from "../db/repositories/api-keys.js";
|
|
9
|
+
/** Extended request with API key */
|
|
10
|
+
interface AuthenticatedRequest extends Request {
|
|
11
|
+
apiKey: ApiKeyRecord;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Create authentication middleware.
|
|
15
|
+
*
|
|
16
|
+
* Validates API key from Authorization header and attaches to request.
|
|
17
|
+
* Returns 401 for missing/invalid key.
|
|
18
|
+
*/
|
|
19
|
+
declare function createAuthMiddleware(database: Database.Database): (request: Request, response: Response, next: NextFunction) => void;
|
|
20
|
+
export { createAuthMiddleware };
|
|
21
|
+
export type { AuthenticatedRequest };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API key authentication middleware.
|
|
3
|
+
*
|
|
4
|
+
* Validates Bearer token and attaches API key record to request.
|
|
5
|
+
*/
|
|
6
|
+
import { findApiKeyByKey, updateLastUsed, } from "../db/repositories/api-keys.js";
|
|
7
|
+
import { logAccess } from "../db/repositories/audit-log.js";
|
|
8
|
+
/** Extract Bearer token from Authorization header */
|
|
9
|
+
function extractBearerToken(authorizationHeader) {
|
|
10
|
+
if (!authorizationHeader)
|
|
11
|
+
return undefined;
|
|
12
|
+
const match = /^Bearer\s+(\S+)$/iu.exec(authorizationHeader);
|
|
13
|
+
return match?.[1];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Create authentication middleware.
|
|
17
|
+
*
|
|
18
|
+
* Validates API key from Authorization header and attaches to request.
|
|
19
|
+
* Returns 401 for missing/invalid key.
|
|
20
|
+
*/
|
|
21
|
+
function createAuthMiddleware(database) {
|
|
22
|
+
return (request, response, next) => {
|
|
23
|
+
const token = extractBearerToken(request.headers.authorization);
|
|
24
|
+
if (!token) {
|
|
25
|
+
logAccess(database, {
|
|
26
|
+
action: "auth",
|
|
27
|
+
success: false,
|
|
28
|
+
errorMessage: "Missing authorization header",
|
|
29
|
+
});
|
|
30
|
+
response.status(401).json({ error: "Missing authorization header" });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const apiKey = findApiKeyByKey(database, token);
|
|
34
|
+
if (!apiKey) {
|
|
35
|
+
logAccess(database, {
|
|
36
|
+
action: "auth",
|
|
37
|
+
success: false,
|
|
38
|
+
errorMessage: "Invalid API key",
|
|
39
|
+
});
|
|
40
|
+
response.status(401).json({ error: "Invalid API key" });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Update last used timestamp
|
|
44
|
+
updateLastUsed(database, apiKey.id);
|
|
45
|
+
// Attach API key to request
|
|
46
|
+
request.apiKey = apiKey;
|
|
47
|
+
next();
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export { createAuthMiddleware };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path parameter validation middleware.
|
|
3
|
+
*
|
|
4
|
+
* Validates agent and name parameters to prevent injection attacks
|
|
5
|
+
* and ensure consistent credential naming.
|
|
6
|
+
*/
|
|
7
|
+
import type { NextFunction, Request, Response } from "express";
|
|
8
|
+
/** Validate agent and name path parameters */
|
|
9
|
+
declare function validateParameters(request: Request, response: Response, next: NextFunction): void;
|
|
10
|
+
export { validateParameters };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path parameter validation middleware.
|
|
3
|
+
*
|
|
4
|
+
* Validates agent and name parameters to prevent injection attacks
|
|
5
|
+
* and ensure consistent credential naming.
|
|
6
|
+
*/
|
|
7
|
+
/** Valid pattern for agent and name path segments */
|
|
8
|
+
const VALID_PATH_SEGMENT = /^[\w.-]{1,64}$/u;
|
|
9
|
+
/** Validate agent and name path parameters */
|
|
10
|
+
function validateParameters(request, response, next) {
|
|
11
|
+
const { agent, name } = request.params;
|
|
12
|
+
if (agent !== undefined && !VALID_PATH_SEGMENT.test(agent)) {
|
|
13
|
+
response.status(400).json({
|
|
14
|
+
error: "Invalid agent format. Must be 1-64 characters: letters, numbers, dots, hyphens, underscores.",
|
|
15
|
+
});
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (name !== undefined && !VALID_PATH_SEGMENT.test(name)) {
|
|
19
|
+
response.status(400).json({
|
|
20
|
+
error: "Invalid name format. Must be 1-64 characters: letters, numbers, dots, hyphens, underscores.",
|
|
21
|
+
});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
next();
|
|
25
|
+
}
|
|
26
|
+
export { validateParameters };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure functions for checking if credentials need refresh.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from refresh-manager to reduce complexity.
|
|
5
|
+
*/
|
|
6
|
+
import { type Credentials } from "axauth";
|
|
7
|
+
/**
|
|
8
|
+
* Check if credential is refreshable (has refresh_token).
|
|
9
|
+
*
|
|
10
|
+
* axauth's refreshCredentials requires a refresh_token to work.
|
|
11
|
+
* API keys and tokens without refresh_token cannot be refreshed.
|
|
12
|
+
* Returns false for non-object data to avoid TypeError from `in` operator.
|
|
13
|
+
*/
|
|
14
|
+
declare function isRefreshable(data: unknown): data is Record<string, unknown>;
|
|
15
|
+
/**
|
|
16
|
+
* Check if credential needs refresh based on expiry.
|
|
17
|
+
*
|
|
18
|
+
* Only returns true if:
|
|
19
|
+
* 1. Credential data is an object with refresh_token (required for axauth refresh)
|
|
20
|
+
* 2. Credential has expiry info that is within threshold
|
|
21
|
+
*
|
|
22
|
+
* @param data - Decrypted credential data (accepts unknown for safety)
|
|
23
|
+
* @param thresholdSeconds - Refresh if expires within this many seconds
|
|
24
|
+
* @returns true if refresh needed, false otherwise
|
|
25
|
+
*/
|
|
26
|
+
declare function needsRefresh(data: unknown, thresholdSeconds: number): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Map vault credential data to axauth Credentials type.
|
|
29
|
+
*
|
|
30
|
+
* @param agent - Agent CLI name
|
|
31
|
+
* @param data - Decrypted credential data from vault (must have refresh_token)
|
|
32
|
+
* @returns Credentials object for axauth, or undefined if not mappable
|
|
33
|
+
*/
|
|
34
|
+
declare function toAxauthCredentials(agent: string, data: Record<string, unknown>): Credentials | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Extract expiry date from refreshed credentials.
|
|
37
|
+
* Looks for common expiry fields (expires_at, expiry_date).
|
|
38
|
+
*/
|
|
39
|
+
declare function extractExpiryDate(data: Record<string, unknown>): Date | undefined;
|
|
40
|
+
export { extractExpiryDate, isRefreshable, needsRefresh, toAxauthCredentials };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure functions for checking if credentials need refresh.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from refresh-manager to reduce complexity.
|
|
5
|
+
*/
|
|
6
|
+
import { AGENT_CLIS } from "axshared";
|
|
7
|
+
import { isCredentialExpired } from "axauth";
|
|
8
|
+
/** Valid agent CLI names - sourced from axshared for consistency */
|
|
9
|
+
const VALID_AGENTS = new Set(AGENT_CLIS);
|
|
10
|
+
/**
|
|
11
|
+
* Check if credential is refreshable (has refresh_token).
|
|
12
|
+
*
|
|
13
|
+
* axauth's refreshCredentials requires a refresh_token to work.
|
|
14
|
+
* API keys and tokens without refresh_token cannot be refreshed.
|
|
15
|
+
* Returns false for non-object data to avoid TypeError from `in` operator.
|
|
16
|
+
*/
|
|
17
|
+
function isRefreshable(data) {
|
|
18
|
+
return (typeof data === "object" &&
|
|
19
|
+
data !== null &&
|
|
20
|
+
"refresh_token" in data &&
|
|
21
|
+
typeof data.refresh_token === "string");
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Check if credential needs refresh based on expiry.
|
|
25
|
+
*
|
|
26
|
+
* Only returns true if:
|
|
27
|
+
* 1. Credential data is an object with refresh_token (required for axauth refresh)
|
|
28
|
+
* 2. Credential has expiry info that is within threshold
|
|
29
|
+
*
|
|
30
|
+
* @param data - Decrypted credential data (accepts unknown for safety)
|
|
31
|
+
* @param thresholdSeconds - Refresh if expires within this many seconds
|
|
32
|
+
* @returns true if refresh needed, false otherwise
|
|
33
|
+
*/
|
|
34
|
+
function needsRefresh(data, thresholdSeconds) {
|
|
35
|
+
// Must be an object with refresh_token to be refreshable
|
|
36
|
+
if (!isRefreshable(data))
|
|
37
|
+
return false;
|
|
38
|
+
const expired = isCredentialExpired(data, thresholdSeconds);
|
|
39
|
+
// undefined means no expiry info found - don't refresh
|
|
40
|
+
return expired === true;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Map vault credential data to axauth Credentials type.
|
|
44
|
+
*
|
|
45
|
+
* @param agent - Agent CLI name
|
|
46
|
+
* @param data - Decrypted credential data from vault (must have refresh_token)
|
|
47
|
+
* @returns Credentials object for axauth, or undefined if not mappable
|
|
48
|
+
*/
|
|
49
|
+
function toAxauthCredentials(agent, data) {
|
|
50
|
+
if (!VALID_AGENTS.has(agent)) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
// Only OAuth credentials can be refreshed
|
|
54
|
+
return {
|
|
55
|
+
agent: agent,
|
|
56
|
+
type: "oauth",
|
|
57
|
+
data,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Timestamp threshold to distinguish seconds from milliseconds.
|
|
62
|
+
* Values below this are assumed to be Unix timestamps in seconds.
|
|
63
|
+
*/
|
|
64
|
+
const SECONDS_THRESHOLD = 10_000_000_000;
|
|
65
|
+
/**
|
|
66
|
+
* Extract expiry date from refreshed credentials.
|
|
67
|
+
* Looks for common expiry fields (expires_at, expiry_date).
|
|
68
|
+
*/
|
|
69
|
+
function extractExpiryDate(data) {
|
|
70
|
+
// Google OAuth uses expiry_date (milliseconds)
|
|
71
|
+
if (typeof data.expiry_date === "number") {
|
|
72
|
+
return new Date(data.expiry_date);
|
|
73
|
+
}
|
|
74
|
+
// Generic OAuth uses expires_at (seconds or milliseconds)
|
|
75
|
+
if (typeof data.expires_at === "number") {
|
|
76
|
+
const ts = data.expires_at < SECONDS_THRESHOLD
|
|
77
|
+
? data.expires_at * 1000
|
|
78
|
+
: data.expires_at;
|
|
79
|
+
return new Date(ts);
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
export { extractExpiryDate, isRefreshable, needsRefresh, toAxauthCredentials };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logging helpers for refresh operations.
|
|
3
|
+
*/
|
|
4
|
+
import type Database from "better-sqlite3";
|
|
5
|
+
interface RefreshLogContext {
|
|
6
|
+
database: Database.Database;
|
|
7
|
+
apiKeyId: string;
|
|
8
|
+
agent: string;
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
/** Log a successful refresh */
|
|
12
|
+
declare function logRefreshSuccess(context: RefreshLogContext): void;
|
|
13
|
+
/** Log a failed refresh (best-effort, ignores logging errors) */
|
|
14
|
+
declare function logRefreshError(context: RefreshLogContext, errorMessage: string): void;
|
|
15
|
+
/** Extract error message from unknown error */
|
|
16
|
+
declare function getErrorMessage(error: unknown): string;
|
|
17
|
+
export { getErrorMessage, logRefreshError, logRefreshSuccess };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logging helpers for refresh operations.
|
|
3
|
+
*/
|
|
4
|
+
import { logAccess } from "../db/repositories/audit-log.js";
|
|
5
|
+
/** Log a successful refresh */
|
|
6
|
+
function logRefreshSuccess(context) {
|
|
7
|
+
logAccess(context.database, {
|
|
8
|
+
apiKeyId: context.apiKeyId,
|
|
9
|
+
action: "refresh",
|
|
10
|
+
agent: context.agent,
|
|
11
|
+
name: context.name,
|
|
12
|
+
success: true,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** Log a failed refresh (best-effort, ignores logging errors) */
|
|
16
|
+
function logRefreshError(context, errorMessage) {
|
|
17
|
+
try {
|
|
18
|
+
logAccess(context.database, {
|
|
19
|
+
apiKeyId: context.apiKeyId,
|
|
20
|
+
action: "refresh",
|
|
21
|
+
agent: context.agent,
|
|
22
|
+
name: context.name,
|
|
23
|
+
success: false,
|
|
24
|
+
errorMessage,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Ignore logging failures
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Extract error message from unknown error */
|
|
32
|
+
function getErrorMessage(error) {
|
|
33
|
+
return error instanceof Error ? error.message : String(error);
|
|
34
|
+
}
|
|
35
|
+
export { getErrorMessage, logRefreshError, logRefreshSuccess };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refresh manager with per-credential mutex locking.
|
|
3
|
+
*
|
|
4
|
+
* Prevents concurrent refreshes of the same credential and coordinates
|
|
5
|
+
* with axauth's refresh functionality.
|
|
6
|
+
*/
|
|
7
|
+
import type Database from "better-sqlite3";
|
|
8
|
+
/** Key for credential-specific mutex */
|
|
9
|
+
type CredentialKey = `${string}/${string}`;
|
|
10
|
+
/** Result type for the full refresh operation */
|
|
11
|
+
type FullRefreshResult = {
|
|
12
|
+
ok: true;
|
|
13
|
+
data: unknown;
|
|
14
|
+
expiresAt: Date | undefined;
|
|
15
|
+
updatedAt: Date;
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
error: string;
|
|
19
|
+
};
|
|
20
|
+
/** Options for refresh operation */
|
|
21
|
+
interface RefreshManagerOptions {
|
|
22
|
+
timeoutMs: number;
|
|
23
|
+
}
|
|
24
|
+
/** Build credential key for mutex */
|
|
25
|
+
declare function buildKey(agent: string, name: string): CredentialKey;
|
|
26
|
+
/**
|
|
27
|
+
* Perform refresh with mutex locking.
|
|
28
|
+
*
|
|
29
|
+
* If a refresh is already in progress for this credential, waits for it
|
|
30
|
+
* rather than starting a new one. The mutex covers the FULL operation
|
|
31
|
+
* (refresh + validate + persist), ensuring all callers see the same result.
|
|
32
|
+
*
|
|
33
|
+
* Note: There is a small race window where a request that loaded stale
|
|
34
|
+
* credential data could start a duplicate refresh if it checks the mutex
|
|
35
|
+
* right after the previous refresh completes. This is rare (requires precise
|
|
36
|
+
* timing), harmless (produces correct results), and self-limiting (OAuth
|
|
37
|
+
* providers handle duplicate refreshes). The complexity of cooldown timers
|
|
38
|
+
* or reference counting isn't justified for this edge case.
|
|
39
|
+
*
|
|
40
|
+
* @param database - Database connection
|
|
41
|
+
* @param agent - Agent name
|
|
42
|
+
* @param name - Credential name
|
|
43
|
+
* @param data - Current decrypted credential data
|
|
44
|
+
* @param apiKeyId - API key ID for audit logging
|
|
45
|
+
* @param originalUpdatedAt - Original updatedAt for optimistic locking
|
|
46
|
+
* @param options - Refresh options
|
|
47
|
+
* @returns Refresh result with new data, expiresAt, updatedAt or error
|
|
48
|
+
*/
|
|
49
|
+
declare function refreshWithMutex(database: Database.Database, agent: string, name: string, data: Record<string, unknown>, apiKeyId: string, originalUpdatedAt: Date, options: RefreshManagerOptions): Promise<FullRefreshResult>;
|
|
50
|
+
export { extractExpiryDate, isRefreshable, needsRefresh, toAxauthCredentials, } from "./check-refresh.js";
|
|
51
|
+
export { buildKey, refreshWithMutex };
|