@oxyhq/core 1.2.5 → 1.4.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.
@@ -6,6 +6,66 @@ function OxyServicesAuthMixin(Base) {
6
6
  return class extends Base {
7
7
  constructor(...args) {
8
8
  super(...args);
9
+ /** @internal */ this._serviceToken = null;
10
+ /** @internal */ this._serviceTokenExp = 0;
11
+ /** @internal */ this._serviceApiKey = null;
12
+ /** @internal */ this._serviceApiSecret = null;
13
+ }
14
+ /**
15
+ * Configure service credentials for internal service-to-service communication.
16
+ * Call this once at startup so that getServiceToken() and makeServiceRequest()
17
+ * can automatically obtain and refresh tokens.
18
+ *
19
+ * @param apiKey - DeveloperApp API key (oxy_dk_*)
20
+ * @param apiSecret - DeveloperApp API secret
21
+ */
22
+ configureServiceAuth(apiKey, apiSecret) {
23
+ this._serviceApiKey = apiKey;
24
+ this._serviceApiSecret = apiSecret;
25
+ // Invalidate any cached token
26
+ this._serviceToken = null;
27
+ this._serviceTokenExp = 0;
28
+ }
29
+ /**
30
+ * Get a service token for internal service-to-service communication.
31
+ * Tokens are short-lived (1h) and automatically cached/refreshed.
32
+ *
33
+ * @param apiKey - DeveloperApp API key (optional if configureServiceAuth was called)
34
+ * @param apiSecret - DeveloperApp API secret (optional if configureServiceAuth was called)
35
+ */
36
+ async getServiceToken(apiKey, apiSecret) {
37
+ const key = apiKey || this._serviceApiKey;
38
+ const secret = apiSecret || this._serviceApiSecret;
39
+ if (!key || !secret) {
40
+ throw new Error('Service credentials not provided. Call configureServiceAuth() or pass apiKey and apiSecret.');
41
+ }
42
+ // Return cached token if still valid (with 60s buffer)
43
+ if (this._serviceToken && this._serviceTokenExp > Date.now() + 60000) {
44
+ return this._serviceToken;
45
+ }
46
+ const response = await this.makeRequest('POST', '/api/auth/service-token', { apiKey: key, apiSecret: secret }, { cache: false, retry: false });
47
+ this._serviceToken = response.token;
48
+ this._serviceTokenExp = Date.now() + response.expiresIn * 1000;
49
+ return this._serviceToken;
50
+ }
51
+ /**
52
+ * Make an authenticated request on behalf of a user using a service token.
53
+ * Automatically obtains/refreshes the service token.
54
+ *
55
+ * @param method - HTTP method
56
+ * @param url - API endpoint URL
57
+ * @param data - Request body or query params
58
+ * @param userId - Optional user ID to act on behalf of (sent as X-Oxy-User-Id)
59
+ */
60
+ async makeServiceRequest(method, url, data, userId) {
61
+ const token = await this.getServiceToken();
62
+ const headers = {
63
+ Authorization: `Bearer ${token}`,
64
+ };
65
+ if (userId) {
66
+ headers['X-Oxy-User-Id'] = userId;
67
+ }
68
+ return this.makeRequest(method, url, data, { headers, cache: false });
9
69
  }
10
70
  /**
11
71
  * Register a new identity with public key authentication
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.OxyServicesUtilityMixin = OxyServicesUtilityMixin;
4
37
  /**
@@ -59,7 +92,7 @@ function OxyServicesUtilityMixin(Base) {
59
92
  * @returns Express middleware function
60
93
  */
61
94
  auth(options = {}) {
62
- const { debug = false, onError, loadUser = false, optional = false } = options;
95
+ const { debug = false, onError, loadUser = false, optional = false, jwtSecret } = options;
63
96
  // Cast to any for cross-mixin method access (Auth mixin methods available at runtime)
64
97
  const oxyInstance = this;
65
98
  // Return an async middleware function
@@ -114,6 +147,95 @@ function OxyServicesUtilityMixin(Base) {
114
147
  return onError(error);
115
148
  return res.status(401).json(error);
116
149
  }
150
+ // Handle service tokens (internal service-to-service auth)
151
+ // Service tokens are stateless JWTs with type: 'service' — requires signature verification
152
+ if (decoded.type === 'service') {
153
+ // Service tokens MUST be cryptographically verified — reject if no secret provided
154
+ if (!jwtSecret) {
155
+ if (optional) {
156
+ req.userId = null;
157
+ req.user = null;
158
+ return next();
159
+ }
160
+ const error = {
161
+ message: 'Service token verification not configured',
162
+ code: 'SERVICE_TOKEN_NOT_CONFIGURED',
163
+ status: 403
164
+ };
165
+ if (onError)
166
+ return onError(error);
167
+ return res.status(403).json(error);
168
+ }
169
+ // Verify JWT signature (not just decode)
170
+ try {
171
+ const { createHmac } = await Promise.resolve().then(() => __importStar(require('crypto')));
172
+ const [headerB64, payloadB64, signatureB64] = token.split('.');
173
+ if (!headerB64 || !payloadB64 || !signatureB64) {
174
+ throw new Error('Invalid token structure');
175
+ }
176
+ const expectedSig = createHmac('sha256', jwtSecret)
177
+ .update(`${headerB64}.${payloadB64}`)
178
+ .digest('base64')
179
+ .replace(/\+/g, '-')
180
+ .replace(/\//g, '_')
181
+ .replace(/=/g, '');
182
+ // Timing-safe comparison
183
+ const sigBuf = Buffer.from(signatureB64);
184
+ const expectedBuf = Buffer.from(expectedSig);
185
+ const { timingSafeEqual } = await Promise.resolve().then(() => __importStar(require('crypto')));
186
+ if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
187
+ throw new Error('Invalid signature');
188
+ }
189
+ }
190
+ catch {
191
+ if (optional) {
192
+ req.userId = null;
193
+ req.user = null;
194
+ return next();
195
+ }
196
+ const error = { message: 'Invalid service token signature', code: 'INVALID_SERVICE_TOKEN', status: 401 };
197
+ if (onError)
198
+ return onError(error);
199
+ return res.status(401).json(error);
200
+ }
201
+ // Check expiration
202
+ if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
203
+ if (optional) {
204
+ req.userId = null;
205
+ req.user = null;
206
+ return next();
207
+ }
208
+ const error = { message: 'Service token expired', code: 'TOKEN_EXPIRED', status: 401 };
209
+ if (onError)
210
+ return onError(error);
211
+ return res.status(401).json(error);
212
+ }
213
+ // Validate required service token fields
214
+ if (!decoded.appId) {
215
+ if (optional) {
216
+ req.userId = null;
217
+ req.user = null;
218
+ return next();
219
+ }
220
+ const error = { message: 'Invalid service token: missing appId', code: 'INVALID_SERVICE_TOKEN', status: 401 };
221
+ if (onError)
222
+ return onError(error);
223
+ return res.status(401).json(error);
224
+ }
225
+ // Read delegated user ID from header
226
+ const oxyUserId = req.headers['x-oxy-user-id'];
227
+ req.userId = oxyUserId || null;
228
+ req.user = oxyUserId ? { id: oxyUserId } : null;
229
+ req.accessToken = token;
230
+ req.serviceApp = {
231
+ appId: decoded.appId || '',
232
+ appName: decoded.appName || 'unknown',
233
+ };
234
+ if (debug) {
235
+ console.log(`[oxy.auth] Service token OK app=${decoded.appName} delegateUser=${oxyUserId || '(none)'}`);
236
+ }
237
+ return next();
238
+ }
117
239
  const userId = decoded.userId || decoded.id;
118
240
  if (!userId) {
119
241
  if (optional) {
@@ -326,5 +448,36 @@ function OxyServicesUtilityMixin(Base) {
326
448
  }
327
449
  };
328
450
  }
451
+ /**
452
+ * Express.js middleware that only allows service tokens.
453
+ * Use this for internal-only endpoints that should not be accessible
454
+ * to regular users or API key consumers.
455
+ *
456
+ * @example
457
+ * ```typescript
458
+ * // Protect internal endpoints
459
+ * app.use('/internal', oxy.serviceAuth());
460
+ *
461
+ * app.post('/internal/trigger', (req, res) => {
462
+ * console.log('Service app:', req.serviceApp);
463
+ * console.log('Acting on behalf of user:', req.userId);
464
+ * });
465
+ * ```
466
+ */
467
+ serviceAuth(options = {}) {
468
+ const innerAuth = this.auth({ ...options });
469
+ return async (req, res, next) => {
470
+ await innerAuth(req, res, () => {
471
+ if (!req.serviceApp) {
472
+ return res.status(403).json({
473
+ error: 'Service token required',
474
+ message: 'This endpoint is only accessible to internal services',
475
+ code: 'SERVICE_TOKEN_REQUIRED',
476
+ });
477
+ }
478
+ next();
479
+ });
480
+ };
481
+ }
329
482
  };
330
483
  }
@@ -3,6 +3,66 @@ export function OxyServicesAuthMixin(Base) {
3
3
  return class extends Base {
4
4
  constructor(...args) {
5
5
  super(...args);
6
+ /** @internal */ this._serviceToken = null;
7
+ /** @internal */ this._serviceTokenExp = 0;
8
+ /** @internal */ this._serviceApiKey = null;
9
+ /** @internal */ this._serviceApiSecret = null;
10
+ }
11
+ /**
12
+ * Configure service credentials for internal service-to-service communication.
13
+ * Call this once at startup so that getServiceToken() and makeServiceRequest()
14
+ * can automatically obtain and refresh tokens.
15
+ *
16
+ * @param apiKey - DeveloperApp API key (oxy_dk_*)
17
+ * @param apiSecret - DeveloperApp API secret
18
+ */
19
+ configureServiceAuth(apiKey, apiSecret) {
20
+ this._serviceApiKey = apiKey;
21
+ this._serviceApiSecret = apiSecret;
22
+ // Invalidate any cached token
23
+ this._serviceToken = null;
24
+ this._serviceTokenExp = 0;
25
+ }
26
+ /**
27
+ * Get a service token for internal service-to-service communication.
28
+ * Tokens are short-lived (1h) and automatically cached/refreshed.
29
+ *
30
+ * @param apiKey - DeveloperApp API key (optional if configureServiceAuth was called)
31
+ * @param apiSecret - DeveloperApp API secret (optional if configureServiceAuth was called)
32
+ */
33
+ async getServiceToken(apiKey, apiSecret) {
34
+ const key = apiKey || this._serviceApiKey;
35
+ const secret = apiSecret || this._serviceApiSecret;
36
+ if (!key || !secret) {
37
+ throw new Error('Service credentials not provided. Call configureServiceAuth() or pass apiKey and apiSecret.');
38
+ }
39
+ // Return cached token if still valid (with 60s buffer)
40
+ if (this._serviceToken && this._serviceTokenExp > Date.now() + 60000) {
41
+ return this._serviceToken;
42
+ }
43
+ const response = await this.makeRequest('POST', '/api/auth/service-token', { apiKey: key, apiSecret: secret }, { cache: false, retry: false });
44
+ this._serviceToken = response.token;
45
+ this._serviceTokenExp = Date.now() + response.expiresIn * 1000;
46
+ return this._serviceToken;
47
+ }
48
+ /**
49
+ * Make an authenticated request on behalf of a user using a service token.
50
+ * Automatically obtains/refreshes the service token.
51
+ *
52
+ * @param method - HTTP method
53
+ * @param url - API endpoint URL
54
+ * @param data - Request body or query params
55
+ * @param userId - Optional user ID to act on behalf of (sent as X-Oxy-User-Id)
56
+ */
57
+ async makeServiceRequest(method, url, data, userId) {
58
+ const token = await this.getServiceToken();
59
+ const headers = {
60
+ Authorization: `Bearer ${token}`,
61
+ };
62
+ if (userId) {
63
+ headers['X-Oxy-User-Id'] = userId;
64
+ }
65
+ return this.makeRequest(method, url, data, { headers, cache: false });
6
66
  }
7
67
  /**
8
68
  * Register a new identity with public key authentication
@@ -56,7 +56,7 @@ export function OxyServicesUtilityMixin(Base) {
56
56
  * @returns Express middleware function
57
57
  */
58
58
  auth(options = {}) {
59
- const { debug = false, onError, loadUser = false, optional = false } = options;
59
+ const { debug = false, onError, loadUser = false, optional = false, jwtSecret } = options;
60
60
  // Cast to any for cross-mixin method access (Auth mixin methods available at runtime)
61
61
  const oxyInstance = this;
62
62
  // Return an async middleware function
@@ -111,6 +111,95 @@ export function OxyServicesUtilityMixin(Base) {
111
111
  return onError(error);
112
112
  return res.status(401).json(error);
113
113
  }
114
+ // Handle service tokens (internal service-to-service auth)
115
+ // Service tokens are stateless JWTs with type: 'service' — requires signature verification
116
+ if (decoded.type === 'service') {
117
+ // Service tokens MUST be cryptographically verified — reject if no secret provided
118
+ if (!jwtSecret) {
119
+ if (optional) {
120
+ req.userId = null;
121
+ req.user = null;
122
+ return next();
123
+ }
124
+ const error = {
125
+ message: 'Service token verification not configured',
126
+ code: 'SERVICE_TOKEN_NOT_CONFIGURED',
127
+ status: 403
128
+ };
129
+ if (onError)
130
+ return onError(error);
131
+ return res.status(403).json(error);
132
+ }
133
+ // Verify JWT signature (not just decode)
134
+ try {
135
+ const { createHmac } = await import('crypto');
136
+ const [headerB64, payloadB64, signatureB64] = token.split('.');
137
+ if (!headerB64 || !payloadB64 || !signatureB64) {
138
+ throw new Error('Invalid token structure');
139
+ }
140
+ const expectedSig = createHmac('sha256', jwtSecret)
141
+ .update(`${headerB64}.${payloadB64}`)
142
+ .digest('base64')
143
+ .replace(/\+/g, '-')
144
+ .replace(/\//g, '_')
145
+ .replace(/=/g, '');
146
+ // Timing-safe comparison
147
+ const sigBuf = Buffer.from(signatureB64);
148
+ const expectedBuf = Buffer.from(expectedSig);
149
+ const { timingSafeEqual } = await import('crypto');
150
+ if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
151
+ throw new Error('Invalid signature');
152
+ }
153
+ }
154
+ catch {
155
+ if (optional) {
156
+ req.userId = null;
157
+ req.user = null;
158
+ return next();
159
+ }
160
+ const error = { message: 'Invalid service token signature', code: 'INVALID_SERVICE_TOKEN', status: 401 };
161
+ if (onError)
162
+ return onError(error);
163
+ return res.status(401).json(error);
164
+ }
165
+ // Check expiration
166
+ if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
167
+ if (optional) {
168
+ req.userId = null;
169
+ req.user = null;
170
+ return next();
171
+ }
172
+ const error = { message: 'Service token expired', code: 'TOKEN_EXPIRED', status: 401 };
173
+ if (onError)
174
+ return onError(error);
175
+ return res.status(401).json(error);
176
+ }
177
+ // Validate required service token fields
178
+ if (!decoded.appId) {
179
+ if (optional) {
180
+ req.userId = null;
181
+ req.user = null;
182
+ return next();
183
+ }
184
+ const error = { message: 'Invalid service token: missing appId', code: 'INVALID_SERVICE_TOKEN', status: 401 };
185
+ if (onError)
186
+ return onError(error);
187
+ return res.status(401).json(error);
188
+ }
189
+ // Read delegated user ID from header
190
+ const oxyUserId = req.headers['x-oxy-user-id'];
191
+ req.userId = oxyUserId || null;
192
+ req.user = oxyUserId ? { id: oxyUserId } : null;
193
+ req.accessToken = token;
194
+ req.serviceApp = {
195
+ appId: decoded.appId || '',
196
+ appName: decoded.appName || 'unknown',
197
+ };
198
+ if (debug) {
199
+ console.log(`[oxy.auth] Service token OK app=${decoded.appName} delegateUser=${oxyUserId || '(none)'}`);
200
+ }
201
+ return next();
202
+ }
114
203
  const userId = decoded.userId || decoded.id;
115
204
  if (!userId) {
116
205
  if (optional) {
@@ -323,5 +412,36 @@ export function OxyServicesUtilityMixin(Base) {
323
412
  }
324
413
  };
325
414
  }
415
+ /**
416
+ * Express.js middleware that only allows service tokens.
417
+ * Use this for internal-only endpoints that should not be accessible
418
+ * to regular users or API key consumers.
419
+ *
420
+ * @example
421
+ * ```typescript
422
+ * // Protect internal endpoints
423
+ * app.use('/internal', oxy.serviceAuth());
424
+ *
425
+ * app.post('/internal/trigger', (req, res) => {
426
+ * console.log('Service app:', req.serviceApp);
427
+ * console.log('Acting on behalf of user:', req.userId);
428
+ * });
429
+ * ```
430
+ */
431
+ serviceAuth(options = {}) {
432
+ const innerAuth = this.auth({ ...options });
433
+ return async (req, res, next) => {
434
+ await innerAuth(req, res, () => {
435
+ if (!req.serviceApp) {
436
+ return res.status(403).json({
437
+ error: 'Service token required',
438
+ message: 'This endpoint is only accessible to internal services',
439
+ code: 'SERVICE_TOKEN_REQUIRED',
440
+ });
441
+ }
442
+ next();
443
+ });
444
+ };
445
+ }
326
446
  };
327
447
  }
@@ -23,6 +23,8 @@ export type { CrossDomainAuthOptions } from './CrossDomainAuth';
23
23
  export type { FedCMAuthOptions, FedCMConfig } from './mixins/OxyServices.fedcm';
24
24
  export type { PopupAuthOptions } from './mixins/OxyServices.popup';
25
25
  export type { RedirectAuthOptions } from './mixins/OxyServices.redirect';
26
+ export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
27
+ export type { ServiceApp } from './mixins/OxyServices.utility';
26
28
  export { KeyManager, SignatureService, RecoveryPhraseService } from './crypto';
27
29
  export type { KeyPair, SignedMessage, AuthChallenge, RecoveryPhraseResult } from './crypto';
28
30
  export * from './models/interfaces';
@@ -29,8 +29,44 @@ export interface PublicKeyCheckResponse {
29
29
  registered: boolean;
30
30
  message: string;
31
31
  }
32
+ export interface ServiceTokenResponse {
33
+ token: string;
34
+ expiresIn: number;
35
+ appName: string;
36
+ }
32
37
  export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T): {
33
38
  new (...args: any[]): {
39
+ /** @internal */ _serviceToken: string | null;
40
+ /** @internal */ _serviceTokenExp: number;
41
+ /** @internal */ _serviceApiKey: string | null;
42
+ /** @internal */ _serviceApiSecret: string | null;
43
+ /**
44
+ * Configure service credentials for internal service-to-service communication.
45
+ * Call this once at startup so that getServiceToken() and makeServiceRequest()
46
+ * can automatically obtain and refresh tokens.
47
+ *
48
+ * @param apiKey - DeveloperApp API key (oxy_dk_*)
49
+ * @param apiSecret - DeveloperApp API secret
50
+ */
51
+ configureServiceAuth(apiKey: string, apiSecret: string): void;
52
+ /**
53
+ * Get a service token for internal service-to-service communication.
54
+ * Tokens are short-lived (1h) and automatically cached/refreshed.
55
+ *
56
+ * @param apiKey - DeveloperApp API key (optional if configureServiceAuth was called)
57
+ * @param apiSecret - DeveloperApp API secret (optional if configureServiceAuth was called)
58
+ */
59
+ getServiceToken(apiKey?: string, apiSecret?: string): Promise<string>;
60
+ /**
61
+ * Make an authenticated request on behalf of a user using a service token.
62
+ * Automatically obtains/refreshes the service token.
63
+ *
64
+ * @param method - HTTP method
65
+ * @param url - API endpoint URL
66
+ * @param data - Request body or query params
67
+ * @param userId - Optional user ID to act on behalf of (sent as X-Oxy-User-Id)
68
+ */
69
+ makeServiceRequest<R = any>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, userId?: string): Promise<R>;
34
70
  /**
35
71
  * Register a new identity with public key authentication
36
72
  * Identity is purely cryptographic - username and profile data are optional
@@ -1,5 +1,12 @@
1
1
  import type { ApiError } from '../models/interfaces';
2
2
  import type { OxyServicesBase } from '../OxyServices.base';
3
+ /**
4
+ * Service app metadata attached to requests authenticated with service tokens
5
+ */
6
+ export interface ServiceApp {
7
+ appId: string;
8
+ appName: string;
9
+ }
3
10
  /**
4
11
  * Options for oxyClient.auth() middleware
5
12
  */
@@ -12,6 +19,12 @@ interface AuthMiddlewareOptions {
12
19
  loadUser?: boolean;
13
20
  /** Optional auth - attach user if token present but don't block (default: false) */
14
21
  optional?: boolean;
22
+ /**
23
+ * JWT secret for verifying service token signatures locally.
24
+ * When provided, service tokens will be cryptographically verified.
25
+ * When omitted, service tokens will be rejected (secure default).
26
+ */
27
+ jwtSecret?: string;
15
28
  }
16
29
  export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base: T): {
17
30
  new (...args: any[]): {
@@ -80,6 +93,25 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
80
93
  authSocket(options?: {
81
94
  debug?: boolean;
82
95
  }): (socket: any, next: (err?: Error) => void) => Promise<void>;
96
+ /**
97
+ * Express.js middleware that only allows service tokens.
98
+ * Use this for internal-only endpoints that should not be accessible
99
+ * to regular users or API key consumers.
100
+ *
101
+ * @example
102
+ * ```typescript
103
+ * // Protect internal endpoints
104
+ * app.use('/internal', oxy.serviceAuth());
105
+ *
106
+ * app.post('/internal/trigger', (req, res) => {
107
+ * console.log('Service app:', req.serviceApp);
108
+ * console.log('Acting on behalf of user:', req.userId);
109
+ * });
110
+ * ```
111
+ */
112
+ serviceAuth(options?: {
113
+ debug?: boolean;
114
+ }): (req: any, res: any, next: any) => Promise<void>;
83
115
  httpService: import("../HttpService").HttpService;
84
116
  cloudURL: string;
85
117
  config: import("../OxyServices.base").OxyConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "1.2.5",
3
+ "version": "1.4.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
package/src/index.ts CHANGED
@@ -30,6 +30,8 @@ export type { CrossDomainAuthOptions } from './CrossDomainAuth';
30
30
  export type { FedCMAuthOptions, FedCMConfig } from './mixins/OxyServices.fedcm';
31
31
  export type { PopupAuthOptions } from './mixins/OxyServices.popup';
32
32
  export type { RedirectAuthOptions } from './mixins/OxyServices.redirect';
33
+ export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
34
+ export type { ServiceApp } from './mixins/OxyServices.utility';
33
35
 
34
36
  // --- Crypto / Identity ---
35
37
  export { KeyManager, SignatureService, RecoveryPhraseService } from './crypto';
@@ -35,12 +35,99 @@ export interface PublicKeyCheckResponse {
35
35
  message: string;
36
36
  }
37
37
 
38
+ export interface ServiceTokenResponse {
39
+ token: string;
40
+ expiresIn: number;
41
+ appName: string;
42
+ }
43
+
38
44
  export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T) {
39
45
  return class extends Base {
46
+ /** @internal */ _serviceToken: string | null = null;
47
+ /** @internal */ _serviceTokenExp: number = 0;
48
+ /** @internal */ _serviceApiKey: string | null = null;
49
+ /** @internal */ _serviceApiSecret: string | null = null;
50
+
40
51
  constructor(...args: any[]) {
41
52
  super(...(args as [any]));
42
53
  }
43
54
 
55
+ /**
56
+ * Configure service credentials for internal service-to-service communication.
57
+ * Call this once at startup so that getServiceToken() and makeServiceRequest()
58
+ * can automatically obtain and refresh tokens.
59
+ *
60
+ * @param apiKey - DeveloperApp API key (oxy_dk_*)
61
+ * @param apiSecret - DeveloperApp API secret
62
+ */
63
+ configureServiceAuth(apiKey: string, apiSecret: string): void {
64
+ this._serviceApiKey = apiKey;
65
+ this._serviceApiSecret = apiSecret;
66
+ // Invalidate any cached token
67
+ this._serviceToken = null;
68
+ this._serviceTokenExp = 0;
69
+ }
70
+
71
+ /**
72
+ * Get a service token for internal service-to-service communication.
73
+ * Tokens are short-lived (1h) and automatically cached/refreshed.
74
+ *
75
+ * @param apiKey - DeveloperApp API key (optional if configureServiceAuth was called)
76
+ * @param apiSecret - DeveloperApp API secret (optional if configureServiceAuth was called)
77
+ */
78
+ async getServiceToken(apiKey?: string, apiSecret?: string): Promise<string> {
79
+ const key = apiKey || this._serviceApiKey;
80
+ const secret = apiSecret || this._serviceApiSecret;
81
+
82
+ if (!key || !secret) {
83
+ throw new Error('Service credentials not provided. Call configureServiceAuth() or pass apiKey and apiSecret.');
84
+ }
85
+
86
+ // Return cached token if still valid (with 60s buffer)
87
+ if (this._serviceToken && this._serviceTokenExp > Date.now() + 60_000) {
88
+ return this._serviceToken;
89
+ }
90
+
91
+ const response = await this.makeRequest<ServiceTokenResponse>(
92
+ 'POST',
93
+ '/api/auth/service-token',
94
+ { apiKey: key, apiSecret: secret },
95
+ { cache: false, retry: false }
96
+ );
97
+
98
+ this._serviceToken = response.token;
99
+ this._serviceTokenExp = Date.now() + response.expiresIn * 1000;
100
+
101
+ return this._serviceToken;
102
+ }
103
+
104
+ /**
105
+ * Make an authenticated request on behalf of a user using a service token.
106
+ * Automatically obtains/refreshes the service token.
107
+ *
108
+ * @param method - HTTP method
109
+ * @param url - API endpoint URL
110
+ * @param data - Request body or query params
111
+ * @param userId - Optional user ID to act on behalf of (sent as X-Oxy-User-Id)
112
+ */
113
+ async makeServiceRequest<R = any>(
114
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
115
+ url: string,
116
+ data?: any,
117
+ userId?: string
118
+ ): Promise<R> {
119
+ const token = await this.getServiceToken();
120
+
121
+ const headers: Record<string, string> = {
122
+ Authorization: `Bearer ${token}`,
123
+ };
124
+ if (userId) {
125
+ headers['X-Oxy-User-Id'] = userId;
126
+ }
127
+
128
+ return this.makeRequest<R>(method, url, data, { headers, cache: false });
129
+ }
130
+
44
131
  /**
45
132
  * Register a new identity with public key authentication
46
133
  * Identity is purely cryptographic - username and profile data are optional
@@ -14,9 +14,20 @@ interface JwtPayload {
14
14
  userId?: string;
15
15
  id?: string;
16
16
  sessionId?: string;
17
+ type?: string;
18
+ appId?: string;
19
+ appName?: string;
17
20
  [key: string]: any;
18
21
  }
19
22
 
23
+ /**
24
+ * Service app metadata attached to requests authenticated with service tokens
25
+ */
26
+ export interface ServiceApp {
27
+ appId: string;
28
+ appName: string;
29
+ }
30
+
20
31
  /**
21
32
  * Options for oxyClient.auth() middleware
22
33
  */
@@ -29,6 +40,12 @@ interface AuthMiddlewareOptions {
29
40
  loadUser?: boolean;
30
41
  /** Optional auth - attach user if token present but don't block (default: false) */
31
42
  optional?: boolean;
43
+ /**
44
+ * JWT secret for verifying service token signatures locally.
45
+ * When provided, service tokens will be cryptographically verified.
46
+ * When omitted, service tokens will be rejected (secure default).
47
+ */
48
+ jwtSecret?: string;
32
49
  }
33
50
 
34
51
  export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base: T) {
@@ -91,7 +108,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
91
108
  * @returns Express middleware function
92
109
  */
93
110
  auth(options: AuthMiddlewareOptions = {}) {
94
- const { debug = false, onError, loadUser = false, optional = false } = options;
111
+ const { debug = false, onError, loadUser = false, optional = false, jwtSecret } = options;
95
112
  // Cast to any for cross-mixin method access (Auth mixin methods available at runtime)
96
113
  const oxyInstance = this as any;
97
114
 
@@ -149,6 +166,99 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
149
166
  return res.status(401).json(error);
150
167
  }
151
168
 
169
+ // Handle service tokens (internal service-to-service auth)
170
+ // Service tokens are stateless JWTs with type: 'service' — requires signature verification
171
+ if (decoded.type === 'service') {
172
+ // Service tokens MUST be cryptographically verified — reject if no secret provided
173
+ if (!jwtSecret) {
174
+ if (optional) {
175
+ req.userId = null;
176
+ req.user = null;
177
+ return next();
178
+ }
179
+ const error = {
180
+ message: 'Service token verification not configured',
181
+ code: 'SERVICE_TOKEN_NOT_CONFIGURED',
182
+ status: 403
183
+ };
184
+ if (onError) return onError(error);
185
+ return res.status(403).json(error);
186
+ }
187
+
188
+ // Verify JWT signature (not just decode)
189
+ try {
190
+ const { createHmac } = await import('crypto');
191
+ const [headerB64, payloadB64, signatureB64] = token.split('.');
192
+ if (!headerB64 || !payloadB64 || !signatureB64) {
193
+ throw new Error('Invalid token structure');
194
+ }
195
+ const expectedSig = createHmac('sha256', jwtSecret)
196
+ .update(`${headerB64}.${payloadB64}`)
197
+ .digest('base64')
198
+ .replace(/\+/g, '-')
199
+ .replace(/\//g, '_')
200
+ .replace(/=/g, '');
201
+
202
+ // Timing-safe comparison
203
+ const sigBuf = Buffer.from(signatureB64);
204
+ const expectedBuf = Buffer.from(expectedSig);
205
+ const { timingSafeEqual } = await import('crypto');
206
+ if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
207
+ throw new Error('Invalid signature');
208
+ }
209
+ } catch {
210
+ if (optional) {
211
+ req.userId = null;
212
+ req.user = null;
213
+ return next();
214
+ }
215
+ const error = { message: 'Invalid service token signature', code: 'INVALID_SERVICE_TOKEN', status: 401 };
216
+ if (onError) return onError(error);
217
+ return res.status(401).json(error);
218
+ }
219
+
220
+ // Check expiration
221
+ if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
222
+ if (optional) {
223
+ req.userId = null;
224
+ req.user = null;
225
+ return next();
226
+ }
227
+ const error = { message: 'Service token expired', code: 'TOKEN_EXPIRED', status: 401 };
228
+ if (onError) return onError(error);
229
+ return res.status(401).json(error);
230
+ }
231
+
232
+ // Validate required service token fields
233
+ if (!decoded.appId) {
234
+ if (optional) {
235
+ req.userId = null;
236
+ req.user = null;
237
+ return next();
238
+ }
239
+ const error = { message: 'Invalid service token: missing appId', code: 'INVALID_SERVICE_TOKEN', status: 401 };
240
+ if (onError) return onError(error);
241
+ return res.status(401).json(error);
242
+ }
243
+
244
+ // Read delegated user ID from header
245
+ const oxyUserId = req.headers['x-oxy-user-id'] as string;
246
+
247
+ req.userId = oxyUserId || null;
248
+ req.user = oxyUserId ? ({ id: oxyUserId } as User) : null;
249
+ req.accessToken = token;
250
+ req.serviceApp = {
251
+ appId: decoded.appId || '',
252
+ appName: decoded.appName || 'unknown',
253
+ };
254
+
255
+ if (debug) {
256
+ console.log(`[oxy.auth] Service token OK app=${decoded.appName} delegateUser=${oxyUserId || '(none)'}`);
257
+ }
258
+
259
+ return next();
260
+ }
261
+
152
262
  const userId = decoded.userId || decoded.id;
153
263
  if (!userId) {
154
264
  if (optional) {
@@ -378,6 +488,38 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
378
488
  }
379
489
  };
380
490
  }
491
+ /**
492
+ * Express.js middleware that only allows service tokens.
493
+ * Use this for internal-only endpoints that should not be accessible
494
+ * to regular users or API key consumers.
495
+ *
496
+ * @example
497
+ * ```typescript
498
+ * // Protect internal endpoints
499
+ * app.use('/internal', oxy.serviceAuth());
500
+ *
501
+ * app.post('/internal/trigger', (req, res) => {
502
+ * console.log('Service app:', req.serviceApp);
503
+ * console.log('Acting on behalf of user:', req.userId);
504
+ * });
505
+ * ```
506
+ */
507
+ serviceAuth(options: { debug?: boolean } = {}) {
508
+ const innerAuth = this.auth({ ...options });
509
+
510
+ return async (req: any, res: any, next: any) => {
511
+ await innerAuth(req, res, () => {
512
+ if (!req.serviceApp) {
513
+ return res.status(403).json({
514
+ error: 'Service token required',
515
+ message: 'This endpoint is only accessible to internal services',
516
+ code: 'SERVICE_TOKEN_REQUIRED',
517
+ });
518
+ }
519
+ next();
520
+ });
521
+ };
522
+ }
381
523
  };
382
524
  }
383
525