@friggframework/core 2.0.0--canary.643.45b1fbc.0 → 2.0.0--canary.636.7dc8dca.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/handlers/routers/user.js +39 -98
- package/index.js +0 -6
- package/logs/index.js +3 -11
- package/logs/logger.js +3 -133
- package/modules/module.js +20 -0
- package/modules/requester/oauth-2.js +147 -3
- package/modules/requester/requester.js +114 -7
- package/package.json +5 -5
- package/types/module-plugin/index.d.ts +3 -0
- package/handlers/rate-limiter.js +0 -90
- package/handlers/routers/user-router.js +0 -226
- package/user/use-cases/login-with-api-key.js +0 -325
- package/user/use-cases/validate-api-key-auth-mode.js +0 -103
package/handlers/routers/user.js
CHANGED
|
@@ -1,19 +1,9 @@
|
|
|
1
|
+
const express = require('express');
|
|
1
2
|
const { createAppHandler } = require('../app-handler-helpers');
|
|
3
|
+
const { checkRequiredParams } = require('@friggframework/core');
|
|
2
4
|
const {
|
|
3
5
|
createUserRepository,
|
|
4
6
|
} = require('../../user/repositories/user-repository-factory');
|
|
5
|
-
const {
|
|
6
|
-
createModuleRepository,
|
|
7
|
-
} = require('../../modules/repositories/module-repository-factory');
|
|
8
|
-
const {
|
|
9
|
-
createCredentialRepository,
|
|
10
|
-
} = require('../../credential/repositories/credential-repository-factory');
|
|
11
|
-
const {
|
|
12
|
-
createIntegrationRepository,
|
|
13
|
-
} = require('../../integrations/repositories/integration-repository-factory');
|
|
14
|
-
const {
|
|
15
|
-
getModulesDefinitionFromIntegrationClasses,
|
|
16
|
-
} = require('../../integrations/utils/map-integration-dto');
|
|
17
7
|
const {
|
|
18
8
|
CreateIndividualUser,
|
|
19
9
|
} = require('../../user/use-cases/create-individual-user');
|
|
@@ -21,101 +11,52 @@ const { LoginUser } = require('../../user/use-cases/login-user');
|
|
|
21
11
|
const {
|
|
22
12
|
CreateTokenForUserId,
|
|
23
13
|
} = require('../../user/use-cases/create-token-for-user-id');
|
|
24
|
-
const
|
|
25
|
-
GetUserFromXFriggHeaders,
|
|
26
|
-
} = require('../../user/use-cases/get-user-from-x-frigg-headers');
|
|
27
|
-
const { LoginWithApiKey } = require('../../user/use-cases/login-with-api-key');
|
|
28
|
-
const {
|
|
29
|
-
validateApiKeyAuthMode,
|
|
30
|
-
} = require('../../user/use-cases/validate-api-key-auth-mode');
|
|
31
|
-
const {
|
|
32
|
-
ProcessAuthorizationCallback,
|
|
33
|
-
} = require('../../modules/use-cases/process-authorization-callback');
|
|
34
|
-
const { FixedWindowRateLimiter } = require('../rate-limiter');
|
|
14
|
+
const catchAsyncError = require('express-async-handler');
|
|
35
15
|
const { loadAppDefinition } = require('../app-definition-loader');
|
|
36
|
-
const { buildUserRouter } = require('./user-router');
|
|
37
|
-
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
// Module-scope wiring (production). Kept thin; all logic lives in use cases.
|
|
40
|
-
// The route factory (buildUserRouter) is side-effect-free and lives in
|
|
41
|
-
// ./user-router.js so it can be unit-tested without loading an app definition.
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
const { integrations: integrationClasses, userConfig } = loadAppDefinition();
|
|
44
|
-
const moduleDefinitions =
|
|
45
|
-
getModulesDefinitionFromIntegrationClasses(integrationClasses);
|
|
46
|
-
|
|
47
|
-
// Fail fast if apiKey mode names a module the app does not have (no-op when off).
|
|
48
|
-
validateApiKeyAuthMode(userConfig, moduleDefinitions);
|
|
49
|
-
|
|
50
|
-
const apiKeyModeEnabled = Boolean(userConfig?.authModes?.apiKey);
|
|
51
16
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
// and the SameSite=strict session cookie is the only residual protection. This
|
|
55
|
-
// is intentionally NOT a hard failure — it must not break unconfigured local
|
|
56
|
-
// dev — but adopters serving a browser SPA should set allowedOrigins (ADR-034 §7).
|
|
57
|
-
if (
|
|
58
|
-
apiKeyModeEnabled &&
|
|
59
|
-
!Array.isArray(userConfig?.authModes?.apiKey?.allowedOrigins)
|
|
60
|
-
) {
|
|
61
|
-
// eslint-disable-next-line no-console
|
|
62
|
-
console.warn(
|
|
63
|
-
'[Frigg] apiKey auth mode is enabled without user.authModes.apiKey.allowedOrigins. ' +
|
|
64
|
-
'CSRF Origin/Referer enforcement is OFF; the SameSite=strict session cookie is the only ' +
|
|
65
|
-
'residual protection. Set allowedOrigins to a list of trusted browser origins to lock this down (ADR-034 §7).'
|
|
66
|
-
);
|
|
67
|
-
}
|
|
17
|
+
const router = express();
|
|
18
|
+
const { userConfig } = loadAppDefinition();
|
|
68
19
|
const userRepository = createUserRepository();
|
|
69
20
|
const createIndividualUser = new CreateIndividualUser({
|
|
70
21
|
userRepository,
|
|
71
22
|
userConfig,
|
|
72
23
|
});
|
|
73
|
-
const loginUser = new LoginUser({
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// apiKey-mode collaborators are only wired when the mode is enabled, so an app
|
|
77
|
-
// that never opts in pays nothing and behaves exactly as before.
|
|
78
|
-
let loginWithApiKey = null;
|
|
79
|
-
if (apiKeyModeEnabled) {
|
|
80
|
-
const moduleRepository = createModuleRepository();
|
|
81
|
-
const credentialRepository = createCredentialRepository();
|
|
82
|
-
const integrationRepository = createIntegrationRepository();
|
|
83
|
-
|
|
84
|
-
const getUserFromXFriggHeaders = new GetUserFromXFriggHeaders({
|
|
85
|
-
userRepository,
|
|
86
|
-
userConfig,
|
|
87
|
-
});
|
|
88
|
-
const processAuthorizationCallback = new ProcessAuthorizationCallback({
|
|
89
|
-
moduleRepository,
|
|
90
|
-
credentialRepository,
|
|
91
|
-
integrationRepository,
|
|
92
|
-
moduleDefinitions,
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
loginWithApiKey = new LoginWithApiKey({
|
|
96
|
-
userConfig,
|
|
97
|
-
moduleDefinitions,
|
|
98
|
-
getUserFromXFriggHeaders,
|
|
99
|
-
processAuthorizationCallback,
|
|
100
|
-
createTokenForUserId,
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const rlConfig = userConfig?.authModes?.apiKey?.rateLimit || {};
|
|
105
|
-
const apiKeyLoginLimiter = new FixedWindowRateLimiter({
|
|
106
|
-
windowMs: rlConfig.windowMs ?? 60000,
|
|
107
|
-
maxPerKey: rlConfig.maxPerKey ?? 10,
|
|
108
|
-
maxGlobal: rlConfig.maxGlobal ?? 1000,
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
const router = buildUserRouter({
|
|
24
|
+
const loginUser = new LoginUser({
|
|
25
|
+
userRepository,
|
|
112
26
|
userConfig,
|
|
113
|
-
loginUser,
|
|
114
|
-
createIndividualUser,
|
|
115
|
-
createTokenForUserId,
|
|
116
|
-
loginWithApiKey,
|
|
117
|
-
apiKeyLoginLimiter,
|
|
118
27
|
});
|
|
28
|
+
const createTokenForUserId = new CreateTokenForUserId({ userRepository });
|
|
29
|
+
|
|
30
|
+
// define the login endpoint
|
|
31
|
+
router.route('/user/login').post(
|
|
32
|
+
catchAsyncError(async (req, res) => {
|
|
33
|
+
const { username, password } = checkRequiredParams(req.body, [
|
|
34
|
+
'username',
|
|
35
|
+
'password',
|
|
36
|
+
]);
|
|
37
|
+
const user = await loginUser.execute({ username, password });
|
|
38
|
+
const token = await createTokenForUserId.execute(user.getId(), 120);
|
|
39
|
+
res.status(201);
|
|
40
|
+
res.json({ token });
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
router.route('/user/create').post(
|
|
45
|
+
catchAsyncError(async (req, res) => {
|
|
46
|
+
const { username, password } = checkRequiredParams(req.body, [
|
|
47
|
+
'username',
|
|
48
|
+
'password',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const user = await createIndividualUser.execute({
|
|
52
|
+
username,
|
|
53
|
+
password,
|
|
54
|
+
});
|
|
55
|
+
const token = await createTokenForUserId.execute(user.getId(), 120);
|
|
56
|
+
res.status(201);
|
|
57
|
+
res.json({ token });
|
|
58
|
+
})
|
|
59
|
+
);
|
|
119
60
|
|
|
120
61
|
const handler = createAppHandler('HTTP Event: User', router);
|
|
121
62
|
|
package/index.js
CHANGED
|
@@ -31,10 +31,6 @@ const {
|
|
|
31
31
|
GetUserFromAdopterJwt,
|
|
32
32
|
} = require('./user/use-cases/get-user-from-adopter-jwt');
|
|
33
33
|
const { AuthenticateUser } = require('./user/use-cases/authenticate-user');
|
|
34
|
-
const { LoginWithApiKey } = require('./user/use-cases/login-with-api-key');
|
|
35
|
-
const {
|
|
36
|
-
validateApiKeyAuthMode,
|
|
37
|
-
} = require('./user/use-cases/validate-api-key-auth-mode');
|
|
38
34
|
|
|
39
35
|
const {
|
|
40
36
|
CredentialRepository,
|
|
@@ -125,8 +121,6 @@ module.exports = {
|
|
|
125
121
|
GetUserFromXFriggHeaders,
|
|
126
122
|
GetUserFromAdopterJwt,
|
|
127
123
|
AuthenticateUser,
|
|
128
|
-
LoginWithApiKey,
|
|
129
|
-
validateApiKeyAuthMode,
|
|
130
124
|
CredentialRepository,
|
|
131
125
|
ModuleRepository,
|
|
132
126
|
IntegrationMappingRepository,
|
package/logs/index.js
CHANGED
|
@@ -1,15 +1,7 @@
|
|
|
1
|
-
const {
|
|
2
|
-
debug,
|
|
3
|
-
initDebugLog,
|
|
4
|
-
flushDebugLog,
|
|
5
|
-
redactSensitive,
|
|
6
|
-
SENSITIVE_KEYS,
|
|
7
|
-
} = require('./logger');
|
|
1
|
+
const {debug, initDebugLog, flushDebugLog} = require('./logger');
|
|
8
2
|
|
|
9
3
|
module.exports = {
|
|
10
4
|
debug,
|
|
11
5
|
initDebugLog,
|
|
12
|
-
flushDebugLog
|
|
13
|
-
|
|
14
|
-
SENSITIVE_KEYS,
|
|
15
|
-
};
|
|
6
|
+
flushDebugLog
|
|
7
|
+
}
|
package/logs/logger.js
CHANGED
|
@@ -6,127 +6,6 @@ const util = require('util');
|
|
|
6
6
|
const logs = [];
|
|
7
7
|
let flushCalled = false;
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* Keys whose values must never reach the logs. Matched case-insensitively.
|
|
11
|
-
* These are the credential-bearing fields a request body / headers can carry —
|
|
12
|
-
* the apiKey-login body (`{ apiKey }`), the friggToken body (`{ password }`),
|
|
13
|
-
* OAuth material, and Authorization headers. Buffered debug output (and the
|
|
14
|
-
* verbose `DEBUG_VERBOSE=1` path) is dumped verbatim on any 5xx, so a raw secret
|
|
15
|
-
* in `event.body` would otherwise land in CloudWatch (ADR-034 §4).
|
|
16
|
-
* @constant {Set<string>}
|
|
17
|
-
*/
|
|
18
|
-
const SENSITIVE_KEYS = new Set([
|
|
19
|
-
'apikey',
|
|
20
|
-
'api_key',
|
|
21
|
-
'password',
|
|
22
|
-
'token',
|
|
23
|
-
'authorization',
|
|
24
|
-
'refresh_token',
|
|
25
|
-
'access_token',
|
|
26
|
-
]);
|
|
27
|
-
|
|
28
|
-
const REDACTED = '[REDACTED]';
|
|
29
|
-
// Bound recursion and body-parse cost so a pathological event can never hang or
|
|
30
|
-
// blow the stack inside the logger. The logger must never throw.
|
|
31
|
-
const MAX_REDACT_DEPTH = 8;
|
|
32
|
-
const MAX_BODY_PARSE_LENGTH = 100000;
|
|
33
|
-
|
|
34
|
-
function isSensitiveKey(key) {
|
|
35
|
-
return typeof key === 'string' && SENSITIVE_KEYS.has(key.toLowerCase());
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Substring/regex fallback for a request body we could not (or should not)
|
|
40
|
-
* JSON-parse: a non-JSON body, a form-urlencoded body, or one too large to parse
|
|
41
|
-
* cheaply. Masks `"key":"value"` (JSON-ish) and `key=value` (form) shapes for the
|
|
42
|
-
* denylisted keys. Best-effort — never throws.
|
|
43
|
-
*/
|
|
44
|
-
function redactBodyStringFallback(body) {
|
|
45
|
-
let out = body;
|
|
46
|
-
for (const key of SENSITIVE_KEYS) {
|
|
47
|
-
// JSON-ish: "apiKey": "secret" -> "apiKey":"[REDACTED]"
|
|
48
|
-
out = out.replace(
|
|
49
|
-
new RegExp(`("${key}"\\s*:\\s*)"(?:[^"\\\\]|\\\\.)*"`, 'gi'),
|
|
50
|
-
`$1"${REDACTED}"`
|
|
51
|
-
);
|
|
52
|
-
// Form-urlencoded: apiKey=secret -> apiKey=[REDACTED]
|
|
53
|
-
out = out.replace(
|
|
54
|
-
new RegExp(`(${key}=)[^&\\s]*`, 'gi'),
|
|
55
|
-
`$1${REDACTED}`
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
return out;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Redact a serialized request `body` string. JSON bodies are parsed, deep-redacted
|
|
63
|
-
* and re-serialized; non-JSON / oversized bodies fall back to pattern masking.
|
|
64
|
-
* Never throws.
|
|
65
|
-
*/
|
|
66
|
-
function redactBodyString(body) {
|
|
67
|
-
if (body.length <= MAX_BODY_PARSE_LENGTH) {
|
|
68
|
-
try {
|
|
69
|
-
const parsed = JSON.parse(body);
|
|
70
|
-
if (parsed && typeof parsed === 'object') {
|
|
71
|
-
return JSON.stringify(redactValue(parsed, 0, new Set()));
|
|
72
|
-
}
|
|
73
|
-
} catch (_) {
|
|
74
|
-
// Not JSON — fall through to pattern masking below.
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return redactBodyStringFallback(body);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Deep-clone `value`, masking any denylisted key anywhere in the structure and
|
|
82
|
-
* redacting an embedded `body` string (the Lambda/API-Gateway convention).
|
|
83
|
-
* Returns a NEW object so the caller's data is never mutated; circular refs and
|
|
84
|
-
* excessive depth are handled defensively. Never throws (guarded by the public
|
|
85
|
-
* `redactSensitive`).
|
|
86
|
-
*/
|
|
87
|
-
function redactValue(value, depth, seen) {
|
|
88
|
-
if (value === null || typeof value !== 'object') {
|
|
89
|
-
return value;
|
|
90
|
-
}
|
|
91
|
-
if (depth > MAX_REDACT_DEPTH || seen.has(value)) {
|
|
92
|
-
return value;
|
|
93
|
-
}
|
|
94
|
-
seen.add(value);
|
|
95
|
-
|
|
96
|
-
if (Array.isArray(value)) {
|
|
97
|
-
return value.map((v) => redactValue(v, depth + 1, seen));
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const out = {};
|
|
101
|
-
for (const [k, v] of Object.entries(value)) {
|
|
102
|
-
if (isSensitiveKey(k)) {
|
|
103
|
-
out[k] = REDACTED;
|
|
104
|
-
} else if (k.toLowerCase() === 'body' && typeof v === 'string') {
|
|
105
|
-
out[k] = redactBodyString(v);
|
|
106
|
-
} else {
|
|
107
|
-
out[k] = redactValue(v, depth + 1, seen);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return out;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Framework-wide redaction applied to anything buffered for logging. Strips
|
|
115
|
-
* credential-bearing fields from objects (e.g. a buffered Lambda event) before
|
|
116
|
-
* they are serialized. Non-object arguments (the event name, plain strings) pass
|
|
117
|
-
* through untouched. Guaranteed not to throw.
|
|
118
|
-
* @param {*} value
|
|
119
|
-
* @returns {*}
|
|
120
|
-
*/
|
|
121
|
-
function redactSensitive(value) {
|
|
122
|
-
try {
|
|
123
|
-
return redactValue(value, 0, new Set());
|
|
124
|
-
} catch (_) {
|
|
125
|
-
// A logger must never break the request it is trying to describe.
|
|
126
|
-
return value;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
9
|
function debug(...messages) {
|
|
131
10
|
if (messages.length) {
|
|
132
11
|
const date = new Date();
|
|
@@ -146,11 +25,8 @@ function initDebugLog(...initMessages) {
|
|
|
146
25
|
// Hacky but fast way to empty an array.
|
|
147
26
|
logs.length = 0;
|
|
148
27
|
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
// choke point every handler passes its raw event through, so masking here
|
|
152
|
-
// protects both the buffered dump and the DEBUG_VERBOSE=1 immediate path.
|
|
153
|
-
debug(...initMessages.map(redactSensitive));
|
|
28
|
+
// Log initial event
|
|
29
|
+
debug(...initMessages);
|
|
154
30
|
}
|
|
155
31
|
|
|
156
32
|
function flushDebugLog(error) {
|
|
@@ -186,10 +62,4 @@ function flushDebugLog(error) {
|
|
|
186
62
|
}
|
|
187
63
|
}
|
|
188
64
|
|
|
189
|
-
module.exports = {
|
|
190
|
-
debug,
|
|
191
|
-
initDebugLog,
|
|
192
|
-
flushDebugLog,
|
|
193
|
-
redactSensitive,
|
|
194
|
-
SENSITIVE_KEYS,
|
|
195
|
-
};
|
|
65
|
+
module.exports = { debug, initDebugLog, flushDebugLog };
|
package/modules/module.js
CHANGED
|
@@ -148,9 +148,29 @@ class Module extends Delegate {
|
|
|
148
148
|
await this.deauthorize();
|
|
149
149
|
} else if (delegateString === this.api.DLGT_INVALID_AUTH) {
|
|
150
150
|
await this.markCredentialsInvalid(object);
|
|
151
|
+
} else if (delegateString === this.api.DLGT_CREDENTIAL_RELOAD) {
|
|
152
|
+
return this.reloadCredential();
|
|
151
153
|
}
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Re-reads the credential row from the database. The requester can then
|
|
158
|
+
* adopt a concurrent invocation's refresh and does not race it.
|
|
159
|
+
* @returns {Promise<Object|null>} The persisted token fields, or null
|
|
160
|
+
* when no credential row is available. The requester treats null as
|
|
161
|
+
* "nothing to adopt" and continues with its own refresh.
|
|
162
|
+
*/
|
|
163
|
+
async reloadCredential() {
|
|
164
|
+
if (!this.credential?.id) return null;
|
|
165
|
+
const freshFromDatabase =
|
|
166
|
+
await this.credentialRepository.findCredentialById(
|
|
167
|
+
this.credential.id
|
|
168
|
+
);
|
|
169
|
+
if (!freshFromDatabase) return null;
|
|
170
|
+
this.credential = freshFromDatabase;
|
|
171
|
+
return this.apiParamsFromCredential(freshFromDatabase);
|
|
172
|
+
}
|
|
173
|
+
|
|
154
174
|
async markCredentialsInvalid(diagnosticInfo = null) {
|
|
155
175
|
if (!this.credential) return;
|
|
156
176
|
|
|
@@ -60,9 +60,26 @@ class OAuth2Requester extends Requester {
|
|
|
60
60
|
this.DLGT_TOKEN_UPDATE = 'TOKEN_UPDATE';
|
|
61
61
|
/** @type {string} Delegate type for token deauthorization notifications */
|
|
62
62
|
this.DLGT_TOKEN_DEAUTHORIZED = 'TOKEN_DEAUTHORIZED';
|
|
63
|
+
/**
|
|
64
|
+
* @type {string} Delegate type that asks the Module for the stored
|
|
65
|
+
* credential. The requester can then adopt a concurrent invocation's
|
|
66
|
+
* refresh and does not race it. See _adoptNewerCredential.
|
|
67
|
+
*/
|
|
68
|
+
this.DLGT_CREDENTIAL_RELOAD = 'CREDENTIAL_RELOAD';
|
|
63
69
|
|
|
64
70
|
this.delegateTypes.push(this.DLGT_TOKEN_UPDATE);
|
|
65
71
|
this.delegateTypes.push(this.DLGT_TOKEN_DEAUTHORIZED);
|
|
72
|
+
this.delegateTypes.push(this.DLGT_CREDENTIAL_RELOAD);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Re-read delays after an invalid_grant, in ms. The winner's write
|
|
76
|
+
* can arrive after the loser's rejection (716 ms measured in
|
|
77
|
+
* production). Wait between re-reads before you decide that the
|
|
78
|
+
* credential is dead. Tests can inject other values.
|
|
79
|
+
*/
|
|
80
|
+
this.credentialReloadBackoffMs = params?.credentialReloadBackoffMs ?? [
|
|
81
|
+
500, 1000, 1500,
|
|
82
|
+
];
|
|
66
83
|
|
|
67
84
|
/** @type {string} OAuth grant type */
|
|
68
85
|
this.grant_type = get(params, 'grant_type', 'authorization_code');
|
|
@@ -295,6 +312,16 @@ class OAuth2Requester extends Requester {
|
|
|
295
312
|
* @returns {Promise<boolean>} True if refresh succeeded, false if failed
|
|
296
313
|
*/
|
|
297
314
|
async refreshAuth() {
|
|
315
|
+
// The wrapper runs this check before it calls refreshAuth(). Callers
|
|
316
|
+
// that reach this method directly skip the wrapper, so the check runs
|
|
317
|
+
// here for them. The guard keeps it at one read per refresh.
|
|
318
|
+
if (
|
|
319
|
+
!this._isInsideRefreshFlow() &&
|
|
320
|
+
(await this._adoptNewerCredential())
|
|
321
|
+
) {
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
298
325
|
try {
|
|
299
326
|
console.log('[Frigg] Starting token refresh', {
|
|
300
327
|
grant_type: this.grant_type,
|
|
@@ -310,7 +337,11 @@ class OAuth2Requester extends Requester {
|
|
|
310
337
|
refresh_token: this.refresh_token,
|
|
311
338
|
});
|
|
312
339
|
} else {
|
|
313
|
-
|
|
340
|
+
// getTokenFromClientCredentials() reports a failed token
|
|
341
|
+
// request itself and resolves to undefined. Without this
|
|
342
|
+
// check, the refresh counts as a success with no new token.
|
|
343
|
+
const tokenRes = await this.getTokenFromClientCredentials();
|
|
344
|
+
if (!tokenRes) return false;
|
|
314
345
|
}
|
|
315
346
|
console.log('[Frigg] Token refresh succeeded');
|
|
316
347
|
return true;
|
|
@@ -322,8 +353,21 @@ class OAuth2Requester extends Requester {
|
|
|
322
353
|
response_status: error?.response?.status,
|
|
323
354
|
response_data: error?.response?.data,
|
|
324
355
|
});
|
|
325
|
-
|
|
326
|
-
|
|
356
|
+
|
|
357
|
+
if (!this._isDefinitiveAuthRejection(error)) {
|
|
358
|
+
throw this._transportFailureError(error, moduleName);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (await this._adoptNewerCredentialWithBackoff()) return true;
|
|
362
|
+
|
|
363
|
+
// The provider rejected the grant, and the store has nothing
|
|
364
|
+
// newer. The credential is dead.
|
|
365
|
+
this.telemetry?.count?.('frigg.auth.refresh_race_lost', 1, {
|
|
366
|
+
module: this._telemetryModuleLabel(),
|
|
367
|
+
});
|
|
368
|
+
// Send the status only. The refresh body contains the
|
|
369
|
+
// client_secret, and FetchError puts the body in its message
|
|
370
|
+
// outside prod.
|
|
327
371
|
await this.notify(this.DLGT_INVALID_AUTH, {
|
|
328
372
|
statusCode: error?.statusCode,
|
|
329
373
|
});
|
|
@@ -331,6 +375,106 @@ class OAuth2Requester extends Requester {
|
|
|
331
375
|
}
|
|
332
376
|
}
|
|
333
377
|
|
|
378
|
+
/**
|
|
379
|
+
* A timeout, a 429, or a 5xx from the token endpoint says nothing about
|
|
380
|
+
* the credential. The error must stay retryable: the caller fails loudly
|
|
381
|
+
* (worker throw → SQS retry → DLQ) and does not flag a healthy
|
|
382
|
+
* credential. This is a fresh Error on purpose. Outside prod, the
|
|
383
|
+
* original message can contain the request body, and the body carries
|
|
384
|
+
* the client_secret.
|
|
385
|
+
*/
|
|
386
|
+
_transportFailureError(error, moduleName) {
|
|
387
|
+
const status =
|
|
388
|
+
error?.statusCode ?? error?.status ?? error?.response?.status;
|
|
389
|
+
const transportError = new Error(
|
|
390
|
+
`[Frigg] Token refresh transport failure for ${moduleName}` +
|
|
391
|
+
(status != null ? ` (status ${status})` : '')
|
|
392
|
+
);
|
|
393
|
+
transportError.statusCode = status;
|
|
394
|
+
transportError.isTokenRefreshTransportFailure = true;
|
|
395
|
+
return transportError;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* A definitive rejection can mean that another invocation consumed this
|
|
400
|
+
* refresh token first. That invocation's write can be unreadable for a
|
|
401
|
+
* short time. Re-read with a bounded backoff before you decide that the
|
|
402
|
+
* credential is dead.
|
|
403
|
+
*/
|
|
404
|
+
async _adoptNewerCredentialWithBackoff() {
|
|
405
|
+
for (const delayMs of this.credentialReloadBackoffMs) {
|
|
406
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
407
|
+
if (await this._adoptNewerCredential()) {
|
|
408
|
+
console.log(
|
|
409
|
+
'[Frigg] Adopted a newer credential after a refresh rejection',
|
|
410
|
+
{ module: this._telemetryModuleLabel() }
|
|
411
|
+
);
|
|
412
|
+
this.telemetry?.count?.('frigg.auth.refresh_race_recovered', 1, {
|
|
413
|
+
module: this._telemetryModuleLabel(),
|
|
414
|
+
});
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* True when the token endpoint refused the grant. RFC 6749 §5.2 sets
|
|
423
|
+
* the status: 400, or 401 for invalid_client. A 429 or a 5xx is never a
|
|
424
|
+
* verdict on the credential. Body markers are only a fallback for SDK
|
|
425
|
+
* errors that have no status code. Production FetchErrors have a
|
|
426
|
+
* sanitized body, so a marker cannot be the primary signal.
|
|
427
|
+
*/
|
|
428
|
+
_isDefinitiveAuthRejection(error) {
|
|
429
|
+
const status =
|
|
430
|
+
error?.statusCode ?? error?.status ?? error?.response?.status;
|
|
431
|
+
if (status !== undefined && status !== null) {
|
|
432
|
+
return status === 400 || status === 401;
|
|
433
|
+
}
|
|
434
|
+
const haystack = [
|
|
435
|
+
error?.message,
|
|
436
|
+
error?.body,
|
|
437
|
+
typeof error?.error === 'string' ? error.error : null,
|
|
438
|
+
error?.response?.data && JSON.stringify(error.response.data),
|
|
439
|
+
]
|
|
440
|
+
.filter(Boolean)
|
|
441
|
+
.join(' ');
|
|
442
|
+
return /\b(invalid_grant|invalid_client)\b/i.test(haystack);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Adopts the credential stored in the database if it is newer than the
|
|
447
|
+
* credential from the instance. The refresh token decides "newer": a
|
|
448
|
+
* provider can rotate it and return an identical access-token string.
|
|
449
|
+
* The reload only reads. A reload failure is not fatal: a database blip
|
|
450
|
+
* must not change the auth behavior.
|
|
451
|
+
*
|
|
452
|
+
* @returns {Promise<boolean>} True if the module adopted a newer
|
|
453
|
+
* credential.
|
|
454
|
+
*/
|
|
455
|
+
async _adoptNewerCredential() {
|
|
456
|
+
let stored = null;
|
|
457
|
+
try {
|
|
458
|
+
stored = await this.notify(this.DLGT_CREDENTIAL_RELOAD);
|
|
459
|
+
} catch (_) {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
if (!stored?.refresh_token) return false;
|
|
463
|
+
if (stored.refresh_token === this.refresh_token) return false;
|
|
464
|
+
|
|
465
|
+
if (stored.access_token) {
|
|
466
|
+
this.access_token = stored.access_token;
|
|
467
|
+
}
|
|
468
|
+
this.refresh_token = stored.refresh_token;
|
|
469
|
+
if (stored.accessTokenExpire !== undefined) {
|
|
470
|
+
this.accessTokenExpire = stored.accessTokenExpire;
|
|
471
|
+
}
|
|
472
|
+
if (stored.refreshTokenExpire !== undefined) {
|
|
473
|
+
this.refreshTokenExpire = stored.refreshTokenExpire;
|
|
474
|
+
}
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
|
|
334
478
|
/**
|
|
335
479
|
* Obtains tokens using the Resource Owner Password Credentials grant.
|
|
336
480
|
* Requires username and password to be set.
|