streamby-core 0.40.0 → 0.41.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/CHANGELOG.md +32 -0
- package/README.md +115 -10
- package/dist/adapters/database/connectionManager.js +3 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +9 -1
- package/dist/mcp/audit.d.ts +5 -2
- package/dist/mcp/audit.js +8 -0
- package/dist/mcp/server.js +3 -3
- package/dist/middleware/createRouter.js +3 -0
- package/dist/middleware/routes/mcp.js +11 -4
- package/dist/middleware/routes/oauthResource.d.ts +3 -0
- package/dist/middleware/routes/oauthResource.js +18 -0
- package/dist/services/auth.d.ts +2 -0
- package/dist/services/auth.js +36 -10
- package/dist/services/oauthTokens.d.ts +60 -0
- package/dist/services/oauthTokens.js +164 -0
- package/dist/types/index.d.ts +12 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.41.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **OAuth access tokens**: apps the user signs in to with their account (claude.ai's
|
|
8
|
+
connector) work with StreamBy and its MCP server, as a protected resource (RFC 9728). The
|
|
9
|
+
README documents the setup, and how to add the connector in claude.ai.
|
|
10
|
+
- `createOAuthTokenVerifier({ issuer, audiences, jwks })` checks a token with the
|
|
11
|
+
authorization server's keys (RS256, from its JWKS URL or a function), its issuer, its
|
|
12
|
+
audience (one of StreamBy's resources, RFC 8707) and its expiry. The host's `authProvider`
|
|
13
|
+
returns its user with `apiToken: verified.scope` and `oauth: { clientId, clientName? }`.
|
|
14
|
+
- Scopes map to permissions: `streamby:read` to every `:read` one, `streamby:write` to those
|
|
15
|
+
and making and changing; none to deleting nor managing members (`OAUTH_SCOPE_PERMISSIONS`,
|
|
16
|
+
`permissionsForOAuthScopes`). The user's role in each project still applies.
|
|
17
|
+
- An app deletes nothing: its `DELETE` requests answer 403 whatever their route's permission
|
|
18
|
+
(`project:update`, `workflow:write` and `exports:write` reach some that delete), but taking
|
|
19
|
+
a deliverable's mark off. API tokens are unchanged.
|
|
20
|
+
- The router's `oauth` config (`{ issuer, resource, resourceMetadataUrl?, resourceName? }`)
|
|
21
|
+
makes a request without a valid token answer 401 with
|
|
22
|
+
`WWW-Authenticate: Bearer resource_metadata="..."`, and serves the metadata at
|
|
23
|
+
`GET /.well-known/oauth-protected-resource`; `protectedResourceMetadata` builds it for the
|
|
24
|
+
host to serve at its root too.
|
|
25
|
+
- `looksLikeOAuthAccessToken` tells an access token from a host's session tokens.
|
|
26
|
+
- `Auth.oauth`: the app a request comes from. A request with it and without `apiToken` is
|
|
27
|
+
refused (an app never gets all of its user's access).
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- The MCP server takes an OAuth access token too. An app's requests are limited per user it acts
|
|
32
|
+
for, and its tool calls are recorded in `mcp_audit` by its client (`oauthClientId`, a new
|
|
33
|
+
column added at startup) with no `tokenId` (now nullable).
|
|
34
|
+
|
|
3
35
|
## 0.40.0
|
|
4
36
|
|
|
5
37
|
### ⚠️ Breaking changes
|
package/README.md
CHANGED
|
@@ -173,19 +173,106 @@ list shows only the token's projects.
|
|
|
173
173
|
|
|
174
174
|
---
|
|
175
175
|
|
|
176
|
+
## 🔐 OAuth: apps the user signs in to
|
|
177
|
+
|
|
178
|
+
An app the user signs in to with their account, such as claude.ai's connector, works with
|
|
179
|
+
StreamBy through an **OAuth access token**, not an API token. StreamBy is the protected resource
|
|
180
|
+
(RFC 9728); the tokens are issued by the host's authorization server (for Nhexa, Nhexa-API).
|
|
181
|
+
|
|
182
|
+
A token is a JWT the server signs with RS256 for one resource (its audience, RFC 8707), checked
|
|
183
|
+
with the server's published keys (JWKS) alone: no secret is shared. It reaches what the user
|
|
184
|
+
approved, and never more than the user can: their role in each project still applies.
|
|
185
|
+
|
|
186
|
+
| Scope | Permissions |
|
|
187
|
+
|---|---|
|
|
188
|
+
| `streamby:read` | Every `:read` permission: `project:read`, `members:read`, `assets:read`, `production:read`, `workflow:read`, `exports:read`, `rights:read`, `jobs:read`. |
|
|
189
|
+
| `streamby:write` | Those, and making and changing: `project:update`, `assets:write`, `production:write`, `workflow:write`, `exports:write`, `rights:write`, `jobs:run`, `projects:create`. |
|
|
190
|
+
|
|
191
|
+
No scope grants deleting (`*:delete`, `project:delete`) nor managing members. And since some
|
|
192
|
+
write permissions also reach routes that delete (`project:update` a connection, a worker or an
|
|
193
|
+
integration; `workflow:write` a pipeline; `exports:write` an export), **an app's `DELETE` is
|
|
194
|
+
refused (403) whatever its route**, but taking a deliverable's mark off, which only forgets the
|
|
195
|
+
mark (the asset stays). API tokens aren't affected. Other scopes (`profile`, `email`) grant
|
|
196
|
+
nothing in StreamBy. `OAUTH_SCOPE_PERMISSIONS` is exported.
|
|
197
|
+
|
|
198
|
+
**Telling clients where to sign in**
|
|
199
|
+
|
|
200
|
+
Set `oauth` in the router's config. A request without a valid token then gets a 401 with
|
|
201
|
+
`WWW-Authenticate: Bearer resource_metadata="..."`, and the metadata says which authorization
|
|
202
|
+
server to use. The router serves it at `GET /streamby/.well-known/oauth-protected-resource`;
|
|
203
|
+
serve it at your root too, where RFC 9728 puts it (`resourceMetadataUrl` defaults to that):
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
import express from 'express';
|
|
207
|
+
import { createStreamByRouter, protectedResourceMetadata } from 'streamby-core';
|
|
208
|
+
|
|
209
|
+
const oauth = { issuer: 'https://api.example.com', resource: 'https://api.example.com/streamby/mcp' };
|
|
210
|
+
app.get('/.well-known/oauth-protected-resource/streamby/mcp', (_req, res) => res.json(protectedResourceMetadata(oauth)));
|
|
211
|
+
app.use('/streamby', createStreamByRouter({ /* ... */ oauth }));
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**Validating a token (your `authProvider`)**
|
|
215
|
+
|
|
216
|
+
Your `authProvider` checks the token with `createOAuthTokenVerifier` and returns the user, with
|
|
217
|
+
the token's scope in `apiToken` and the app in `oauth`. A request with `oauth` but no `apiToken`
|
|
218
|
+
is refused, so an app never gets all of its user's access. `looksLikeOAuthAccessToken` tells the
|
|
219
|
+
token from your own session tokens.
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
import { createOAuthTokenVerifier, looksLikeOAuthAccessToken, API_TOKEN_PREFIX } from 'streamby-core';
|
|
223
|
+
|
|
224
|
+
const verifyOAuthToken = createOAuthTokenVerifier({
|
|
225
|
+
issuer: 'https://api.example.com',
|
|
226
|
+
// What a token may be for: StreamBy's resources.
|
|
227
|
+
audiences: ['https://api.example.com/streamby', 'https://api.example.com/streamby/mcp'],
|
|
228
|
+
// The server's JWKS URL, or a function giving its keys (when the host is the server itself).
|
|
229
|
+
jwks: 'https://api.example.com/.well-known/jwks.json',
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
createStreamByRouter({
|
|
233
|
+
// ...
|
|
234
|
+
authProvider: async (req) => {
|
|
235
|
+
const bearer = req.headers.authorization?.replace(/^Bearer /, '');
|
|
236
|
+
if (bearer?.startsWith(API_TOKEN_PREFIX)) { /* an API token, as above */ }
|
|
237
|
+
if (looksLikeOAuthAccessToken(bearer)) {
|
|
238
|
+
const verified = await verifyOAuthToken(bearer); // null: forged, expired, or for another resource
|
|
239
|
+
if (!verified) throw new Error('Invalid OAuth access token');
|
|
240
|
+
// Optional but recommended: has the user taken the app's access away? (your own records)
|
|
241
|
+
if (!(await stillConnected(verified.userId, verified.clientId))) throw new Error('Access revoked');
|
|
242
|
+
const user = await findUser(verified.userId);
|
|
243
|
+
return {
|
|
244
|
+
userId: user.id, username: user.username, role: user.role,
|
|
245
|
+
apiToken: verified.scope,
|
|
246
|
+
oauth: { clientId: verified.clientId, clientName: 'Claude' }, // the name, if you know it
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
// ...your session-based authentication
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
The verifier keeps the keys for 5 minutes (`jwksCacheMs`) and fetches them again for a key id it
|
|
255
|
+
doesn't know (a rotation), at most once a minute. It doesn't know whether the user has since
|
|
256
|
+
taken the app's access away: without that check, a revoked app keeps its access until its access
|
|
257
|
+
token expires (Nhexa's last an hour). Nhexa-API checks the user's consent on every request, so
|
|
258
|
+
taking the access away in accounts.nhexa.cl ends it at once.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
176
262
|
## 🤖 MCP server
|
|
177
263
|
|
|
178
264
|
StreamBy serves an [MCP](https://modelcontextprotocol.io) server at `POST /streamby/mcp`
|
|
179
265
|
(Streamable HTTP, stateless). Agents such as Claude Code use it to read and work on StreamBy
|
|
180
|
-
projects with an API token
|
|
266
|
+
projects with an API token, and apps the user signed in to (claude.ai's connector) with an OAuth
|
|
267
|
+
access token (see above). A browser session gets 401.
|
|
181
268
|
|
|
182
269
|
Each tool calls StreamBy's own REST API with the caller's token. So it reaches exactly what the
|
|
183
|
-
token allows (and, for a user's token, what
|
|
270
|
+
token allows (and, for a user's token or an app, what the user's role in each project allows), and a
|
|
184
271
|
change is saved just like one made from the UI, which shows it when it loads the project.
|
|
185
272
|
|
|
186
273
|
**Tools**
|
|
187
274
|
|
|
188
|
-
A token sees only the tools whose permissions it has.
|
|
275
|
+
A token sees only the tools whose permissions it has (an app: those its scopes grant).
|
|
189
276
|
|
|
190
277
|
| Tools | Permissions |
|
|
191
278
|
|---|---|
|
|
@@ -273,13 +360,14 @@ code there that downloads the URL and imports the file (in Blender,
|
|
|
273
360
|
|
|
274
361
|
**Limits and audit**
|
|
275
362
|
|
|
276
|
-
- Each token can make at most 120 requests a minute (a sliding window)
|
|
277
|
-
answers 429 with `Retry-After`. Each instance counts its own
|
|
278
|
-
`mcp.rateLimit`.
|
|
363
|
+
- Each token can make at most 120 requests a minute (a sliding window); an app, per user it acts
|
|
364
|
+
for. Past that, StreamBy answers 429 with `Retry-After`. Each instance counts its own
|
|
365
|
+
requests. Change the limit with `mcp.rateLimit`.
|
|
279
366
|
- Every tool call is recorded in the `mcp_audit` table, created at startup in the main database.
|
|
280
|
-
Each row has the user (a project's token: the token itself), the token's id
|
|
281
|
-
|
|
282
|
-
the arguments or the
|
|
367
|
+
Each row has the user (a project's token: the token itself), the token's id (an app's call:
|
|
368
|
+
its OAuth client, in `oauthClientId`, and no token), the project, the tool, whether it went
|
|
369
|
+
through, the status, the error and the duration. A row never includes the arguments or the
|
|
370
|
+
token.
|
|
283
371
|
|
|
284
372
|
```ts
|
|
285
373
|
createStreamByRouter({
|
|
@@ -337,7 +425,24 @@ and an expiry:
|
|
|
337
425
|
}
|
|
338
426
|
```
|
|
339
427
|
|
|
340
|
-
|
|
428
|
+
### Connecting from claude.ai
|
|
429
|
+
|
|
430
|
+
claude.ai connects through a custom connector, which signs in with OAuth (see above): nobody on
|
|
431
|
+
the team sets up a token or a file. Each person signs in with their own account and approves what
|
|
432
|
+
Claude may do, and Claude reaches only that, and only what their role in each project allows.
|
|
433
|
+
|
|
434
|
+
- **For yourself**: in claude.ai, Settings → Connectors → Add custom connector. Give it a name
|
|
435
|
+
(StreamBy) and the server's URL, `https://your-host/streamby/mcp`, and add it. Then Connect:
|
|
436
|
+
claude.ai opens the sign-in page, and after you sign in, the consent screen, with what it asks
|
|
437
|
+
for (`streamby:read`, and `streamby:write` to make and change things). Approve it.
|
|
438
|
+
- **For your organization** (Team and Enterprise plans): an owner adds it once in Organization
|
|
439
|
+
settings → Connectors, with the same URL; each member then connects it with their own account
|
|
440
|
+
from their Settings → Connectors.
|
|
441
|
+
|
|
442
|
+
claude.ai registers itself with the authorization server (dynamic client registration or a
|
|
443
|
+
client ID metadata document), so there's nothing to set up there. To take its access away, the
|
|
444
|
+
person removes it in their account's connected apps (in Nhexa, accounts.nhexa.cl → Aplicaciones
|
|
445
|
+
→ Quitar acceso), or disconnects it in claude.ai.
|
|
341
446
|
|
|
342
447
|
---
|
|
343
448
|
|
|
@@ -249,6 +249,9 @@ const createTables = async (pool) => {
|
|
|
249
249
|
"createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
250
250
|
);
|
|
251
251
|
CREATE INDEX IF NOT EXISTS mcp_audit_token_idx ON streamby.mcp_audit ("tokenId", "createdAt");
|
|
252
|
+
-- An app's calls (an OAuth access token) are recorded by its client, with no token.
|
|
253
|
+
ALTER TABLE streamby.mcp_audit ADD COLUMN IF NOT EXISTS "oauthClientId" VARCHAR(255);
|
|
254
|
+
ALTER TABLE streamby.mcp_audit ALTER COLUMN "tokenId" DROP NOT NULL;
|
|
252
255
|
`);
|
|
253
256
|
console.log('✅ "mcp_audit" table ensured to exist.');
|
|
254
257
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export { createStreamByRouter } from './middleware/createRouter';
|
|
2
2
|
export { emitToUser } from './services/wsHub';
|
|
3
3
|
export { createNotification } from './services/notification';
|
|
4
|
-
export type { UserPlan, UserSubscription, Auth, ApiTokenScope, ApiTokenKind, ApiTokenPermission, ProjectPermission, AccountPermission } from './types';
|
|
4
|
+
export type { UserPlan, UserSubscription, Auth, ApiTokenScope, ApiTokenKind, ApiTokenPermission, ProjectPermission, AccountPermission, OAuthClientInfo, OAuthResourceConfig } from './types';
|
|
5
5
|
export { resolveApiToken, API_TOKEN_PREFIX } from './services/apiTokens';
|
|
6
6
|
export { PROJECT_PERMISSIONS, ACCOUNT_PERMISSIONS } from './services/apiTokenPermissions';
|
|
7
7
|
export type { ResolvedApiToken } from './services/apiTokens';
|
|
8
|
+
export { createOAuthTokenVerifier, looksLikeOAuthAccessToken, protectedResourceMetadata, defaultResourceMetadataUrl, permissionsForOAuthScopes, OAUTH_SCOPES, OAUTH_SCOPE_PERMISSIONS, } from './services/oauthTokens';
|
|
9
|
+
export type { VerifiedOAuthToken, OAuthTokenVerifierOptions, Jwks, Jwk, OAuthScope } from './services/oauthTokens';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ACCOUNT_PERMISSIONS = exports.PROJECT_PERMISSIONS = exports.API_TOKEN_PREFIX = exports.resolveApiToken = exports.createNotification = exports.emitToUser = exports.createStreamByRouter = void 0;
|
|
3
|
+
exports.OAUTH_SCOPE_PERMISSIONS = exports.OAUTH_SCOPES = exports.permissionsForOAuthScopes = exports.defaultResourceMetadataUrl = exports.protectedResourceMetadata = exports.looksLikeOAuthAccessToken = exports.createOAuthTokenVerifier = exports.ACCOUNT_PERMISSIONS = exports.PROJECT_PERMISSIONS = exports.API_TOKEN_PREFIX = exports.resolveApiToken = exports.createNotification = exports.emitToUser = exports.createStreamByRouter = void 0;
|
|
4
4
|
var createRouter_1 = require("./middleware/createRouter");
|
|
5
5
|
Object.defineProperty(exports, "createStreamByRouter", { enumerable: true, get: function () { return createRouter_1.createStreamByRouter; } });
|
|
6
6
|
var wsHub_1 = require("./services/wsHub");
|
|
@@ -13,3 +13,11 @@ Object.defineProperty(exports, "API_TOKEN_PREFIX", { enumerable: true, get: func
|
|
|
13
13
|
var apiTokenPermissions_1 = require("./services/apiTokenPermissions");
|
|
14
14
|
Object.defineProperty(exports, "PROJECT_PERMISSIONS", { enumerable: true, get: function () { return apiTokenPermissions_1.PROJECT_PERMISSIONS; } });
|
|
15
15
|
Object.defineProperty(exports, "ACCOUNT_PERMISSIONS", { enumerable: true, get: function () { return apiTokenPermissions_1.ACCOUNT_PERMISSIONS; } });
|
|
16
|
+
var oauthTokens_1 = require("./services/oauthTokens");
|
|
17
|
+
Object.defineProperty(exports, "createOAuthTokenVerifier", { enumerable: true, get: function () { return oauthTokens_1.createOAuthTokenVerifier; } });
|
|
18
|
+
Object.defineProperty(exports, "looksLikeOAuthAccessToken", { enumerable: true, get: function () { return oauthTokens_1.looksLikeOAuthAccessToken; } });
|
|
19
|
+
Object.defineProperty(exports, "protectedResourceMetadata", { enumerable: true, get: function () { return oauthTokens_1.protectedResourceMetadata; } });
|
|
20
|
+
Object.defineProperty(exports, "defaultResourceMetadataUrl", { enumerable: true, get: function () { return oauthTokens_1.defaultResourceMetadataUrl; } });
|
|
21
|
+
Object.defineProperty(exports, "permissionsForOAuthScopes", { enumerable: true, get: function () { return oauthTokens_1.permissionsForOAuthScopes; } });
|
|
22
|
+
Object.defineProperty(exports, "OAUTH_SCOPES", { enumerable: true, get: function () { return oauthTokens_1.OAUTH_SCOPES; } });
|
|
23
|
+
Object.defineProperty(exports, "OAUTH_SCOPE_PERMISSIONS", { enumerable: true, get: function () { return oauthTokens_1.OAUTH_SCOPE_PERMISSIONS; } });
|
package/dist/mcp/audit.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { StreamByConfig } from '../types';
|
|
1
|
+
import { Auth, StreamByConfig } from '../types';
|
|
2
2
|
export interface McpAuditEntry {
|
|
3
3
|
userId: string;
|
|
4
|
-
tokenId: string;
|
|
4
|
+
tokenId: string | null;
|
|
5
|
+
oauthClientId: string | null;
|
|
5
6
|
projectId: string | null;
|
|
6
7
|
tool: string;
|
|
7
8
|
ok: boolean;
|
|
@@ -9,4 +10,6 @@ export interface McpAuditEntry {
|
|
|
9
10
|
error: string | null;
|
|
10
11
|
durationMs: number;
|
|
11
12
|
}
|
|
13
|
+
/** Who made an MCP request, as the audit log keeps it. */
|
|
14
|
+
export declare const auditCallerOf: (auth: Auth) => Pick<McpAuditEntry, "userId" | "tokenId" | "oauthClientId">;
|
|
12
15
|
export declare function recordMcpCall(entry: McpAuditEntry, config: StreamByConfig): Promise<void>;
|
package/dist/mcp/audit.js
CHANGED
|
@@ -3,9 +3,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.auditCallerOf = void 0;
|
|
6
7
|
exports.recordMcpCall = recordMcpCall;
|
|
7
8
|
const crypto_1 = __importDefault(require("crypto"));
|
|
8
9
|
const manager_1 = require("../models/manager");
|
|
10
|
+
/** Who made an MCP request, as the audit log keeps it. */
|
|
11
|
+
const auditCallerOf = (auth) => ({
|
|
12
|
+
userId: auth.userId,
|
|
13
|
+
tokenId: auth.oauth ? null : auth.apiToken?.tokenId ?? null,
|
|
14
|
+
oauthClientId: auth.oauth?.clientId ?? null,
|
|
15
|
+
});
|
|
16
|
+
exports.auditCallerOf = auditCallerOf;
|
|
9
17
|
async function recordMcpCall(entry, config) {
|
|
10
18
|
try {
|
|
11
19
|
const dbType = config.databases?.find(db => db.main)?.type ?? 'nosql';
|
package/dist/mcp/server.js
CHANGED
|
@@ -40,7 +40,7 @@ function createMcpServer(auth, api, config, version) {
|
|
|
40
40
|
const refused = await (0, workflowNodes_1.ensureWorkflowNode)(tool.workflowNode, tool.projectOf?.(args), auth, api);
|
|
41
41
|
if (refused) {
|
|
42
42
|
await (0, audit_1.recordMcpCall)({
|
|
43
|
-
|
|
43
|
+
...(0, audit_1.auditCallerOf)(auth), projectId, tool: tool.name,
|
|
44
44
|
ok: false, status: null, error: refused, durationMs: Date.now() - started,
|
|
45
45
|
}, config);
|
|
46
46
|
return { isError: true, content: [{ type: 'text', text: refused }] };
|
|
@@ -50,7 +50,7 @@ function createMcpServer(auth, api, config, version) {
|
|
|
50
50
|
status = answer.status;
|
|
51
51
|
const ok = status >= 200 && status < 300;
|
|
52
52
|
await (0, audit_1.recordMcpCall)({
|
|
53
|
-
|
|
53
|
+
...(0, audit_1.auditCallerOf)(auth), projectId, tool: tool.name,
|
|
54
54
|
ok, status, error: ok ? null : errorText(answer.body, status), durationMs: Date.now() - started,
|
|
55
55
|
}, config);
|
|
56
56
|
if (!ok)
|
|
@@ -61,7 +61,7 @@ function createMcpServer(auth, api, config, version) {
|
|
|
61
61
|
catch (err) {
|
|
62
62
|
const message = err instanceof Error ? err.message : String(err);
|
|
63
63
|
await (0, audit_1.recordMcpCall)({
|
|
64
|
-
|
|
64
|
+
...(0, audit_1.auditCallerOf)(auth), projectId, tool: tool.name,
|
|
65
65
|
ok: false, status, error: message, durationMs: Date.now() - started,
|
|
66
66
|
}, config);
|
|
67
67
|
return { isError: true, content: [{ type: 'text', text: `The call failed: ${message}` }] };
|
|
@@ -28,6 +28,7 @@ const dbConnection_1 = require("./routes/dbConnection");
|
|
|
28
28
|
const storageConnection_1 = require("./routes/storageConnection");
|
|
29
29
|
const userIntegration_1 = require("./routes/userIntegration");
|
|
30
30
|
const oauth_1 = require("./routes/oauth");
|
|
31
|
+
const oauthResource_1 = require("./routes/oauthResource");
|
|
31
32
|
const notification_1 = require("./routes/notification");
|
|
32
33
|
const subscription_1 = require("./routes/subscription");
|
|
33
34
|
const media_1 = require("./routes/media");
|
|
@@ -112,6 +113,8 @@ function createStreamByRouter(config) {
|
|
|
112
113
|
if (config.websocket?.server) {
|
|
113
114
|
(0, wsHub_1.initWsHub)(config.websocket.server, config);
|
|
114
115
|
}
|
|
116
|
+
// Where to sign in for a token (RFC 9728): open, before anything asks for one.
|
|
117
|
+
router.use((0, oauthResource_1.oauthResourceRouter)(config));
|
|
115
118
|
// /auth answers whether there's a session (it reads it itself); everything after needs one,
|
|
116
119
|
// but for the public export route (see authenticate).
|
|
117
120
|
router.use((0, auth_2.authRouter)(config));
|
|
@@ -5,11 +5,13 @@ const express_1 = require("express");
|
|
|
5
5
|
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
6
6
|
const server_1 = require("../../mcp/server");
|
|
7
7
|
const rateLimit_1 = require("../../mcp/rateLimit");
|
|
8
|
+
const oauthTokens_1 = require("../../services/oauthTokens");
|
|
8
9
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
9
10
|
const { version } = require('../../../package.json');
|
|
10
11
|
const jsonRpcError = (res, status, message) => res.status(status).json({ jsonrpc: '2.0', error: { code: -32000, message }, id: null });
|
|
11
12
|
// StreamBy's MCP server (Streamable HTTP, stateless: one server per request), for agents
|
|
12
|
-
// working with StreamBy through an API token
|
|
13
|
+
// working with StreamBy through an API token, or an OAuth access token of an app the user
|
|
14
|
+
// signed in to (e.g. claude.ai's connector). Each tool calls the REST API right here -- this
|
|
13
15
|
// same process, under the same mount -- with the request's own Authorization header, so it
|
|
14
16
|
// reaches what the token and its owner can and nothing more.
|
|
15
17
|
function mcpRouter(config) {
|
|
@@ -17,12 +19,17 @@ function mcpRouter(config) {
|
|
|
17
19
|
const limiter = (0, rateLimit_1.createRateLimiter)(config.mcp?.rateLimit ?? rateLimit_1.DEFAULT_MCP_RATE_LIMIT);
|
|
18
20
|
router.post('/mcp', async (req, res) => {
|
|
19
21
|
const auth = req.auth;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
// A browser session isn't for agents: they come with a token.
|
|
23
|
+
if (!auth.apiToken) {
|
|
24
|
+
if (config.oauth)
|
|
25
|
+
res.setHeader('WWW-Authenticate', (0, oauthTokens_1.wwwAuthenticate)(config.oauth, false));
|
|
26
|
+
return jsonRpcError(res, 401, 'The MCP server takes an API token (Authorization: Bearer sbt_...) or an OAuth access token');
|
|
27
|
+
}
|
|
28
|
+
// Per token; an app is counted per user it acts for.
|
|
22
29
|
const taken = limiter.take(auth.apiToken.tokenId);
|
|
23
30
|
if (!taken.allowed) {
|
|
24
31
|
res.setHeader('Retry-After', String(Math.ceil(taken.retryAfterMs / 1000)));
|
|
25
|
-
return jsonRpcError(res, 429, 'Too many requests for this
|
|
32
|
+
return jsonRpcError(res, 429, 'Too many requests for this token; try again later');
|
|
26
33
|
}
|
|
27
34
|
const base = config.mcp?.apiBaseUrl ?? `http://127.0.0.1:${req.socket.localPort}${req.baseUrl}`;
|
|
28
35
|
const api = async ({ method, path, body }) => {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.oauthResourceRouter = oauthResourceRouter;
|
|
4
|
+
const express_1 = require("express");
|
|
5
|
+
const oauthTokens_1 = require("../../services/oauthTokens");
|
|
6
|
+
// StreamBy's metadata as an OAuth protected resource (RFC 9728), open to anyone: an MCP client
|
|
7
|
+
// that got a 401 reads it to know which authorization server to sign the user in with. The
|
|
8
|
+
// host serves the same document at its root too, where RFC 9728 puts it.
|
|
9
|
+
function oauthResourceRouter(config) {
|
|
10
|
+
const router = (0, express_1.Router)();
|
|
11
|
+
router.get('/.well-known/oauth-protected-resource', (_req, res) => {
|
|
12
|
+
if (!config.oauth)
|
|
13
|
+
return res.status(404).json({ error: 'Not an OAuth protected resource' });
|
|
14
|
+
res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=300' });
|
|
15
|
+
return res.json((0, oauthTokens_1.protectedResourceMetadata)(config.oauth));
|
|
16
|
+
});
|
|
17
|
+
return router;
|
|
18
|
+
}
|
package/dist/services/auth.d.ts
CHANGED
|
@@ -6,5 +6,7 @@ export declare const isPublicRoute: (method: string, path: string) => boolean;
|
|
|
6
6
|
export declare const scopeReachesProject: (scope: Auth["apiToken"], projectId: string) => boolean;
|
|
7
7
|
/** Whether a token has a permission (a request without a token: always). */
|
|
8
8
|
export declare const tokenAllows: (scope: Auth["apiToken"], permission: ApiTokenPermission) => boolean;
|
|
9
|
+
/** Whether an app (an OAuth request) may make this request's method on its path. */
|
|
10
|
+
export declare const oauthMayDelete: (method: string, path: string) => boolean;
|
|
9
11
|
export declare const enforceApiTokenScope: (req: Request, res: Response, next: NextFunction) => void | Response<any, Record<string, any>>;
|
|
10
12
|
export declare const authenticate: (config: StreamByConfig) => (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
|
package/dist/services/auth.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.authenticate = exports.enforceApiTokenScope = exports.tokenAllows = exports.scopeReachesProject = exports.isPublicRoute = void 0;
|
|
3
|
+
exports.authenticate = exports.enforceApiTokenScope = exports.oauthMayDelete = exports.tokenAllows = exports.scopeReachesProject = exports.isPublicRoute = void 0;
|
|
4
4
|
const auth_1 = require("../utils/auth");
|
|
5
5
|
const apiTokenPermissions_1 = require("./apiTokenPermissions");
|
|
6
|
+
const oauthTokens_1 = require("./oauthTokens");
|
|
6
7
|
// The one route open without a session: a project's public export, read
|
|
7
8
|
// (GET /:projectId/export/:exportName, see routes/export). Exactly that path -- not any path
|
|
8
9
|
// that has an "export" segment somewhere.
|
|
@@ -33,13 +34,29 @@ exports.scopeReachesProject = scopeReachesProject;
|
|
|
33
34
|
/** Whether a token has a permission (a request without a token: always). */
|
|
34
35
|
const tokenAllows = (scope, permission) => !scope || scope.permissions.includes(permission);
|
|
35
36
|
exports.tokenAllows = tokenAllows;
|
|
37
|
+
// ── OAuth apps ──
|
|
38
|
+
// An app the user signed in to was approved to make and change things, never to delete them:
|
|
39
|
+
// its permissions come from its scopes, and some of those (project:update, workflow:write,
|
|
40
|
+
// exports:write) also reach routes that delete (a connection, a worker, an integration, a
|
|
41
|
+
// pipeline, an export). So an app's DELETE is refused, whatever its route's permission.
|
|
42
|
+
// The one DELETE it may make is taking a deliverable's mark off: that forgets the mark only
|
|
43
|
+
// (the asset, its versions and its files stay, and it can be marked again), and it's the
|
|
44
|
+
// counterpart of marking one, which assets:write already allows.
|
|
45
|
+
const OAUTH_DELETE_ALLOWED = new RegExp(`^/projects/[^/]+/deliverables/[^/]+/?$`);
|
|
46
|
+
/** Whether an app (an OAuth request) may make this request's method on its path. */
|
|
47
|
+
const oauthMayDelete = (method, path) => method.toUpperCase() !== 'DELETE' || OAUTH_DELETE_ALLOWED.test(path);
|
|
48
|
+
exports.oauthMayDelete = oauthMayDelete;
|
|
36
49
|
const enforceApiTokenScope = (req, res, next) => {
|
|
37
|
-
const
|
|
50
|
+
const auth = req.auth;
|
|
51
|
+
const scope = auth?.apiToken;
|
|
38
52
|
if (!scope)
|
|
39
53
|
return next();
|
|
40
54
|
// The MCP server handles a token itself: its tools call the routes here, each one checked.
|
|
41
55
|
if (req.path === '/mcp')
|
|
42
56
|
return next();
|
|
57
|
+
if (auth?.oauth && !(0, exports.oauthMayDelete)(req.method, req.path)) {
|
|
58
|
+
return res.status(403).json({ error: 'An app connected with OAuth can\'t delete' });
|
|
59
|
+
}
|
|
43
60
|
const rule = (0, apiTokenPermissions_1.routeRuleFor)(req.method, req.path);
|
|
44
61
|
if (!rule || (rule.userTokenOnly && scope.kind !== 'user')) {
|
|
45
62
|
return res.status(403).json({ error: 'Not available with an API token' });
|
|
@@ -53,25 +70,34 @@ const enforceApiTokenScope = (req, res, next) => {
|
|
|
53
70
|
next();
|
|
54
71
|
};
|
|
55
72
|
exports.enforceApiTokenScope = enforceApiTokenScope;
|
|
73
|
+
const hasBearer = (req) => /^Bearer\s+\S/i.test(req.headers.authorization ?? '');
|
|
56
74
|
const authenticate = (config) => async (req, res, next) => {
|
|
57
75
|
if ((0, exports.isPublicRoute)(req.method, req.path)) {
|
|
58
76
|
return next();
|
|
59
77
|
}
|
|
78
|
+
// A client without a valid token is told where to sign in (RFC 9728), when StreamBy is set
|
|
79
|
+
// up as an OAuth resource.
|
|
80
|
+
const unauthorized = () => {
|
|
81
|
+
if (config.oauth)
|
|
82
|
+
res.setHeader('WWW-Authenticate', (0, oauthTokens_1.wwwAuthenticate)(config.oauth, hasBearer(req)));
|
|
83
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
84
|
+
};
|
|
60
85
|
try {
|
|
61
86
|
const auth = await config.authProvider(req);
|
|
62
|
-
if (!auth || !auth.userId || !auth.role)
|
|
63
|
-
return
|
|
64
|
-
}
|
|
87
|
+
if (!auth || !auth.userId || !auth.role)
|
|
88
|
+
return unauthorized();
|
|
65
89
|
// A project token's principal is only ever a project's token (see utils/auth).
|
|
66
|
-
if ((0, auth_1.isProjectTokenPrincipal)(auth.userId) && auth.apiToken?.kind !== 'project')
|
|
67
|
-
return
|
|
68
|
-
|
|
90
|
+
if ((0, auth_1.isProjectTokenPrincipal)(auth.userId) && auth.apiToken?.kind !== 'project')
|
|
91
|
+
return unauthorized();
|
|
92
|
+
// An app acting for the user reaches only what its scopes grant: never without them.
|
|
93
|
+
if (auth.oauth && auth.apiToken?.kind !== 'user')
|
|
94
|
+
return unauthorized();
|
|
69
95
|
req.auth = auth;
|
|
70
96
|
next();
|
|
71
97
|
}
|
|
72
98
|
catch (error) {
|
|
73
|
-
console.error('Authentication failed:', error);
|
|
74
|
-
return
|
|
99
|
+
console.error('Authentication failed:', error instanceof Error ? error.message : error);
|
|
100
|
+
return unauthorized();
|
|
75
101
|
}
|
|
76
102
|
};
|
|
77
103
|
exports.authenticate = authenticate;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ApiTokenScope, OAuthResourceConfig } from '../types';
|
|
2
|
+
import { ApiTokenPermission } from './apiTokenPermissions';
|
|
3
|
+
export declare const OAUTH_SCOPES: readonly ["streamby:read", "streamby:write"];
|
|
4
|
+
export type OAuthScope = typeof OAUTH_SCOPES[number];
|
|
5
|
+
/** The permissions each scope grants. */
|
|
6
|
+
export declare const OAUTH_SCOPE_PERMISSIONS: Record<OAuthScope, ApiTokenPermission[]>;
|
|
7
|
+
/** What a token's scopes let it do (scopes that aren't StreamBy's grant nothing here). */
|
|
8
|
+
export declare const permissionsForOAuthScopes: (scopes: string[]) => ApiTokenPermission[];
|
|
9
|
+
export interface Jwk {
|
|
10
|
+
kty: string;
|
|
11
|
+
kid?: string;
|
|
12
|
+
n?: string;
|
|
13
|
+
e?: string;
|
|
14
|
+
alg?: string;
|
|
15
|
+
use?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface Jwks {
|
|
18
|
+
keys: Jwk[];
|
|
19
|
+
}
|
|
20
|
+
export interface OAuthTokenVerifierOptions {
|
|
21
|
+
issuer: string;
|
|
22
|
+
audiences: string[];
|
|
23
|
+
jwks: string | (() => Promise<Jwks>);
|
|
24
|
+
jwksCacheMs?: number;
|
|
25
|
+
fetcher?: (url: string, init: RequestInit) => Promise<Response>;
|
|
26
|
+
now?: () => number;
|
|
27
|
+
}
|
|
28
|
+
export interface VerifiedOAuthToken {
|
|
29
|
+
userId: string;
|
|
30
|
+
clientId: string;
|
|
31
|
+
scopes: string[];
|
|
32
|
+
expiresAt: Date;
|
|
33
|
+
scope: ApiTokenScope;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Whether a bearer is shaped as an OAuth access token (a JWT signed with RS256), not whether
|
|
37
|
+
* it's valid: for an authProvider to tell it from its own session tokens.
|
|
38
|
+
*/
|
|
39
|
+
export declare const looksLikeOAuthAccessToken: (token: unknown) => token is string;
|
|
40
|
+
/** The grant an OAuth request counts as (its rate limit's key): one app, for one user. */
|
|
41
|
+
export declare const oauthGrantId: (clientId: string, userId: string) => string;
|
|
42
|
+
/**
|
|
43
|
+
* Checks OAuth access tokens: their signature with the issuer's keys, the issuer, that they're
|
|
44
|
+
* for StreamBy (their audience), and that they haven't expired. The verified token, or null.
|
|
45
|
+
* It doesn't know whether the user has since revoked the app: the host may check that too.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createOAuthTokenVerifier(options: OAuthTokenVerifierOptions): (token: unknown) => Promise<VerifiedOAuthToken | null>;
|
|
48
|
+
/** Where RFC 9728 puts a resource's metadata: at its origin, before its path. */
|
|
49
|
+
export declare const defaultResourceMetadataUrl: (resource: string) => string;
|
|
50
|
+
export declare const resourceMetadataUrlOf: (oauth: OAuthResourceConfig) => string;
|
|
51
|
+
/** The metadata a client reads to know where to sign in, and what to ask for. */
|
|
52
|
+
export declare const protectedResourceMetadata: (oauth: OAuthResourceConfig) => {
|
|
53
|
+
resource: string;
|
|
54
|
+
authorization_servers: string[];
|
|
55
|
+
scopes_supported: ("streamby:read" | "streamby:write")[];
|
|
56
|
+
bearer_methods_supported: string[];
|
|
57
|
+
resource_name: string;
|
|
58
|
+
};
|
|
59
|
+
/** The WWW-Authenticate challenge of a 401: the metadata's URL, and why the token was refused. */
|
|
60
|
+
export declare const wwwAuthenticate: (oauth: OAuthResourceConfig, invalidToken: boolean) => string;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.wwwAuthenticate = exports.protectedResourceMetadata = exports.resourceMetadataUrlOf = exports.defaultResourceMetadataUrl = exports.oauthGrantId = exports.looksLikeOAuthAccessToken = exports.permissionsForOAuthScopes = exports.OAUTH_SCOPE_PERMISSIONS = exports.OAUTH_SCOPES = void 0;
|
|
7
|
+
exports.createOAuthTokenVerifier = createOAuthTokenVerifier;
|
|
8
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
9
|
+
// ── OAuth access tokens ──
|
|
10
|
+
// An app the user signed in to with their account (e.g. claude.ai's connector) comes with an
|
|
11
|
+
// OAuth access token: a JWT the authorization server signed (RS256) for one resource (its
|
|
12
|
+
// audience, RFC 8707). It's checked with the server's published keys alone (JWKS): no secret
|
|
13
|
+
// is shared. What it may do is what the user approved -- its scopes -- as permissions of the
|
|
14
|
+
// API tokens' kind, so the same checks limit it; and, as a user's token, never more than the
|
|
15
|
+
// user can (their role in each project still applies).
|
|
16
|
+
exports.OAUTH_SCOPES = ['streamby:read', 'streamby:write'];
|
|
17
|
+
const READ_PERMISSIONS = [
|
|
18
|
+
'project:read', 'members:read', 'assets:read', 'production:read', 'workflow:read',
|
|
19
|
+
'exports:read', 'rights:read', 'jobs:read',
|
|
20
|
+
];
|
|
21
|
+
// Making and changing, as the consent screen says: never deleting, nor managing members.
|
|
22
|
+
const WRITE_PERMISSIONS = [
|
|
23
|
+
'project:update', 'assets:write', 'production:write', 'workflow:write', 'exports:write',
|
|
24
|
+
'rights:write', 'jobs:run', 'projects:create',
|
|
25
|
+
];
|
|
26
|
+
/** The permissions each scope grants. */
|
|
27
|
+
exports.OAUTH_SCOPE_PERMISSIONS = {
|
|
28
|
+
'streamby:read': READ_PERMISSIONS,
|
|
29
|
+
'streamby:write': [...READ_PERMISSIONS, ...WRITE_PERMISSIONS],
|
|
30
|
+
};
|
|
31
|
+
/** What a token's scopes let it do (scopes that aren't StreamBy's grant nothing here). */
|
|
32
|
+
const permissionsForOAuthScopes = (scopes) => [...new Set(scopes.flatMap(scope => exports.OAUTH_SCOPE_PERMISSIONS[scope] ?? []))];
|
|
33
|
+
exports.permissionsForOAuthScopes = permissionsForOAuthScopes;
|
|
34
|
+
const SKEW_SECONDS = 60;
|
|
35
|
+
const REFETCH_MIN_MS = 60 * 1000;
|
|
36
|
+
const decodePart = (part) => {
|
|
37
|
+
try {
|
|
38
|
+
const value = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
|
39
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Whether a bearer is shaped as an OAuth access token (a JWT signed with RS256), not whether
|
|
47
|
+
* it's valid: for an authProvider to tell it from its own session tokens.
|
|
48
|
+
*/
|
|
49
|
+
const looksLikeOAuthAccessToken = (token) => {
|
|
50
|
+
if (typeof token !== 'string')
|
|
51
|
+
return false;
|
|
52
|
+
const parts = token.split('.');
|
|
53
|
+
return parts.length === 3 && decodePart(parts[0])?.alg === 'RS256';
|
|
54
|
+
};
|
|
55
|
+
exports.looksLikeOAuthAccessToken = looksLikeOAuthAccessToken;
|
|
56
|
+
/** The grant an OAuth request counts as (its rate limit's key): one app, for one user. */
|
|
57
|
+
const oauthGrantId = (clientId, userId) => `oauth:${clientId}:${userId}`;
|
|
58
|
+
exports.oauthGrantId = oauthGrantId;
|
|
59
|
+
/**
|
|
60
|
+
* Checks OAuth access tokens: their signature with the issuer's keys, the issuer, that they're
|
|
61
|
+
* for StreamBy (their audience), and that they haven't expired. The verified token, or null.
|
|
62
|
+
* It doesn't know whether the user has since revoked the app: the host may check that too.
|
|
63
|
+
*/
|
|
64
|
+
function createOAuthTokenVerifier(options) {
|
|
65
|
+
const now = () => (options.now ? options.now() : Date.now());
|
|
66
|
+
const cacheMs = options.jwksCacheMs ?? 5 * 60 * 1000;
|
|
67
|
+
const fetcher = options.fetcher ?? fetch;
|
|
68
|
+
let keys = new Map();
|
|
69
|
+
let fetchedAt = -Infinity;
|
|
70
|
+
let inFlight = null;
|
|
71
|
+
const loadKeys = async () => {
|
|
72
|
+
const jwks = typeof options.jwks === 'function'
|
|
73
|
+
? await options.jwks()
|
|
74
|
+
: await fetcher(options.jwks, { headers: { Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(5000) })
|
|
75
|
+
.then(res => (res.ok ? res.json() : Promise.reject(new Error(`JWKS answered ${res.status}`))));
|
|
76
|
+
const next = new Map();
|
|
77
|
+
for (const jwk of jwks?.keys ?? []) {
|
|
78
|
+
if (jwk.kty !== 'RSA' || !jwk.kid || (jwk.alg && jwk.alg !== 'RS256') || (jwk.use && jwk.use !== 'sig'))
|
|
79
|
+
continue;
|
|
80
|
+
try {
|
|
81
|
+
next.set(jwk.kid, crypto_1.default.createPublicKey({ key: jwk, format: 'jwk' }));
|
|
82
|
+
}
|
|
83
|
+
catch { /* not a usable key */ }
|
|
84
|
+
}
|
|
85
|
+
keys = next;
|
|
86
|
+
fetchedAt = now();
|
|
87
|
+
};
|
|
88
|
+
const refresh = async () => {
|
|
89
|
+
inFlight ?? (inFlight = loadKeys().catch(err => {
|
|
90
|
+
fetchedAt = now(); // don't hammer a failing JWKS
|
|
91
|
+
console.error('❌ StreamBy OAuth: could not load the issuer\'s keys:', err instanceof Error ? err.message : err);
|
|
92
|
+
}).finally(() => { inFlight = null; }));
|
|
93
|
+
await inFlight;
|
|
94
|
+
};
|
|
95
|
+
const keyFor = async (kid) => {
|
|
96
|
+
if (now() - fetchedAt > cacheMs)
|
|
97
|
+
await refresh();
|
|
98
|
+
if (!keys.has(kid) && now() - fetchedAt > REFETCH_MIN_MS)
|
|
99
|
+
await refresh();
|
|
100
|
+
return keys.get(kid);
|
|
101
|
+
};
|
|
102
|
+
return async function verify(token) {
|
|
103
|
+
if (!(0, exports.looksLikeOAuthAccessToken)(token))
|
|
104
|
+
return null;
|
|
105
|
+
const [headerPart, payloadPart, signaturePart] = token.split('.');
|
|
106
|
+
const header = decodePart(headerPart);
|
|
107
|
+
const claims = decodePart(payloadPart);
|
|
108
|
+
if (!header || !claims || typeof header.kid !== 'string')
|
|
109
|
+
return null;
|
|
110
|
+
const key = await keyFor(header.kid);
|
|
111
|
+
if (!key)
|
|
112
|
+
return null;
|
|
113
|
+
const signed = crypto_1.default.verify('RSA-SHA256', Buffer.from(`${headerPart}.${payloadPart}`), key, Buffer.from(signaturePart, 'base64url'));
|
|
114
|
+
if (!signed)
|
|
115
|
+
return null;
|
|
116
|
+
const seconds = Math.floor(now() / 1000);
|
|
117
|
+
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
118
|
+
if (claims.iss !== options.issuer)
|
|
119
|
+
return null;
|
|
120
|
+
if (!audiences.some(aud => typeof aud === 'string' && options.audiences.includes(aud)))
|
|
121
|
+
return null;
|
|
122
|
+
if (typeof claims.exp !== 'number' || claims.exp + SKEW_SECONDS <= seconds)
|
|
123
|
+
return null;
|
|
124
|
+
if (typeof claims.nbf === 'number' && claims.nbf - SKEW_SECONDS > seconds)
|
|
125
|
+
return null;
|
|
126
|
+
if (typeof claims.sub !== 'string' || !claims.sub || typeof claims.client_id !== 'string' || !claims.client_id)
|
|
127
|
+
return null;
|
|
128
|
+
const scopes = typeof claims.scope === 'string' ? claims.scope.split(' ').filter(Boolean) : [];
|
|
129
|
+
return {
|
|
130
|
+
userId: claims.sub,
|
|
131
|
+
clientId: claims.client_id,
|
|
132
|
+
scopes,
|
|
133
|
+
expiresAt: new Date(claims.exp * 1000),
|
|
134
|
+
scope: {
|
|
135
|
+
tokenId: (0, exports.oauthGrantId)(claims.client_id, claims.sub),
|
|
136
|
+
kind: 'user',
|
|
137
|
+
permissions: (0, exports.permissionsForOAuthScopes)(scopes),
|
|
138
|
+
projectIds: '*',
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// ── The protected resource's metadata (RFC 9728) ──
|
|
144
|
+
/** Where RFC 9728 puts a resource's metadata: at its origin, before its path. */
|
|
145
|
+
const defaultResourceMetadataUrl = (resource) => {
|
|
146
|
+
const url = new URL(resource);
|
|
147
|
+
const path = url.pathname === '/' ? '' : url.pathname.replace(/\/$/, '');
|
|
148
|
+
return `${url.origin}/.well-known/oauth-protected-resource${path}`;
|
|
149
|
+
};
|
|
150
|
+
exports.defaultResourceMetadataUrl = defaultResourceMetadataUrl;
|
|
151
|
+
const resourceMetadataUrlOf = (oauth) => oauth.resourceMetadataUrl ?? (0, exports.defaultResourceMetadataUrl)(oauth.resource);
|
|
152
|
+
exports.resourceMetadataUrlOf = resourceMetadataUrlOf;
|
|
153
|
+
/** The metadata a client reads to know where to sign in, and what to ask for. */
|
|
154
|
+
const protectedResourceMetadata = (oauth) => ({
|
|
155
|
+
resource: oauth.resource,
|
|
156
|
+
authorization_servers: [oauth.issuer],
|
|
157
|
+
scopes_supported: [...exports.OAUTH_SCOPES],
|
|
158
|
+
bearer_methods_supported: ['header'],
|
|
159
|
+
resource_name: oauth.resourceName ?? 'StreamBy',
|
|
160
|
+
});
|
|
161
|
+
exports.protectedResourceMetadata = protectedResourceMetadata;
|
|
162
|
+
/** The WWW-Authenticate challenge of a 401: the metadata's URL, and why the token was refused. */
|
|
163
|
+
const wwwAuthenticate = (oauth, invalidToken) => `Bearer resource_metadata="${(0, exports.resourceMetadataUrlOf)(oauth)}"${invalidToken ? ', error="invalid_token"' : ''}`;
|
|
164
|
+
exports.wwwAuthenticate = wwwAuthenticate;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -146,6 +146,7 @@ export interface StreamByConfig {
|
|
|
146
146
|
download?: number;
|
|
147
147
|
};
|
|
148
148
|
};
|
|
149
|
+
oauth?: OAuthResourceConfig;
|
|
149
150
|
websocket?: {
|
|
150
151
|
server: WebSocketServer;
|
|
151
152
|
};
|
|
@@ -179,6 +180,17 @@ export interface Auth {
|
|
|
179
180
|
username: string;
|
|
180
181
|
role: 'viewer' | 'editor' | 'admin';
|
|
181
182
|
apiToken?: ApiTokenScope;
|
|
183
|
+
oauth?: OAuthClientInfo;
|
|
184
|
+
}
|
|
185
|
+
export interface OAuthClientInfo {
|
|
186
|
+
clientId: string;
|
|
187
|
+
clientName?: string;
|
|
188
|
+
}
|
|
189
|
+
export interface OAuthResourceConfig {
|
|
190
|
+
issuer: string;
|
|
191
|
+
resource: string;
|
|
192
|
+
resourceMetadataUrl?: string;
|
|
193
|
+
resourceName?: string;
|
|
182
194
|
}
|
|
183
195
|
export type { ApiTokenPermission, ProjectPermission, AccountPermission };
|
|
184
196
|
export type ApiTokenKind = 'user' | 'project';
|