@oxyhq/core 1.2.4 → 1.3.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
@@ -105,12 +105,20 @@ function OxyServicesFedCMMixin(Base) {
105
105
  }
106
106
  catch (error) {
107
107
  debug.log('Interactive sign-in failed:', error);
108
+ const errorMessage = error instanceof Error ? error.message : String(error);
108
109
  if (error.name === 'AbortError') {
109
110
  throw new OxyServices_errors_1.OxyAuthenticationError('Sign-in was cancelled by user');
110
111
  }
111
112
  if (error.name === 'NetworkError') {
112
113
  throw new OxyServices_errors_1.OxyAuthenticationError('Network error during sign-in. Please check your connection.');
113
114
  }
115
+ if (errorMessage.includes('multiple accounts')) {
116
+ throw new OxyServices_errors_1.OxyAuthenticationError('Please sign out and sign in again to use FedCM with a single account');
117
+ }
118
+ if (errorMessage.includes('retrieving a token') || errorMessage.includes('Error retrieving')) {
119
+ debug.error('FedCM token retrieval error - this may be a browser or IdP configuration issue');
120
+ throw new OxyServices_errors_1.OxyAuthenticationError('Authentication failed. Please try again or use an alternative sign-in method.');
121
+ }
114
122
  throw error;
115
123
  }
116
124
  }
@@ -170,7 +178,17 @@ function OxyServicesFedCMMixin(Base) {
170
178
  catch (silentError) {
171
179
  const errorName = silentError instanceof Error ? silentError.name : 'Unknown';
172
180
  const errorMessage = silentError instanceof Error ? silentError.message : String(silentError);
173
- debug.log('Silent SSO: Silent mediation failed:', { name: errorName, message: errorMessage });
181
+ // Handle specific FedCM errors with better logging
182
+ if (errorMessage.includes('multiple accounts')) {
183
+ debug.log('Silent SSO: User has used multiple accounts - silent mediation not available');
184
+ debug.log('Silent SSO: User needs to explicitly sign in to choose account');
185
+ }
186
+ else if (errorMessage.includes('conditions')) {
187
+ debug.log('Silent SSO: Conditions not met (user may not be logged in at IdP or not in approved_clients)');
188
+ }
189
+ else {
190
+ debug.log('Silent SSO: Silent mediation failed:', { name: errorName, message: errorMessage });
191
+ }
174
192
  return null;
175
193
  }
176
194
  if (!credential || !credential.token) {
@@ -114,6 +114,35 @@ function OxyServicesUtilityMixin(Base) {
114
114
  return onError(error);
115
115
  return res.status(401).json(error);
116
116
  }
117
+ // Handle service tokens (internal service-to-service auth)
118
+ // Service tokens are stateless JWTs with type: 'service' — no session validation needed
119
+ if (decoded.type === 'service') {
120
+ // Check expiration
121
+ if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
122
+ if (optional) {
123
+ req.userId = null;
124
+ req.user = null;
125
+ return next();
126
+ }
127
+ const error = { message: 'Service token expired', code: 'TOKEN_EXPIRED', status: 401 };
128
+ if (onError)
129
+ return onError(error);
130
+ return res.status(401).json(error);
131
+ }
132
+ // Read delegated user ID from header
133
+ const oxyUserId = req.headers['x-oxy-user-id'];
134
+ req.userId = oxyUserId || null;
135
+ req.user = oxyUserId ? { id: oxyUserId } : null;
136
+ req.accessToken = token;
137
+ req.serviceApp = {
138
+ appId: decoded.appId || '',
139
+ appName: decoded.appName || 'unknown',
140
+ };
141
+ if (debug) {
142
+ console.log(`[oxy.auth] Service token OK app=${decoded.appName} delegateUser=${oxyUserId || '(none)'}`);
143
+ }
144
+ return next();
145
+ }
117
146
  const userId = decoded.userId || decoded.id;
118
147
  if (!userId) {
119
148
  if (optional) {
@@ -326,5 +355,36 @@ function OxyServicesUtilityMixin(Base) {
326
355
  }
327
356
  };
328
357
  }
358
+ /**
359
+ * Express.js middleware that only allows service tokens.
360
+ * Use this for internal-only endpoints that should not be accessible
361
+ * to regular users or API key consumers.
362
+ *
363
+ * @example
364
+ * ```typescript
365
+ * // Protect internal endpoints
366
+ * app.use('/internal', oxy.serviceAuth());
367
+ *
368
+ * app.post('/internal/trigger', (req, res) => {
369
+ * console.log('Service app:', req.serviceApp);
370
+ * console.log('Acting on behalf of user:', req.userId);
371
+ * });
372
+ * ```
373
+ */
374
+ serviceAuth(options = {}) {
375
+ const innerAuth = this.auth({ ...options });
376
+ return async (req, res, next) => {
377
+ await innerAuth(req, res, () => {
378
+ if (!req.serviceApp) {
379
+ return res.status(403).json({
380
+ error: 'Service token required',
381
+ message: 'This endpoint is only accessible to internal services',
382
+ code: 'SERVICE_TOKEN_REQUIRED',
383
+ });
384
+ }
385
+ next();
386
+ });
387
+ };
388
+ }
329
389
  };
330
390
  }
@@ -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
@@ -101,12 +101,20 @@ export function OxyServicesFedCMMixin(Base) {
101
101
  }
102
102
  catch (error) {
103
103
  debug.log('Interactive sign-in failed:', error);
104
+ const errorMessage = error instanceof Error ? error.message : String(error);
104
105
  if (error.name === 'AbortError') {
105
106
  throw new OxyAuthenticationError('Sign-in was cancelled by user');
106
107
  }
107
108
  if (error.name === 'NetworkError') {
108
109
  throw new OxyAuthenticationError('Network error during sign-in. Please check your connection.');
109
110
  }
111
+ if (errorMessage.includes('multiple accounts')) {
112
+ throw new OxyAuthenticationError('Please sign out and sign in again to use FedCM with a single account');
113
+ }
114
+ if (errorMessage.includes('retrieving a token') || errorMessage.includes('Error retrieving')) {
115
+ debug.error('FedCM token retrieval error - this may be a browser or IdP configuration issue');
116
+ throw new OxyAuthenticationError('Authentication failed. Please try again or use an alternative sign-in method.');
117
+ }
110
118
  throw error;
111
119
  }
112
120
  }
@@ -166,7 +174,17 @@ export function OxyServicesFedCMMixin(Base) {
166
174
  catch (silentError) {
167
175
  const errorName = silentError instanceof Error ? silentError.name : 'Unknown';
168
176
  const errorMessage = silentError instanceof Error ? silentError.message : String(silentError);
169
- debug.log('Silent SSO: Silent mediation failed:', { name: errorName, message: errorMessage });
177
+ // Handle specific FedCM errors with better logging
178
+ if (errorMessage.includes('multiple accounts')) {
179
+ debug.log('Silent SSO: User has used multiple accounts - silent mediation not available');
180
+ debug.log('Silent SSO: User needs to explicitly sign in to choose account');
181
+ }
182
+ else if (errorMessage.includes('conditions')) {
183
+ debug.log('Silent SSO: Conditions not met (user may not be logged in at IdP or not in approved_clients)');
184
+ }
185
+ else {
186
+ debug.log('Silent SSO: Silent mediation failed:', { name: errorName, message: errorMessage });
187
+ }
170
188
  return null;
171
189
  }
172
190
  if (!credential || !credential.token) {
@@ -111,6 +111,35 @@ 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' — no session validation needed
116
+ if (decoded.type === 'service') {
117
+ // Check expiration
118
+ if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
119
+ if (optional) {
120
+ req.userId = null;
121
+ req.user = null;
122
+ return next();
123
+ }
124
+ const error = { message: 'Service token expired', code: 'TOKEN_EXPIRED', status: 401 };
125
+ if (onError)
126
+ return onError(error);
127
+ return res.status(401).json(error);
128
+ }
129
+ // Read delegated user ID from header
130
+ const oxyUserId = req.headers['x-oxy-user-id'];
131
+ req.userId = oxyUserId || null;
132
+ req.user = oxyUserId ? { id: oxyUserId } : null;
133
+ req.accessToken = token;
134
+ req.serviceApp = {
135
+ appId: decoded.appId || '',
136
+ appName: decoded.appName || 'unknown',
137
+ };
138
+ if (debug) {
139
+ console.log(`[oxy.auth] Service token OK app=${decoded.appName} delegateUser=${oxyUserId || '(none)'}`);
140
+ }
141
+ return next();
142
+ }
114
143
  const userId = decoded.userId || decoded.id;
115
144
  if (!userId) {
116
145
  if (optional) {
@@ -323,5 +352,36 @@ export function OxyServicesUtilityMixin(Base) {
323
352
  }
324
353
  };
325
354
  }
355
+ /**
356
+ * Express.js middleware that only allows service tokens.
357
+ * Use this for internal-only endpoints that should not be accessible
358
+ * to regular users or API key consumers.
359
+ *
360
+ * @example
361
+ * ```typescript
362
+ * // Protect internal endpoints
363
+ * app.use('/internal', oxy.serviceAuth());
364
+ *
365
+ * app.post('/internal/trigger', (req, res) => {
366
+ * console.log('Service app:', req.serviceApp);
367
+ * console.log('Acting on behalf of user:', req.userId);
368
+ * });
369
+ * ```
370
+ */
371
+ serviceAuth(options = {}) {
372
+ const innerAuth = this.auth({ ...options });
373
+ return async (req, res, next) => {
374
+ await innerAuth(req, res, () => {
375
+ if (!req.serviceApp) {
376
+ return res.status(403).json({
377
+ error: 'Service token required',
378
+ message: 'This endpoint is only accessible to internal services',
379
+ code: 'SERVICE_TOKEN_REQUIRED',
380
+ });
381
+ }
382
+ next();
383
+ });
384
+ };
385
+ }
326
386
  };
327
387
  }
@@ -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
  */
@@ -80,6 +87,25 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
80
87
  authSocket(options?: {
81
88
  debug?: boolean;
82
89
  }): (socket: any, next: (err?: Error) => void) => Promise<void>;
90
+ /**
91
+ * Express.js middleware that only allows service tokens.
92
+ * Use this for internal-only endpoints that should not be accessible
93
+ * to regular users or API key consumers.
94
+ *
95
+ * @example
96
+ * ```typescript
97
+ * // Protect internal endpoints
98
+ * app.use('/internal', oxy.serviceAuth());
99
+ *
100
+ * app.post('/internal/trigger', (req, res) => {
101
+ * console.log('Service app:', req.serviceApp);
102
+ * console.log('Acting on behalf of user:', req.userId);
103
+ * });
104
+ * ```
105
+ */
106
+ serviceAuth(options?: {
107
+ debug?: boolean;
108
+ }): (req: any, res: any, next: any) => Promise<void>;
83
109
  httpService: import("../HttpService").HttpService;
84
110
  cloudURL: string;
85
111
  config: import("../OxyServices.base").OxyConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "1.2.4",
3
+ "version": "1.3.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
@@ -131,12 +131,21 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
131
131
  return session;
132
132
  } catch (error) {
133
133
  debug.log('Interactive sign-in failed:', error);
134
+ const errorMessage = error instanceof Error ? error.message : String(error);
135
+
134
136
  if ((error as any).name === 'AbortError') {
135
137
  throw new OxyAuthenticationError('Sign-in was cancelled by user');
136
138
  }
137
139
  if ((error as any).name === 'NetworkError') {
138
140
  throw new OxyAuthenticationError('Network error during sign-in. Please check your connection.');
139
141
  }
142
+ if (errorMessage.includes('multiple accounts')) {
143
+ throw new OxyAuthenticationError('Please sign out and sign in again to use FedCM with a single account');
144
+ }
145
+ if (errorMessage.includes('retrieving a token') || errorMessage.includes('Error retrieving')) {
146
+ debug.error('FedCM token retrieval error - this may be a browser or IdP configuration issue');
147
+ throw new OxyAuthenticationError('Authentication failed. Please try again or use an alternative sign-in method.');
148
+ }
140
149
  throw error;
141
150
  }
142
151
  }
@@ -201,7 +210,17 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
201
210
  } catch (silentError) {
202
211
  const errorName = silentError instanceof Error ? silentError.name : 'Unknown';
203
212
  const errorMessage = silentError instanceof Error ? silentError.message : String(silentError);
204
- debug.log('Silent SSO: Silent mediation failed:', { name: errorName, message: errorMessage });
213
+
214
+ // Handle specific FedCM errors with better logging
215
+ if (errorMessage.includes('multiple accounts')) {
216
+ debug.log('Silent SSO: User has used multiple accounts - silent mediation not available');
217
+ debug.log('Silent SSO: User needs to explicitly sign in to choose account');
218
+ } else if (errorMessage.includes('conditions')) {
219
+ debug.log('Silent SSO: Conditions not met (user may not be logged in at IdP or not in approved_clients)');
220
+ } else {
221
+ debug.log('Silent SSO: Silent mediation failed:', { name: errorName, message: errorMessage });
222
+ }
223
+
205
224
  return null;
206
225
  }
207
226
 
@@ -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
  */
@@ -149,6 +160,39 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
149
160
  return res.status(401).json(error);
150
161
  }
151
162
 
163
+ // Handle service tokens (internal service-to-service auth)
164
+ // Service tokens are stateless JWTs with type: 'service' — no session validation needed
165
+ if (decoded.type === 'service') {
166
+ // Check expiration
167
+ if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
168
+ if (optional) {
169
+ req.userId = null;
170
+ req.user = null;
171
+ return next();
172
+ }
173
+ const error = { message: 'Service token expired', code: 'TOKEN_EXPIRED', status: 401 };
174
+ if (onError) return onError(error);
175
+ return res.status(401).json(error);
176
+ }
177
+
178
+ // Read delegated user ID from header
179
+ const oxyUserId = req.headers['x-oxy-user-id'] as string;
180
+
181
+ req.userId = oxyUserId || null;
182
+ req.user = oxyUserId ? ({ id: oxyUserId } as User) : null;
183
+ req.accessToken = token;
184
+ req.serviceApp = {
185
+ appId: decoded.appId || '',
186
+ appName: decoded.appName || 'unknown',
187
+ };
188
+
189
+ if (debug) {
190
+ console.log(`[oxy.auth] Service token OK app=${decoded.appName} delegateUser=${oxyUserId || '(none)'}`);
191
+ }
192
+
193
+ return next();
194
+ }
195
+
152
196
  const userId = decoded.userId || decoded.id;
153
197
  if (!userId) {
154
198
  if (optional) {
@@ -378,6 +422,38 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
378
422
  }
379
423
  };
380
424
  }
425
+ /**
426
+ * Express.js middleware that only allows service tokens.
427
+ * Use this for internal-only endpoints that should not be accessible
428
+ * to regular users or API key consumers.
429
+ *
430
+ * @example
431
+ * ```typescript
432
+ * // Protect internal endpoints
433
+ * app.use('/internal', oxy.serviceAuth());
434
+ *
435
+ * app.post('/internal/trigger', (req, res) => {
436
+ * console.log('Service app:', req.serviceApp);
437
+ * console.log('Acting on behalf of user:', req.userId);
438
+ * });
439
+ * ```
440
+ */
441
+ serviceAuth(options: { debug?: boolean } = {}) {
442
+ const innerAuth = this.auth({ ...options });
443
+
444
+ return async (req: any, res: any, next: any) => {
445
+ await innerAuth(req, res, () => {
446
+ if (!req.serviceApp) {
447
+ return res.status(403).json({
448
+ error: 'Service token required',
449
+ message: 'This endpoint is only accessible to internal services',
450
+ code: 'SERVICE_TOKEN_REQUIRED',
451
+ });
452
+ }
453
+ next();
454
+ });
455
+ };
456
+ }
381
457
  };
382
458
  }
383
459