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 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 (see above). It takes only an API token; a browser session gets 401.
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 its user's role in each project allows), and a
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). Past that, StreamBy
277
- answers 429 with `Retry-After`. Each instance counts its own requests. Change the limit with
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, the project, the
281
- tool, whether it went through, the status, the error and the duration. A row never includes
282
- the arguments or the token.
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
- The claude.ai connector needs OAuth, which isn't available yet: it's for API tokens only for now.
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; } });
@@ -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';
@@ -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
- userId: auth.userId, tokenId: auth.apiToken.tokenId, projectId, tool: tool.name,
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
- userId: auth.userId, tokenId: auth.apiToken.tokenId, projectId, tool: tool.name,
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
- userId: auth.userId, tokenId: auth.apiToken.tokenId, projectId, tool: tool.name,
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. Each tool calls the REST API right here -- this
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
- if (!auth.apiToken)
21
- return jsonRpcError(res, 401, 'The MCP server takes an API token (Authorization: Bearer sbt_...)');
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 API token; try again later');
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,3 @@
1
+ import { Router } from 'express';
2
+ import { StreamByConfig } from '../../types';
3
+ export declare function oauthResourceRouter(config: StreamByConfig): Router;
@@ -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
+ }
@@ -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>>>;
@@ -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 scope = req.auth?.apiToken;
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 res.status(401).json({ error: 'Unauthorized' });
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 res.status(401).json({ error: 'Unauthorized' });
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 res.status(401).json({ error: 'Unauthorized' });
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;
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "streamby-core",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "StreamBy middleware framework for media storage management",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",