@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.
- package/dist/cjs/mixins/OxyServices.auth.js +60 -0
- package/dist/cjs/mixins/OxyServices.fedcm.js +19 -1
- package/dist/cjs/mixins/OxyServices.utility.js +60 -0
- package/dist/esm/mixins/OxyServices.auth.js +60 -0
- package/dist/esm/mixins/OxyServices.fedcm.js +19 -1
- package/dist/esm/mixins/OxyServices.utility.js +60 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +36 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +26 -0
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/mixins/OxyServices.auth.ts +87 -0
- package/src/mixins/OxyServices.fedcm.ts +20 -1
- package/src/mixins/OxyServices.utility.ts +76 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
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
|
-
|
|
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
|
|