@vellumai/assistant 0.4.13 → 0.4.15
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/ARCHITECTURE.md +77 -38
- package/README.md +10 -12
- package/package.json +1 -1
- package/src/__tests__/actor-token-service.test.ts +108 -522
- package/src/__tests__/channel-approval-routes.test.ts +92 -239
- package/src/__tests__/channel-approval.test.ts +100 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
- package/src/__tests__/conversation-routes.test.ts +11 -4
- package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
- package/src/__tests__/mcp-health-check.test.ts +65 -0
- package/src/__tests__/permission-types.test.ts +33 -0
- package/src/__tests__/scan-result-store.test.ts +121 -0
- package/src/__tests__/session-agent-loop.test.ts +120 -0
- package/src/__tests__/session-approval-overrides.test.ts +205 -0
- package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
- package/src/amazon/client.ts +8 -5
- package/src/approvals/guardian-decision-primitive.ts +14 -9
- package/src/approvals/guardian-request-resolvers.ts +2 -2
- package/src/calls/call-controller.ts +2 -2
- package/src/calls/twilio-routes.ts +2 -2
- package/src/cli/mcp.ts +3 -3
- package/src/cli.ts +24 -0
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
- package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
- package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
- package/src/config/bundled-skills/messaging/SKILL.md +49 -14
- package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
- package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
- package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
- package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
- package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
- package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
- package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
- package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
- package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -24
- package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/approval-generators.ts +6 -3
- package/src/daemon/handlers/config-ingress.ts +2 -6
- package/src/daemon/handlers/guardian-actions.ts +1 -1
- package/src/daemon/handlers/sessions.ts +4 -1
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/skills.ts +32 -0
- package/src/daemon/ipc-contract/messages.ts +3 -1
- package/src/daemon/ipc-handler.ts +24 -0
- package/src/daemon/ipc-validate.ts +1 -1
- package/src/daemon/lifecycle.ts +6 -8
- package/src/daemon/server.ts +8 -3
- package/src/daemon/session-agent-loop.ts +19 -1
- package/src/daemon/session-attachments.ts +2 -1
- package/src/daemon/session-history.ts +2 -2
- package/src/daemon/session-process.ts +5 -9
- package/src/daemon/session-surfaces.ts +17 -1
- package/src/daemon/session-tool-setup.ts +216 -69
- package/src/daemon/session.ts +24 -1
- package/src/events/domain-events.ts +1 -1
- package/src/events/tool-domain-event-publisher.ts +5 -10
- package/src/influencer/client.ts +8 -7
- package/src/messaging/providers/gmail/client.ts +33 -1
- package/src/messaging/providers/gmail/mime-builder.ts +5 -1
- package/src/messaging/providers/sms/adapter.ts +3 -7
- package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
- package/src/messaging/providers/whatsapp/adapter.ts +3 -7
- package/src/notifications/adapters/sms.ts +2 -2
- package/src/notifications/adapters/telegram.ts +2 -2
- package/src/permissions/prompter.ts +2 -0
- package/src/permissions/types.ts +11 -1
- package/src/runtime/approval-conversation-turn.ts +4 -0
- package/src/runtime/auth/__tests__/context.test.ts +130 -0
- package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
- package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
- package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
- package/src/runtime/auth/__tests__/policy.test.ts +29 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
- package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
- package/src/runtime/auth/__tests__/subject.test.ts +149 -0
- package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
- package/src/runtime/auth/context.ts +62 -0
- package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
- package/src/runtime/auth/external-assistant-id.ts +69 -0
- package/src/runtime/auth/index.ts +37 -0
- package/src/runtime/auth/middleware.ts +127 -0
- package/src/runtime/auth/policy.ts +17 -0
- package/src/runtime/auth/route-policy.ts +261 -0
- package/src/runtime/auth/scopes.ts +64 -0
- package/src/runtime/auth/subject.ts +68 -0
- package/src/runtime/auth/token-service.ts +275 -0
- package/src/runtime/auth/types.ts +79 -0
- package/src/runtime/channel-approval-parser.ts +11 -5
- package/src/runtime/channel-approval-types.ts +1 -1
- package/src/runtime/channel-approvals.ts +22 -1
- package/src/runtime/guardian-action-followup-executor.ts +2 -2
- package/src/runtime/guardian-context-resolver.ts +15 -0
- package/src/runtime/guardian-decision-types.ts +23 -6
- package/src/runtime/guardian-outbound-actions.ts +4 -22
- package/src/runtime/guardian-reply-router.ts +5 -3
- package/src/runtime/http-server.ts +210 -182
- package/src/runtime/http-types.ts +11 -1
- package/src/runtime/local-actor-identity.ts +25 -0
- package/src/runtime/pending-interactions.ts +1 -0
- package/src/runtime/routes/approval-routes.ts +42 -59
- package/src/runtime/routes/channel-route-shared.ts +9 -41
- package/src/runtime/routes/channel-routes.ts +0 -2
- package/src/runtime/routes/conversation-routes.ts +39 -49
- package/src/runtime/routes/events-routes.ts +15 -22
- package/src/runtime/routes/guardian-action-routes.ts +46 -51
- package/src/runtime/routes/guardian-approval-interception.ts +6 -5
- package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
- package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
- package/src/runtime/routes/inbound-message-handler.ts +39 -45
- package/src/runtime/routes/pairing-routes.ts +9 -9
- package/src/runtime/routes/secret-routes.ts +90 -45
- package/src/runtime/routes/surface-action-routes.ts +12 -2
- package/src/runtime/routes/trust-rules-routes.ts +13 -0
- package/src/runtime/routes/twilio-routes.ts +3 -3
- package/src/runtime/session-approval-overrides.ts +86 -0
- package/src/security/keychain-to-encrypted-migration.ts +8 -1
- package/src/skills/frontmatter.ts +44 -1
- package/src/tools/permission-checker.ts +226 -74
- package/src/runtime/actor-token-service.ts +0 -234
- package/src/runtime/middleware/actor-token.ts +0 -265
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthContext builder — combines sub parsing and scope resolution into
|
|
3
|
+
* a normalized AuthContext that downstream code can consume without
|
|
4
|
+
* knowing about JWT internals.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
|
|
8
|
+
import { resolveScopeProfile } from './scopes.js';
|
|
9
|
+
import { parseSub } from './subject.js';
|
|
10
|
+
import type { AuthContext, TokenClaims } from './types.js';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Result type
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export type BuildAuthContextResult =
|
|
17
|
+
| { ok: true; context: AuthContext }
|
|
18
|
+
| { ok: false; reason: string };
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Builder
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a normalized AuthContext from verified JWT claims.
|
|
26
|
+
*
|
|
27
|
+
* Parses the sub claim and resolves the scope profile into a concrete
|
|
28
|
+
* set of scopes. Returns a failure result if the sub is malformed.
|
|
29
|
+
*
|
|
30
|
+
* When the token audience is `vellum-daemon`, the assistantId is forced
|
|
31
|
+
* to DAEMON_INTERNAL_ASSISTANT_ID ('self') regardless of what the JWT
|
|
32
|
+
* sub encodes. Daemon code must never derive internal scoping from
|
|
33
|
+
* externally-provided assistant IDs.
|
|
34
|
+
*/
|
|
35
|
+
export function buildAuthContext(claims: TokenClaims): BuildAuthContextResult {
|
|
36
|
+
const subResult = parseSub(claims.sub);
|
|
37
|
+
if (!subResult.ok) {
|
|
38
|
+
return { ok: false, reason: subResult.reason };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const scopes = resolveScopeProfile(claims.scope_profile);
|
|
42
|
+
|
|
43
|
+
// Daemon-audience tokens always scope to the internal assistant ID,
|
|
44
|
+
// preventing external assistant IDs from leaking into daemon-side
|
|
45
|
+
// storage and routing.
|
|
46
|
+
const assistantId = claims.aud === 'vellum-daemon'
|
|
47
|
+
? DAEMON_INTERNAL_ASSISTANT_ID
|
|
48
|
+
: subResult.assistantId;
|
|
49
|
+
|
|
50
|
+
const context: AuthContext = {
|
|
51
|
+
subject: claims.sub,
|
|
52
|
+
principalType: subResult.principalType,
|
|
53
|
+
assistantId,
|
|
54
|
+
actorPrincipalId: subResult.actorPrincipalId,
|
|
55
|
+
sessionId: subResult.sessionId,
|
|
56
|
+
scopeProfile: claims.scope_profile,
|
|
57
|
+
scopes,
|
|
58
|
+
policyEpoch: claims.policy_epoch,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return { ok: true, context };
|
|
62
|
+
}
|
|
@@ -1,37 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* JWT credential minting and rotation service.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Replaces the legacy actor-token-service + actor-refresh-token-service with
|
|
5
|
+
* JWT-based access tokens (aud=vellum-gateway) and opaque refresh tokens.
|
|
6
|
+
*
|
|
7
|
+
* Access tokens are standard JWTs with:
|
|
8
|
+
* - aud: 'vellum-gateway'
|
|
9
|
+
* - sub: 'actor:<externalAssistantId>:<guardianPrincipalId>'
|
|
10
|
+
* - scope_profile: 'actor_client_v1'
|
|
11
|
+
* - policy_epoch: CURRENT_POLICY_EPOCH
|
|
12
|
+
* - 30-day TTL
|
|
13
|
+
*
|
|
14
|
+
* Refresh tokens remain opaque random bytes with hash-only storage,
|
|
15
|
+
* family tracking, and replay detection — reusing the existing
|
|
16
|
+
* actor-refresh-token-store infrastructure.
|
|
8
17
|
*/
|
|
9
18
|
|
|
10
19
|
import { createHash, randomBytes } from 'node:crypto';
|
|
11
20
|
|
|
12
|
-
import { getDb } from '
|
|
13
|
-
import { getLogger } from '
|
|
21
|
+
import { getDb } from '../../memory/db.js';
|
|
22
|
+
import { getLogger } from '../../util/logger.js';
|
|
14
23
|
import {
|
|
15
24
|
createRefreshTokenRecord,
|
|
16
25
|
findByTokenHash as findRefreshByHash,
|
|
17
26
|
markRotated,
|
|
18
27
|
revokeByDeviceBinding as revokeRefreshTokensByDevice,
|
|
19
28
|
revokeFamily,
|
|
20
|
-
} from '
|
|
21
|
-
import { hashToken, mintActorToken } from './actor-token-service.js';
|
|
29
|
+
} from '../actor-refresh-token-store.js';
|
|
22
30
|
import {
|
|
23
31
|
createActorTokenRecord,
|
|
24
32
|
revokeByDeviceBinding as revokeActorTokensByDevice,
|
|
25
|
-
} from '
|
|
33
|
+
} from '../actor-token-store.js';
|
|
34
|
+
import { getExternalAssistantId } from './external-assistant-id.js';
|
|
35
|
+
import { CURRENT_POLICY_EPOCH } from './policy.js';
|
|
36
|
+
import { hashToken, mintToken } from './token-service.js';
|
|
26
37
|
|
|
27
|
-
const log = getLogger('
|
|
38
|
+
const log = getLogger('credential-service');
|
|
28
39
|
|
|
29
40
|
// ---------------------------------------------------------------------------
|
|
30
41
|
// Constants
|
|
31
42
|
// ---------------------------------------------------------------------------
|
|
32
43
|
|
|
33
|
-
/** Access token TTL: 30 days
|
|
34
|
-
const
|
|
44
|
+
/** Access token TTL: 30 days in seconds. */
|
|
45
|
+
const ACCESS_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
46
|
+
|
|
47
|
+
/** Access token TTL in ms (for refresh-after hints). */
|
|
48
|
+
const ACCESS_TOKEN_TTL_MS = ACCESS_TOKEN_TTL_SECONDS * 1000;
|
|
35
49
|
|
|
36
50
|
/** Refresh token absolute expiry: 365 days from issuance. */
|
|
37
51
|
const REFRESH_ABSOLUTE_TTL_MS = 365 * 24 * 60 * 60 * 1000;
|
|
@@ -53,51 +67,83 @@ export type RefreshErrorCode =
|
|
|
53
67
|
| 'device_binding_mismatch'
|
|
54
68
|
| 'revoked';
|
|
55
69
|
|
|
56
|
-
export interface
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
actorTokenExpiresAt: number;
|
|
70
|
+
export interface CredentialPairResult {
|
|
71
|
+
accessToken: string;
|
|
72
|
+
accessTokenExpiresAt: number;
|
|
60
73
|
refreshToken: string;
|
|
61
74
|
refreshTokenExpiresAt: number;
|
|
62
75
|
refreshAfter: number;
|
|
76
|
+
guardianPrincipalId: string;
|
|
63
77
|
}
|
|
64
78
|
|
|
65
|
-
export interface
|
|
79
|
+
export interface RotateResult {
|
|
80
|
+
guardianPrincipalId: string;
|
|
81
|
+
accessToken: string;
|
|
82
|
+
accessTokenExpiresAt: number;
|
|
66
83
|
refreshToken: string;
|
|
67
|
-
refreshTokenHash: string;
|
|
68
84
|
refreshTokenExpiresAt: number;
|
|
69
85
|
refreshAfter: number;
|
|
70
86
|
}
|
|
71
87
|
|
|
72
88
|
// ---------------------------------------------------------------------------
|
|
73
|
-
//
|
|
89
|
+
// Internal: refresh token helpers
|
|
74
90
|
// ---------------------------------------------------------------------------
|
|
75
91
|
|
|
76
|
-
|
|
92
|
+
function generateRefreshToken(): string {
|
|
93
|
+
return randomBytes(32).toString('base64url');
|
|
94
|
+
}
|
|
95
|
+
|
|
77
96
|
function hashRefreshToken(token: string): string {
|
|
78
97
|
return hashToken(token);
|
|
79
98
|
}
|
|
80
99
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Internal: mint a JWT access token
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
function mintAccessToken(guardianPrincipalId: string): {
|
|
105
|
+
token: string;
|
|
106
|
+
tokenHash: string;
|
|
107
|
+
expiresAt: number;
|
|
108
|
+
issuedAt: number;
|
|
109
|
+
} {
|
|
110
|
+
const externalAssistantId = getExternalAssistantId();
|
|
111
|
+
const sub = `actor:${externalAssistantId}:${guardianPrincipalId}`;
|
|
112
|
+
|
|
113
|
+
const token = mintToken({
|
|
114
|
+
aud: 'vellum-gateway',
|
|
115
|
+
sub,
|
|
116
|
+
scope_profile: 'actor_client_v1',
|
|
117
|
+
policy_epoch: CURRENT_POLICY_EPOCH,
|
|
118
|
+
ttlSeconds: ACCESS_TOKEN_TTL_SECONDS,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
return {
|
|
123
|
+
token,
|
|
124
|
+
tokenHash: hashToken(token),
|
|
125
|
+
expiresAt: now + ACCESS_TOKEN_TTL_MS,
|
|
126
|
+
issuedAt: now,
|
|
127
|
+
};
|
|
84
128
|
}
|
|
85
129
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Internal: mint a fresh refresh token and persist its hash
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
function mintRefreshTokenInternal(params: {
|
|
91
135
|
assistantId: string;
|
|
92
136
|
guardianPrincipalId: string;
|
|
93
137
|
hashedDeviceId: string;
|
|
94
138
|
platform: string;
|
|
95
139
|
familyId?: string;
|
|
96
|
-
/** When provided (during rotation), inherit the parent token's absolute expiry
|
|
97
|
-
* instead of computing a fresh one. This ensures refresh rotation resets the
|
|
98
|
-
* inactivity window but does NOT extend the absolute session lifetime. */
|
|
99
140
|
absoluteExpiresAt?: number;
|
|
100
|
-
}):
|
|
141
|
+
}): {
|
|
142
|
+
refreshToken: string;
|
|
143
|
+
refreshTokenHash: string;
|
|
144
|
+
refreshTokenExpiresAt: number;
|
|
145
|
+
refreshAfter: number;
|
|
146
|
+
} {
|
|
101
147
|
const now = Date.now();
|
|
102
148
|
const familyId = params.familyId ?? randomBytes(16).toString('hex');
|
|
103
149
|
const refreshToken = generateRefreshToken();
|
|
@@ -125,9 +171,15 @@ export function mintRefreshToken(params: {
|
|
|
125
171
|
};
|
|
126
172
|
}
|
|
127
173
|
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Public: mint credential pair (access token + refresh token)
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
128
178
|
/**
|
|
129
|
-
* Mint
|
|
179
|
+
* Mint a JWT access token and an opaque refresh token for initial issuance.
|
|
130
180
|
* Used by bootstrap and pairing flows.
|
|
181
|
+
*
|
|
182
|
+
* Revokes any existing credentials for the device before minting.
|
|
131
183
|
*/
|
|
132
184
|
export function mintCredentialPair(params: {
|
|
133
185
|
assistantId: string;
|
|
@@ -135,39 +187,26 @@ export function mintCredentialPair(params: {
|
|
|
135
187
|
deviceId: string;
|
|
136
188
|
guardianPrincipalId: string;
|
|
137
189
|
hashedDeviceId: string;
|
|
138
|
-
}): {
|
|
139
|
-
actorToken: string;
|
|
140
|
-
actorTokenExpiresAt: number;
|
|
141
|
-
refreshToken: string;
|
|
142
|
-
refreshTokenExpiresAt: number;
|
|
143
|
-
refreshAfter: number;
|
|
144
|
-
guardianPrincipalId: string;
|
|
145
|
-
} {
|
|
190
|
+
}): CredentialPairResult {
|
|
146
191
|
// Revoke any existing credentials for this device
|
|
147
192
|
revokeActorTokensByDevice(params.assistantId, params.guardianPrincipalId, params.hashedDeviceId);
|
|
148
193
|
revokeRefreshTokensByDevice(params.assistantId, params.guardianPrincipalId, params.hashedDeviceId);
|
|
149
194
|
|
|
150
|
-
// Mint new access token
|
|
151
|
-
const
|
|
152
|
-
assistantId: params.assistantId,
|
|
153
|
-
platform: params.platform,
|
|
154
|
-
deviceId: params.deviceId,
|
|
155
|
-
guardianPrincipalId: params.guardianPrincipalId,
|
|
156
|
-
ttlMs: ACCESS_TOKEN_TTL_MS,
|
|
157
|
-
});
|
|
195
|
+
// Mint new JWT access token
|
|
196
|
+
const access = mintAccessToken(params.guardianPrincipalId);
|
|
158
197
|
|
|
159
198
|
createActorTokenRecord({
|
|
160
|
-
tokenHash:
|
|
199
|
+
tokenHash: access.tokenHash,
|
|
161
200
|
assistantId: params.assistantId,
|
|
162
201
|
guardianPrincipalId: params.guardianPrincipalId,
|
|
163
202
|
hashedDeviceId: params.hashedDeviceId,
|
|
164
203
|
platform: params.platform,
|
|
165
|
-
issuedAt:
|
|
166
|
-
expiresAt:
|
|
204
|
+
issuedAt: access.issuedAt,
|
|
205
|
+
expiresAt: access.expiresAt,
|
|
167
206
|
});
|
|
168
207
|
|
|
169
208
|
// Mint new refresh token
|
|
170
|
-
const refresh =
|
|
209
|
+
const refresh = mintRefreshTokenInternal({
|
|
171
210
|
assistantId: params.assistantId,
|
|
172
211
|
guardianPrincipalId: params.guardianPrincipalId,
|
|
173
212
|
hashedDeviceId: params.hashedDeviceId,
|
|
@@ -175,8 +214,8 @@ export function mintCredentialPair(params: {
|
|
|
175
214
|
});
|
|
176
215
|
|
|
177
216
|
return {
|
|
178
|
-
|
|
179
|
-
|
|
217
|
+
accessToken: access.token,
|
|
218
|
+
accessTokenExpiresAt: access.expiresAt,
|
|
180
219
|
refreshToken: refresh.refreshToken,
|
|
181
220
|
refreshTokenExpiresAt: refresh.refreshTokenExpiresAt,
|
|
182
221
|
refreshAfter: refresh.refreshAfter,
|
|
@@ -185,19 +224,20 @@ export function mintCredentialPair(params: {
|
|
|
185
224
|
}
|
|
186
225
|
|
|
187
226
|
// ---------------------------------------------------------------------------
|
|
188
|
-
//
|
|
227
|
+
// Public: rotate credentials
|
|
189
228
|
// ---------------------------------------------------------------------------
|
|
190
229
|
|
|
191
230
|
/**
|
|
192
231
|
* Rotate credentials: validate refresh token, revoke old, mint new pair.
|
|
193
232
|
*
|
|
194
|
-
* Returns either a successful result or an error code.
|
|
233
|
+
* Returns either a successful result or an error code. The rotation is
|
|
234
|
+
* wrapped in a SQLite transaction for atomicity.
|
|
195
235
|
*/
|
|
196
236
|
export function rotateCredentials(params: {
|
|
197
237
|
refreshToken: string;
|
|
198
238
|
platform: string;
|
|
199
239
|
deviceId: string;
|
|
200
|
-
}): { ok: true; result:
|
|
240
|
+
}): { ok: true; result: RotateResult } | { ok: false; error: RefreshErrorCode } {
|
|
201
241
|
const refreshTokenHash = hashRefreshToken(params.refreshToken);
|
|
202
242
|
const hashedDeviceId = createHash('sha256').update(params.deviceId).digest('hex');
|
|
203
243
|
|
|
@@ -245,12 +285,10 @@ export function rotateCredentials(params: {
|
|
|
245
285
|
return { ok: false, error: 'device_binding_mismatch' };
|
|
246
286
|
}
|
|
247
287
|
|
|
248
|
-
// Wrap the entire rotate-revoke-remint sequence in a transaction
|
|
249
|
-
// partial failures (e.g., DB write error after revoking old tokens) roll back
|
|
250
|
-
// atomically instead of stranding device credentials.
|
|
288
|
+
// Wrap the entire rotate-revoke-remint sequence in a transaction
|
|
251
289
|
const db = getDb();
|
|
252
290
|
return db.transaction(() => {
|
|
253
|
-
// Mark old refresh token as rotated (atomic CAS
|
|
291
|
+
// Mark old refresh token as rotated (atomic CAS)
|
|
254
292
|
const didRotate = markRotated(refreshTokenHash);
|
|
255
293
|
if (!didRotate) {
|
|
256
294
|
return { ok: false as const, error: 'refresh_reuse_detected' as const };
|
|
@@ -259,28 +297,23 @@ export function rotateCredentials(params: {
|
|
|
259
297
|
// Revoke old access tokens for this device
|
|
260
298
|
revokeActorTokensByDevice(record.assistantId, record.guardianPrincipalId, record.hashedDeviceId);
|
|
261
299
|
|
|
262
|
-
// Mint new access token
|
|
263
|
-
const
|
|
264
|
-
assistantId: record.assistantId,
|
|
265
|
-
platform: params.platform,
|
|
266
|
-
deviceId: params.deviceId,
|
|
267
|
-
guardianPrincipalId: record.guardianPrincipalId,
|
|
268
|
-
ttlMs: ACCESS_TOKEN_TTL_MS,
|
|
269
|
-
});
|
|
300
|
+
// Mint new JWT access token
|
|
301
|
+
const access = mintAccessToken(record.guardianPrincipalId);
|
|
270
302
|
|
|
271
303
|
createActorTokenRecord({
|
|
272
|
-
tokenHash:
|
|
304
|
+
tokenHash: access.tokenHash,
|
|
273
305
|
assistantId: record.assistantId,
|
|
274
306
|
guardianPrincipalId: record.guardianPrincipalId,
|
|
275
307
|
hashedDeviceId: record.hashedDeviceId,
|
|
276
308
|
platform: params.platform,
|
|
277
|
-
issuedAt:
|
|
278
|
-
expiresAt:
|
|
309
|
+
issuedAt: access.issuedAt,
|
|
310
|
+
expiresAt: access.expiresAt,
|
|
279
311
|
});
|
|
280
312
|
|
|
281
|
-
// Mint new refresh token in the same family, inheriting the parent's
|
|
282
|
-
// expiry so rotation resets inactivity but never extends
|
|
283
|
-
|
|
313
|
+
// Mint new refresh token in the same family, inheriting the parent's
|
|
314
|
+
// absolute expiry so rotation resets inactivity but never extends
|
|
315
|
+
// the session lifetime.
|
|
316
|
+
const refresh = mintRefreshTokenInternal({
|
|
284
317
|
assistantId: record.assistantId,
|
|
285
318
|
guardianPrincipalId: record.guardianPrincipalId,
|
|
286
319
|
hashedDeviceId: record.hashedDeviceId,
|
|
@@ -298,8 +331,8 @@ export function rotateCredentials(params: {
|
|
|
298
331
|
ok: true as const,
|
|
299
332
|
result: {
|
|
300
333
|
guardianPrincipalId: record.guardianPrincipalId,
|
|
301
|
-
|
|
302
|
-
|
|
334
|
+
accessToken: access.token,
|
|
335
|
+
accessTokenExpiresAt: access.expiresAt,
|
|
303
336
|
refreshToken: refresh.refreshToken,
|
|
304
337
|
refreshTokenExpiresAt: refresh.refreshTokenExpiresAt,
|
|
305
338
|
refreshAfter: refresh.refreshAfter,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External assistant ID resolver.
|
|
3
|
+
*
|
|
4
|
+
* Reads the external assistant ID from the lockfile for use in
|
|
5
|
+
* edge-facing JWT tokens (aud=vellum-gateway). The external ID is
|
|
6
|
+
* needed because the gateway must identify which assistant the token
|
|
7
|
+
* belongs to, while the daemon internally uses 'self'.
|
|
8
|
+
*
|
|
9
|
+
* The value is cached in memory after the first successful read.
|
|
10
|
+
* Falls back to 'self' if the lockfile is unreadable or has no
|
|
11
|
+
* assistant entries.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getLogger } from '../../util/logger.js';
|
|
15
|
+
import { readLockfile } from '../../util/platform.js';
|
|
16
|
+
|
|
17
|
+
const log = getLogger('external-assistant-id');
|
|
18
|
+
|
|
19
|
+
let cached: string | undefined;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the external assistant ID from the lockfile.
|
|
23
|
+
*
|
|
24
|
+
* Resolution order:
|
|
25
|
+
* 1. Cached in-memory value (populated on first call)
|
|
26
|
+
* 2. Most recently hatched entry in lockfile assistants array
|
|
27
|
+
* (sorted by `hatchedAt` descending) → assistantId
|
|
28
|
+
* 3. Fallback: 'self'
|
|
29
|
+
*/
|
|
30
|
+
export function getExternalAssistantId(): string {
|
|
31
|
+
if (cached !== undefined) {
|
|
32
|
+
return cached;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const lockData = readLockfile();
|
|
37
|
+
if (lockData) {
|
|
38
|
+
const assistants = lockData.assistants as Array<Record<string, unknown>> | undefined;
|
|
39
|
+
if (assistants && assistants.length > 0) {
|
|
40
|
+
// Sort by hatchedAt descending to use the most recent entry,
|
|
41
|
+
// matching the pattern used elsewhere in the codebase.
|
|
42
|
+
const sorted = [...assistants].sort((a, b) => {
|
|
43
|
+
const dateA = new Date((a.hatchedAt as string) || 0).getTime();
|
|
44
|
+
const dateB = new Date((b.hatchedAt as string) || 0).getTime();
|
|
45
|
+
return dateB - dateA;
|
|
46
|
+
});
|
|
47
|
+
const latest = sorted[0];
|
|
48
|
+
if (typeof latest.assistantId === 'string') {
|
|
49
|
+
cached = latest.assistantId;
|
|
50
|
+
log.info({ externalAssistantId: cached }, 'Resolved external assistant ID from lockfile');
|
|
51
|
+
return cached;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (err) {
|
|
56
|
+
log.warn({ err }, 'Failed to read lockfile for external assistant ID — falling back to self');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
cached = 'self';
|
|
60
|
+
return cached;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reset the cached external assistant ID. Used by tests to force
|
|
65
|
+
* re-resolution on the next call.
|
|
66
|
+
*/
|
|
67
|
+
export function resetExternalAssistantIdCache(): void {
|
|
68
|
+
cached = undefined;
|
|
69
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth module barrel export.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports all public types and functions from the auth subsystem
|
|
5
|
+
* so consumers can import from a single path.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type { BuildAuthContextResult } from './context.js';
|
|
9
|
+
export { buildAuthContext } from './context.js';
|
|
10
|
+
export type { CredentialPairResult, RefreshErrorCode, RotateResult } from './credential-service.js';
|
|
11
|
+
export { mintCredentialPair, rotateCredentials } from './credential-service.js';
|
|
12
|
+
export { getExternalAssistantId, resetExternalAssistantIdCache } from './external-assistant-id.js';
|
|
13
|
+
export type { AuthenticateResult } from './middleware.js';
|
|
14
|
+
export { authenticateRequest } from './middleware.js';
|
|
15
|
+
export { CURRENT_POLICY_EPOCH, isStaleEpoch } from './policy.js';
|
|
16
|
+
export type { RoutePolicy } from './route-policy.js';
|
|
17
|
+
export { enforcePolicy, getPolicy, registerPolicy } from './route-policy.js';
|
|
18
|
+
export { hasAllScopes, hasScope, resolveScopeProfile } from './scopes.js';
|
|
19
|
+
export type { ParseSubResult } from './subject.js';
|
|
20
|
+
export { parseSub } from './subject.js';
|
|
21
|
+
export type { VerifyResult } from './token-service.js';
|
|
22
|
+
export {
|
|
23
|
+
hashToken,
|
|
24
|
+
initAuthSigningKey,
|
|
25
|
+
loadOrCreateSigningKey,
|
|
26
|
+
mintDaemonDeliveryToken,
|
|
27
|
+
mintToken,
|
|
28
|
+
verifyToken,
|
|
29
|
+
} from './token-service.js';
|
|
30
|
+
export type {
|
|
31
|
+
AuthContext,
|
|
32
|
+
PrincipalType,
|
|
33
|
+
Scope,
|
|
34
|
+
ScopeProfile,
|
|
35
|
+
TokenAudience,
|
|
36
|
+
TokenClaims,
|
|
37
|
+
} from './types.js';
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT bearer auth middleware for the runtime HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Extracts `Authorization: Bearer <token>`, verifies the JWT with
|
|
5
|
+
* `aud=vellum-daemon`, and builds an AuthContext from the claims.
|
|
6
|
+
*
|
|
7
|
+
* Replaces both the legacy bearer shared-secret check and the
|
|
8
|
+
* actor-token HMAC middleware with a single JWT verification path.
|
|
9
|
+
*
|
|
10
|
+
* In dev mode (DISABLE_HTTP_AUTH + VELLUM_UNSAFE_AUTH_BYPASS), JWT
|
|
11
|
+
* verification is skipped and a synthetic AuthContext is constructed
|
|
12
|
+
* so downstream code always has a typed context to consume.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { isHttpAuthDisabled } from '../../config/env.js';
|
|
16
|
+
import { getLogger } from '../../util/logger.js';
|
|
17
|
+
import { DAEMON_INTERNAL_ASSISTANT_ID } from '../assistant-scope.js';
|
|
18
|
+
import { extractBearerToken } from '../middleware/auth.js';
|
|
19
|
+
import { buildAuthContext } from './context.js';
|
|
20
|
+
import { resolveScopeProfile } from './scopes.js';
|
|
21
|
+
import { verifyToken } from './token-service.js';
|
|
22
|
+
import type { AuthContext } from './types.js';
|
|
23
|
+
|
|
24
|
+
const log = getLogger('auth-middleware');
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Result type
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export type AuthenticateResult =
|
|
31
|
+
| { ok: true; context: AuthContext }
|
|
32
|
+
| { ok: false; response: Response };
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Dev bypass synthetic context
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Construct a synthetic AuthContext for dev mode when auth is bypassed.
|
|
40
|
+
* Grants the broadest profile so all routes are accessible during
|
|
41
|
+
* local development.
|
|
42
|
+
*/
|
|
43
|
+
function buildDevBypassContext(): AuthContext {
|
|
44
|
+
return {
|
|
45
|
+
subject: `actor:${DAEMON_INTERNAL_ASSISTANT_ID}:dev-bypass`,
|
|
46
|
+
principalType: 'actor',
|
|
47
|
+
assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
|
|
48
|
+
actorPrincipalId: 'dev-bypass',
|
|
49
|
+
scopeProfile: 'actor_client_v1',
|
|
50
|
+
scopes: resolveScopeProfile('actor_client_v1'),
|
|
51
|
+
policyEpoch: Number.MAX_SAFE_INTEGER,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Main entry point
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Authenticate an incoming HTTP request via JWT bearer token.
|
|
61
|
+
*
|
|
62
|
+
* Returns an AuthContext on success, or an error Response on failure.
|
|
63
|
+
* The caller should return the error Response directly to the client.
|
|
64
|
+
*/
|
|
65
|
+
export function authenticateRequest(req: Request): AuthenticateResult {
|
|
66
|
+
// Dev bypass: skip JWT verification entirely
|
|
67
|
+
if (isHttpAuthDisabled()) {
|
|
68
|
+
return { ok: true, context: buildDevBypassContext() };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const path = new URL(req.url).pathname;
|
|
72
|
+
|
|
73
|
+
const rawToken = extractBearerToken(req);
|
|
74
|
+
if (!rawToken) {
|
|
75
|
+
log.warn({ reason: 'missing_token', path }, 'Auth denied: missing Authorization header');
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
response: Response.json(
|
|
79
|
+
{ error: { code: 'UNAUTHORIZED', message: 'Missing Authorization header' } },
|
|
80
|
+
{ status: 401 },
|
|
81
|
+
),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Verify the JWT with audience = vellum-daemon
|
|
86
|
+
const verifyResult = verifyToken(rawToken, 'vellum-daemon');
|
|
87
|
+
if (!verifyResult.ok) {
|
|
88
|
+
// Stale policy epoch gets a specific error code so clients can refresh
|
|
89
|
+
if (verifyResult.reason === 'stale_policy_epoch') {
|
|
90
|
+
log.warn({ reason: 'stale_policy_epoch', path }, 'Auth denied: stale policy epoch');
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
response: Response.json(
|
|
94
|
+
{ error: { code: 'refresh_required', message: 'Token policy epoch is stale; refresh required' } },
|
|
95
|
+
{ status: 401 },
|
|
96
|
+
),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
log.warn({ reason: verifyResult.reason, path }, 'Auth denied: JWT verification failed');
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
response: Response.json(
|
|
104
|
+
{ error: { code: 'UNAUTHORIZED', message: `Invalid token: ${verifyResult.reason}` } },
|
|
105
|
+
{ status: 401 },
|
|
106
|
+
),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Build normalized AuthContext from verified claims
|
|
111
|
+
const contextResult = buildAuthContext(verifyResult.claims);
|
|
112
|
+
if (!contextResult.ok) {
|
|
113
|
+
log.warn(
|
|
114
|
+
{ reason: contextResult.reason, path, sub: verifyResult.claims.sub },
|
|
115
|
+
'Auth denied: invalid JWT claims',
|
|
116
|
+
);
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
response: Response.json(
|
|
120
|
+
{ error: { code: 'UNAUTHORIZED', message: `Invalid token claims: ${contextResult.reason}` } },
|
|
121
|
+
{ status: 401 },
|
|
122
|
+
),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { ok: true, context: contextResult.context };
|
|
127
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy epoch management.
|
|
3
|
+
*
|
|
4
|
+
* The policy epoch is a monotonic counter embedded in every JWT. When
|
|
5
|
+
* the auth policy changes (e.g., scope profiles are redefined), the
|
|
6
|
+
* epoch is bumped, and tokens carrying a stale epoch are rejected.
|
|
7
|
+
* This gives us a hard revocation mechanism without maintaining a
|
|
8
|
+
* per-token blocklist.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Current policy epoch — bump this when auth policy changes. */
|
|
12
|
+
export const CURRENT_POLICY_EPOCH = 1;
|
|
13
|
+
|
|
14
|
+
/** Returns true if the given epoch is older than the current policy. */
|
|
15
|
+
export function isStaleEpoch(epoch: number): boolean {
|
|
16
|
+
return epoch < CURRENT_POLICY_EPOCH;
|
|
17
|
+
}
|