@agentadmit/sdk 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/.github/workflows/publish.yml +53 -0
- package/LICENSE +56 -0
- package/README.md +203 -0
- package/dist/auth.d.ts +34 -0
- package/dist/auth.js +305 -0
- package/dist/config.d.ts +64 -0
- package/dist/config.js +92 -0
- package/dist/errors.d.ts +41 -0
- package/dist/errors.js +38 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +34 -0
- package/dist/keys.d.ts +12 -0
- package/dist/keys.js +29 -0
- package/dist/routes.d.ts +26 -0
- package/dist/routes.js +209 -0
- package/dist/storage.d.ts +62 -0
- package/dist/storage.js +161 -0
- package/package.json +55 -0
- package/src/auth.ts +356 -0
- package/src/config.ts +150 -0
- package/src/errors.ts +50 -0
- package/src/index.ts +27 -0
- package/src/keys.ts +33 -0
- package/src/routes.ts +245 -0
- package/src/storage.ts +228 -0
- package/tsconfig.json +19 -0
package/dist/storage.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* agentadmit/storage.ts
|
|
4
|
+
* Abstract storage interface + MongoDB + Memory implementations.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.MemoryStorage = exports.MongoDBStorage = void 0;
|
|
8
|
+
exports.createStorage = createStorage;
|
|
9
|
+
class MongoDBStorage {
|
|
10
|
+
constructor(uri, database, connectionsCollection, auditLogCollection, tokensCollection) {
|
|
11
|
+
this.users = null;
|
|
12
|
+
// Lazy import to keep mongodb as optional peer dep
|
|
13
|
+
const { MongoClient } = require('mongodb');
|
|
14
|
+
const client = new MongoClient(uri);
|
|
15
|
+
this.db = client.db(database);
|
|
16
|
+
this.connections = this.db.collection(connectionsCollection);
|
|
17
|
+
this.auditLog = this.db.collection(auditLogCollection);
|
|
18
|
+
this.tokens = this.db.collection(tokensCollection);
|
|
19
|
+
// Create indexes
|
|
20
|
+
this.connections.createIndex({ connection_id: 1 }, { unique: true }).catch(() => { });
|
|
21
|
+
this.connections.createIndex({ user_id: 1, status: 1 }).catch(() => { });
|
|
22
|
+
this.tokens.createIndex({ token_hash: 1 }, { unique: true }).catch(() => { });
|
|
23
|
+
this.auditLog.createIndex({ user_id: 1, timestamp: -1 }).catch(() => { });
|
|
24
|
+
console.log(`[AgentAdmit] MongoDB storage initialized: ${database}`);
|
|
25
|
+
}
|
|
26
|
+
setUsersCollection(name) {
|
|
27
|
+
this.users = this.db.collection(name);
|
|
28
|
+
}
|
|
29
|
+
async storeConnection(connection) {
|
|
30
|
+
await this.connections.insertOne(connection);
|
|
31
|
+
}
|
|
32
|
+
async getConnection(connectionId) {
|
|
33
|
+
return this.connections.findOne({ connection_id: connectionId });
|
|
34
|
+
}
|
|
35
|
+
async getActiveConnection(connectionId) {
|
|
36
|
+
return this.connections.findOne({ connection_id: connectionId, status: 'active' });
|
|
37
|
+
}
|
|
38
|
+
async updateConnection(connectionId, updates) {
|
|
39
|
+
const result = await this.connections.updateOne({ connection_id: connectionId }, { $set: updates });
|
|
40
|
+
return result.modifiedCount > 0;
|
|
41
|
+
}
|
|
42
|
+
async revokeConnection(connectionId) {
|
|
43
|
+
const result = await this.connections.updateOne({ connection_id: connectionId, status: 'active' }, { $set: { status: 'revoked', revoked_at: new Date() } });
|
|
44
|
+
return result.modifiedCount > 0;
|
|
45
|
+
}
|
|
46
|
+
async listConnections(userId) {
|
|
47
|
+
return this.connections.find({ user_id: userId }, { projection: { _id: 0 } }).sort({ created_at: -1 }).toArray();
|
|
48
|
+
}
|
|
49
|
+
async countActiveConnections(userId) {
|
|
50
|
+
return this.connections.countDocuments({ user_id: userId, status: 'active' });
|
|
51
|
+
}
|
|
52
|
+
async storeToken(tokenRecord) {
|
|
53
|
+
await this.tokens.insertOne(tokenRecord);
|
|
54
|
+
}
|
|
55
|
+
async getToken(tokenHash) {
|
|
56
|
+
return this.tokens.findOne({ token_hash: tokenHash });
|
|
57
|
+
}
|
|
58
|
+
async markTokenUsed(tokenHash) {
|
|
59
|
+
const result = await this.tokens.updateOne({ token_hash: tokenHash, used: false }, { $set: { used: true, used_at: new Date() } });
|
|
60
|
+
return result.modifiedCount > 0;
|
|
61
|
+
}
|
|
62
|
+
async logAccess(entry) {
|
|
63
|
+
try {
|
|
64
|
+
await this.auditLog.insertOne(entry);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
console.error('[AgentAdmit] Audit log failed:', err);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async countAuditCalls(userId, periodStart, periodEnd) {
|
|
71
|
+
return this.auditLog.countDocuments({
|
|
72
|
+
user_id: userId,
|
|
73
|
+
timestamp: { $gte: periodStart, $lt: periodEnd },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async getUser(userId, lookupField = 'user_id') {
|
|
77
|
+
if (!this.users)
|
|
78
|
+
return null;
|
|
79
|
+
return this.users.findOne({ [lookupField]: userId });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
exports.MongoDBStorage = MongoDBStorage;
|
|
83
|
+
class MemoryStorage {
|
|
84
|
+
constructor() {
|
|
85
|
+
this._connections = new Map();
|
|
86
|
+
this._tokens = new Map();
|
|
87
|
+
this._auditLog = [];
|
|
88
|
+
this._users = new Map();
|
|
89
|
+
}
|
|
90
|
+
async storeConnection(connection) {
|
|
91
|
+
this._connections.set(connection.connection_id, connection);
|
|
92
|
+
}
|
|
93
|
+
async getConnection(connectionId) {
|
|
94
|
+
return this._connections.get(connectionId) || null;
|
|
95
|
+
}
|
|
96
|
+
async getActiveConnection(connectionId) {
|
|
97
|
+
const conn = this._connections.get(connectionId);
|
|
98
|
+
return conn?.status === 'active' ? conn : null;
|
|
99
|
+
}
|
|
100
|
+
async updateConnection(connectionId, updates) {
|
|
101
|
+
const conn = this._connections.get(connectionId);
|
|
102
|
+
if (!conn)
|
|
103
|
+
return false;
|
|
104
|
+
Object.assign(conn, updates);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
async revokeConnection(connectionId) {
|
|
108
|
+
const conn = this._connections.get(connectionId);
|
|
109
|
+
if (!conn || conn.status !== 'active')
|
|
110
|
+
return false;
|
|
111
|
+
conn.status = 'revoked';
|
|
112
|
+
conn.revoked_at = new Date();
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
async listConnections(userId) {
|
|
116
|
+
return Array.from(this._connections.values()).filter(c => c.user_id === userId);
|
|
117
|
+
}
|
|
118
|
+
async countActiveConnections(userId) {
|
|
119
|
+
return Array.from(this._connections.values()).filter(c => c.user_id === userId && c.status === 'active').length;
|
|
120
|
+
}
|
|
121
|
+
async storeToken(tokenRecord) {
|
|
122
|
+
this._tokens.set(tokenRecord.token_hash, tokenRecord);
|
|
123
|
+
}
|
|
124
|
+
async getToken(tokenHash) {
|
|
125
|
+
return this._tokens.get(tokenHash) || null;
|
|
126
|
+
}
|
|
127
|
+
async markTokenUsed(tokenHash) {
|
|
128
|
+
const token = this._tokens.get(tokenHash);
|
|
129
|
+
if (!token || token.used)
|
|
130
|
+
return false;
|
|
131
|
+
token.used = true;
|
|
132
|
+
token.used_at = new Date();
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
async logAccess(entry) {
|
|
136
|
+
this._auditLog.push(entry);
|
|
137
|
+
}
|
|
138
|
+
async countAuditCalls(userId, periodStart, periodEnd) {
|
|
139
|
+
return this._auditLog.filter(e => e.user_id === userId &&
|
|
140
|
+
e.timestamp >= periodStart &&
|
|
141
|
+
e.timestamp < periodEnd).length;
|
|
142
|
+
}
|
|
143
|
+
async getUser(userId, lookupField = 'user_id') {
|
|
144
|
+
return this._users.get(userId) || null;
|
|
145
|
+
}
|
|
146
|
+
addTestUser(userId, data) {
|
|
147
|
+
this._users.set(userId, data);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
exports.MemoryStorage = MemoryStorage;
|
|
151
|
+
function createStorage(config) {
|
|
152
|
+
const backend = config.storage?.backend || 'mongodb';
|
|
153
|
+
if (backend === 'mongodb') {
|
|
154
|
+
const s = new MongoDBStorage(config.storage.uri, config.storage.database, config.storage.connections_collection || 'agentadmit_connections', config.storage.audit_log_collection || 'agentadmit_audit_log', config.storage.tokens_collection || 'agentadmit_tokens');
|
|
155
|
+
return s;
|
|
156
|
+
}
|
|
157
|
+
if (backend === 'memory') {
|
|
158
|
+
return new MemoryStorage();
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`Unsupported storage backend: ${backend}`);
|
|
161
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentadmit/sdk",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "AgentAdmit SDK \u2014 User-mediated AI agent authorization for Node.js apps",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch",
|
|
10
|
+
"test": "jest"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"jsonwebtoken": "9.0.0",
|
|
14
|
+
"js-yaml": "4.1.0",
|
|
15
|
+
"uuid": "9.0.0",
|
|
16
|
+
"mongodb": "6.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"typescript": "5.3.0",
|
|
20
|
+
"@types/node": "20.0.0",
|
|
21
|
+
"@types/jsonwebtoken": "9.0.0",
|
|
22
|
+
"@types/js-yaml": "4.0.0",
|
|
23
|
+
"@types/uuid": "9.0.0",
|
|
24
|
+
"@types/express": "4.17.0",
|
|
25
|
+
"jest": "29.0.0",
|
|
26
|
+
"@types/jest": "29.0.0",
|
|
27
|
+
"ts-jest": "29.0.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"express": ">=4.0.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependenciesMeta": {
|
|
33
|
+
"express": {
|
|
34
|
+
"optional": true
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
38
|
+
"author": "Christopher Emerson",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "https://github.com/PhoenixCo-Founder/agentadmit-sdk-node.git"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"agentadmit",
|
|
45
|
+
"ai-agent",
|
|
46
|
+
"authorization",
|
|
47
|
+
"auth",
|
|
48
|
+
"sdk",
|
|
49
|
+
"middleware"
|
|
50
|
+
],
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public",
|
|
53
|
+
"registry": "https://registry.npmjs.org"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentadmit/auth.ts
|
|
3
|
+
* Token validation, scope enforcement, and audit logging for Express.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Request, Response, NextFunction } from 'express';
|
|
7
|
+
import jwt from 'jsonwebtoken';
|
|
8
|
+
import { getConfig } from './config';
|
|
9
|
+
import { loadPublicKey } from './keys';
|
|
10
|
+
import { StorageBackend } from './storage';
|
|
11
|
+
import { RateLimitError } from './errors';
|
|
12
|
+
|
|
13
|
+
let _storage: StorageBackend | null = null;
|
|
14
|
+
let _verifyUserToken: ((token: string) => string | Promise<string>) | null = null;
|
|
15
|
+
|
|
16
|
+
export function setStorage(storage: StorageBackend) {
|
|
17
|
+
_storage = storage;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function setUserVerifier(fn: (token: string) => string | Promise<string>) {
|
|
21
|
+
_verifyUserToken = fn;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getStorage(): StorageBackend {
|
|
25
|
+
if (!_storage) throw new Error('AgentAdmit storage not initialized');
|
|
26
|
+
return _storage;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getBearerToken(req: Request): string | null {
|
|
30
|
+
const auth = req.headers.authorization || '';
|
|
31
|
+
if (auth.startsWith('Bearer ')) return auth.slice(7);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AgentContext {
|
|
36
|
+
auth_type: 'agent' | 'user';
|
|
37
|
+
user: Record<string, any>;
|
|
38
|
+
connection: Record<string, any> | null;
|
|
39
|
+
scopes: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Rate-limit retry helpers
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/** Parse an integer from an HTTP response header. Returns null if missing or invalid. */
|
|
47
|
+
function parseIntHeader(headers: Headers, name: string): number | null {
|
|
48
|
+
const val = headers.get(name);
|
|
49
|
+
if (val === null) return null;
|
|
50
|
+
const n = parseInt(val, 10);
|
|
51
|
+
return Number.isFinite(n) ? n : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Parse a float from an HTTP response header. Returns null if missing or invalid. */
|
|
55
|
+
function parseFloatHeader(headers: Headers, name: string): number | null {
|
|
56
|
+
const val = headers.get(name);
|
|
57
|
+
if (val === null) return null;
|
|
58
|
+
const n = parseFloat(val);
|
|
59
|
+
return Number.isFinite(n) ? n : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** sleep for `ms` milliseconds */
|
|
63
|
+
function sleep(ms: number): Promise<void> {
|
|
64
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* POST to the AgentAdmit introspection endpoint with automatic 429 retry.
|
|
69
|
+
*
|
|
70
|
+
* Retry policy:
|
|
71
|
+
* - Initial delay: 1 second
|
|
72
|
+
* - Each retry doubles the delay, capped at 30 seconds
|
|
73
|
+
* - Each delay adds 0–500 ms of random jitter
|
|
74
|
+
* - Honors Retry-After header if present
|
|
75
|
+
* - After maxRetries exhausted, throws RateLimitError
|
|
76
|
+
*/
|
|
77
|
+
async function introspectWithRetry(
|
|
78
|
+
verifyUrl: string,
|
|
79
|
+
token: string,
|
|
80
|
+
appId: string,
|
|
81
|
+
apiKey: string,
|
|
82
|
+
maxRetries: number,
|
|
83
|
+
): Promise<globalThis.Response> {
|
|
84
|
+
let delay = 1000; // ms
|
|
85
|
+
|
|
86
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
87
|
+
let response: globalThis.Response;
|
|
88
|
+
try {
|
|
89
|
+
response = await fetch(verifyUrl, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
Authorization: `Bearer ${apiKey}`,
|
|
93
|
+
'Content-Type': 'application/json',
|
|
94
|
+
},
|
|
95
|
+
body: JSON.stringify({ token }),
|
|
96
|
+
});
|
|
97
|
+
} catch (err: any) {
|
|
98
|
+
throw new Error(`AgentAdmit introspection failed (network): ${err.message}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (response.status !== 429) {
|
|
102
|
+
return response;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- 429 handling ---
|
|
106
|
+
const retryAfter = parseFloatHeader(response.headers, 'Retry-After');
|
|
107
|
+
const limit = parseIntHeader(response.headers, 'X-RateLimit-Limit');
|
|
108
|
+
const remaining = parseIntHeader(response.headers, 'X-RateLimit-Remaining');
|
|
109
|
+
const reset = parseIntHeader(response.headers, 'X-RateLimit-Reset');
|
|
110
|
+
|
|
111
|
+
if (attempt >= maxRetries) {
|
|
112
|
+
throw new RateLimitError({
|
|
113
|
+
message: `AgentAdmit rate limit exceeded. Max retries (${maxRetries}) exhausted.`,
|
|
114
|
+
retryAfter,
|
|
115
|
+
limit,
|
|
116
|
+
remaining,
|
|
117
|
+
reset,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const waitMs = retryAfter !== null ? retryAfter * 1000 : Math.min(delay, 30_000);
|
|
122
|
+
const jitterMs = Math.random() * 500; // 0–500 ms
|
|
123
|
+
const totalWaitMs = waitMs + jitterMs;
|
|
124
|
+
|
|
125
|
+
console.warn(
|
|
126
|
+
`[AgentAdmit] Rate-limited (attempt ${attempt + 1}/${maxRetries}). ` +
|
|
127
|
+
`Retrying in ${(totalWaitMs / 1000).toFixed(2)}s.`,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await sleep(totalWaitMs);
|
|
131
|
+
delay = Math.min(delay * 2, 30_000);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Should never be reached
|
|
135
|
+
throw new Error('Unexpected exit from retry loop');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validate an ag_at_ token and return the agent context.
|
|
142
|
+
*/
|
|
143
|
+
export async function validateAgentToken(token: string): Promise<Omit<AgentContext, 'auth_type'>> {
|
|
144
|
+
const config = getConfig();
|
|
145
|
+
|
|
146
|
+
if (!token.startsWith(config.token_prefix_access)) {
|
|
147
|
+
throw new Error('Not an AgentAdmit access token');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// MANDATORY INTROSPECTION — validate via AgentAdmit hosted service
|
|
151
|
+
// No local JWT decode. Every verification call goes through AgentAdmit.
|
|
152
|
+
const verifyUrl = (config as any).agentadmit_verify_url || 'https://api.agentadmit.com/v1/verify';
|
|
153
|
+
const appId = config.app_id;
|
|
154
|
+
const apiKey = (config as any).api_key || '';
|
|
155
|
+
const maxRetries = (config as any).max_retries ?? 3;
|
|
156
|
+
|
|
157
|
+
// introspectWithRetry handles 429 with exponential backoff + jitter.
|
|
158
|
+
// RateLimitError propagates to the caller when retries are exhausted.
|
|
159
|
+
const response = await introspectWithRetry(verifyUrl, token, appId, apiKey, maxRetries);
|
|
160
|
+
|
|
161
|
+
if (response.status === 401) {
|
|
162
|
+
const errData = (await response.json().catch(() => ({}))) as Record<string, string>;
|
|
163
|
+
throw new Error(errData.error_description || 'Token validation failed');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (response.status !== 200) {
|
|
167
|
+
throw new Error(`Verification service returned ${response.status}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const data = (await response.json()) as Record<string, any>;
|
|
171
|
+
|
|
172
|
+
// Check active flag (RFC 7662 introspection pattern).
|
|
173
|
+
// The verify endpoint returns {active: false} with HTTP 200 for invalid/
|
|
174
|
+
// expired/revoked tokens. Without this check, we'd read empty scopes.
|
|
175
|
+
if (!data.active) {
|
|
176
|
+
const reason = data.error || 'invalid_token';
|
|
177
|
+
throw new Error(`Token is not active: ${reason}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const scopes: string[] = data.scopes || [];
|
|
181
|
+
const userId: string = data.user_id;
|
|
182
|
+
const connectionId: string = data.connection_id;
|
|
183
|
+
|
|
184
|
+
if (!userId) {
|
|
185
|
+
throw new Error('Introspection returned no user');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// User lookup from app's local database (if storage is configured)
|
|
189
|
+
let user: Record<string, any> = { [config.user_lookup_field]: userId };
|
|
190
|
+
try {
|
|
191
|
+
const storage = getStorage();
|
|
192
|
+
const localUser = await storage.getUser(userId, config.user_lookup_field);
|
|
193
|
+
if (localUser) user = localUser;
|
|
194
|
+
} catch {}
|
|
195
|
+
|
|
196
|
+
const connection = {
|
|
197
|
+
connection_id: connectionId,
|
|
198
|
+
scopes,
|
|
199
|
+
agent_label: data.agent_label || 'Unknown Agent',
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return { user, connection, scopes };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Express middleware: require a specific scope (agent-only).
|
|
207
|
+
*/
|
|
208
|
+
export function requireScope(scope: string) {
|
|
209
|
+
return async (req: Request, res: Response, next: NextFunction) => {
|
|
210
|
+
const token = getBearerToken(req);
|
|
211
|
+
const config = getConfig();
|
|
212
|
+
|
|
213
|
+
if (!token || !token.startsWith(config.token_prefix_access)) {
|
|
214
|
+
return res.status(401).json({ error: 'invalid_token', error_description: 'AgentAdmit token required' });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const ctx = await validateAgentToken(token);
|
|
219
|
+
if (!ctx.scopes.includes(scope)) {
|
|
220
|
+
return res.status(403).json({
|
|
221
|
+
error: 'insufficient_scope',
|
|
222
|
+
required_scope: scope,
|
|
223
|
+
granted_scopes: ctx.scopes,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await logAccess(ctx, scope, req);
|
|
228
|
+
(req as any).agentAdmit = { auth_type: 'agent', ...ctx };
|
|
229
|
+
next();
|
|
230
|
+
} catch (err: any) {
|
|
231
|
+
return res.status(401).json({ error: 'invalid_token', error_description: err.message });
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Express middleware: enforce scope only if caller is an agent.
|
|
238
|
+
*/
|
|
239
|
+
export function requireScopeIfAgent(scope: string) {
|
|
240
|
+
return async (req: Request, res: Response, next: NextFunction) => {
|
|
241
|
+
const token = getBearerToken(req);
|
|
242
|
+
const config = getConfig();
|
|
243
|
+
|
|
244
|
+
if (!token || !token.startsWith(config.token_prefix_access)) {
|
|
245
|
+
return next(); // Not an agent — pass through
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const ctx = await validateAgentToken(token);
|
|
250
|
+
if (!ctx.scopes.includes(scope)) {
|
|
251
|
+
return res.status(403).json({
|
|
252
|
+
error: 'insufficient_scope',
|
|
253
|
+
required_scope: scope,
|
|
254
|
+
granted_scopes: ctx.scopes,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
await logAccess(ctx, scope, req);
|
|
259
|
+
(req as any).agentAdmit = { auth_type: 'agent', ...ctx };
|
|
260
|
+
next();
|
|
261
|
+
} catch (err: any) {
|
|
262
|
+
return res.status(401).json({ error: 'invalid_token', error_description: err.message });
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Express middleware: resolve user or agent from token.
|
|
269
|
+
*/
|
|
270
|
+
export function resolveAuth() {
|
|
271
|
+
return async (req: Request, res: Response, next: NextFunction) => {
|
|
272
|
+
const token = getBearerToken(req);
|
|
273
|
+
const config = getConfig();
|
|
274
|
+
|
|
275
|
+
if (!token) {
|
|
276
|
+
return res.status(401).json({ error: 'not_authenticated' });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (token.startsWith(config.token_prefix_access)) {
|
|
280
|
+
try {
|
|
281
|
+
const ctx = await validateAgentToken(token);
|
|
282
|
+
(req as any).agentAdmit = { auth_type: 'agent', ...ctx };
|
|
283
|
+
return next();
|
|
284
|
+
} catch (err: any) {
|
|
285
|
+
return res.status(401).json({ error: 'invalid_token', error_description: err.message });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Regular user token
|
|
290
|
+
if (!_verifyUserToken) {
|
|
291
|
+
return res.status(500).json({ error: 'server_error', error_description: 'User token verifier not configured' });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const userId = await _verifyUserToken(token);
|
|
296
|
+
const storage = getStorage();
|
|
297
|
+
const user = await storage.getUser(userId, config.user_lookup_field);
|
|
298
|
+
if (!user) {
|
|
299
|
+
return res.status(404).json({ error: 'user_not_found' });
|
|
300
|
+
}
|
|
301
|
+
(req as any).agentAdmit = { auth_type: 'user', user, scopes: ['*'], connection: null };
|
|
302
|
+
next();
|
|
303
|
+
} catch {
|
|
304
|
+
return res.status(401).json({ error: 'invalid_token' });
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Write audit log entry.
|
|
311
|
+
*/
|
|
312
|
+
async function logAccess(
|
|
313
|
+
ctx: { connection: Record<string, any> | null; user: Record<string, any> },
|
|
314
|
+
scope: string,
|
|
315
|
+
req: Request,
|
|
316
|
+
): Promise<void> {
|
|
317
|
+
try {
|
|
318
|
+
const config = getConfig();
|
|
319
|
+
const storage = getStorage();
|
|
320
|
+
await storage.logAccess({
|
|
321
|
+
timestamp: new Date(),
|
|
322
|
+
connection_id: ctx.connection?.connection_id || 'unknown',
|
|
323
|
+
user_id: ctx.user?.[config.user_lookup_field] || 'unknown',
|
|
324
|
+
scope_used: scope,
|
|
325
|
+
resource: req.path,
|
|
326
|
+
method: req.method,
|
|
327
|
+
agent_label: ctx.connection?.agent_label || 'Unknown Agent',
|
|
328
|
+
});
|
|
329
|
+
} catch (err) {
|
|
330
|
+
console.error('[AgentAdmit] Audit log failed:', err);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Check connection cap for tier enforcement.
|
|
336
|
+
*/
|
|
337
|
+
export async function checkConnectionCap(userId: string, tier: string): Promise<void> {
|
|
338
|
+
const { getTierLimits } = require('./config');
|
|
339
|
+
const limits = getTierLimits(tier);
|
|
340
|
+
if (!limits?.hard_cap) return;
|
|
341
|
+
|
|
342
|
+
const storage = getStorage();
|
|
343
|
+
const count = await storage.countActiveConnections(userId);
|
|
344
|
+
|
|
345
|
+
if (count >= limits.connections_limit) {
|
|
346
|
+
const err: any = new Error(`Connection limit reached (${count}/${limits.connections_limit})`);
|
|
347
|
+
err.statusCode = 429;
|
|
348
|
+
err.detail = {
|
|
349
|
+
error: 'connection_limit_reached',
|
|
350
|
+
connections_used: count,
|
|
351
|
+
connections_limit: limits.connections_limit,
|
|
352
|
+
tier,
|
|
353
|
+
};
|
|
354
|
+
throw err;
|
|
355
|
+
}
|
|
356
|
+
}
|