Support Microsoft Ads refresh token rotation#1308
Conversation
zapolsky
left a comment
There was a problem hiding this comment.
🔴 Blocked issues — see inline comments. Posted by automated OWOX review (multi-agent + comprehensive + architecture passes). Both findings were verified by reading the actual connector platform / persistence code.
| this.connectorSecretService.injectSecretsAtPaths(result, secretsEntity.credentials); | ||
| this.connectorSecretService.injectSecretsAtPaths(result, credentials); | ||
| if (typeof generatedRefreshToken === 'string' && generatedRefreshToken) { | ||
| result.GeneratedRefreshToken = generatedRefreshToken; |
There was a problem hiding this comment.
[Bug — token rotation is silently broken] GeneratedRefreshToken is injected as a bare string here (and identically at lines 102 and 113), but the Microsoft Ads connector reads it as this.config.GeneratedRefreshToken?.value (packages/connectors/src/Sources/MicrosoftAds/Source.js:607). The connector platform's AbstractConfig.addParameter (packages/connectors/src/Core/AbstractConfig.js:77) assigns a bare string straight to this[name] — it does not wrap it in { value: ... }. So this.config.GeneratedRefreshToken.value === undefined, and the connector falls back to the original (already single-use-invalidated) refresh token. The rotated token is effectively dropped on every subsequent run, so the whole feature is a no-op (and auth will break once the provider invalidates the original token).
Fix: inject the { value } shape used by every other param, in all three branches: result.GeneratedRefreshToken = { value: generatedRefreshToken }; (and resolvedConfig.GeneratedRefreshToken = { value: generatedRefreshToken };, and { GeneratedRefreshToken: { value: generatedRefreshToken } }).
There was a problem hiding this comment.
Refuted on re-verification — please disregard this finding (and do not apply its suggested fix).
The trace above stops two hops short. The full chain: injection happens before DTO construction (connector-source-config.service.ts:33-43 — injectSecrets / injectOAuthCredentials first, then new SourceConfigDto(...)) → the SourceConfigDto constructor runs every top-level value through recursiveValueTransform (packages/connectors/src/Core/Dto/ConfigDto.js:26), which wraps bare strings into { value: ... } → that already-wrapped shape is what gets serialized into OW_CONFIG (connector-process-spawner.service.ts:48) → the runner (connector-runner.js:109-116) hands pre-wrapped values to AbstractConfig.addParameter, so it has nothing left to wrap. At runtime this.config.GeneratedRefreshToken?.value (externalized path) and this.config.AuthType?.items?.GeneratedRefreshToken?.value (OAuth path) both resolve correctly — the feature is live.
Applying the suggested fix (result.GeneratedRefreshToken = { value: ... } in the injector) would double-wrap ({value: {value: 'tok'}}) and actually break the feature.
The real, verified bug in this area is different: the in-run update writes top-level while the OAuth path reads AuthType.items.* first — see the new inline comment on packages/connectors/src/Sources/MicrosoftAds/Source.js:319.
| }); | ||
| break; | ||
| case ConnectorMessageType.CREDENTIALS_UPDATE: | ||
| credentialUpdates = { ...(credentialUpdates ?? {}), ...message.credentials }; |
There was a problem hiding this comment.
[Security — unconstrained write to encrypted credentials] message.credentials is typed z.record(z.string(), z.unknown()) (message-credentials-update.schema.ts:7) — i.e. arbitrary keys/values — and is persisted wholesale via updateCredentialFields → existing.credentials = { ...existing.credentials, ...updates } (connector-source-credentials.service.ts:237), with no allow-list. A misbehaving or compromised connector process (it parses arbitrary external HTTP responses) can therefore emit e.g. {type:'updateCredentials', credentials:{ ClientSecret: '...' }} and overwrite any field in the encrypted ConnectorSourceCredentials row. The rotation feature only ever needs generated_refresh_token.
Fix: restrict the persisted keys to an explicit allow-list (just generated_refresh_token) before calling updateCredentialFields, and tighten the schema from z.record(z.string(), z.unknown()) to the single expected field.
🔵 Review notes (non-blocking)Suggestions and questions — none of these block merge (the blockers are in the inline review).
Automated OWOX review · multi-agent + comprehensive + architecture passes. |
zapolsky
left a comment
There was a problem hiding this comment.
🔴 Blocked issues — see inline comments (3 new, adversarially verified with evidence chains). These should be addressed before merge, but this review does not formally block; a human owns the merge decision. Note: the earlier inline comment on connector-credential-injector.service.ts:227 (bare-string injection) was re-verified and is refuted — see the reply in that thread. Posted by automated OWOX review.
| } | ||
|
|
||
| existing.credentials = secrets; | ||
| existing.credentials = { |
There was a problem hiding this comment.
[Bug — verified] Stale generated_refresh_token permanently shadows a fresh user-provided token; nothing ever clears it.
Evidence chain: Source.js:273 always prefers generatedRefreshToken || originalRefreshToken with no invalid_grant fallback (lines 309–315 only retry across scopes) → this preservation branch (lines 212–217) re-adds generated_refresh_token on every user secret save — incoming secrets always omit it, because mask() never exposes it (connector-secret.service.ts:495-497) and injectSecretsAtPaths skips it (connector-secret.service.ts:171-173), so it can never be re-extracted from a user edit → the only writer is updateCredentialFields after a successful run (connector-executor.service.ts:336-344), so once runs fail the row can never heal itself.
Trigger: the generated token is revoked at Microsoft (consent revoked, password reset, 90-day inactivity) → runs fail → user re-authorizes and pastes a fresh RefreshToken → the dead generated token is preserved and still preferred → permanent failure with no user-visible cause (the field is hidden from the UI by design) and no remedy short of deleting the configuration item or editing the DB row.
Fix: clear generated_refresh_token here when an incoming refresh-token secret value differs from the stored one (i.e. the user re-credentialed; a ********** mask doesn't count). Defense in depth: in Source.js getAccessToken, on invalid_grant with the generated token retry once with the original token.
| ); | ||
|
|
||
| if (success) { | ||
| if (credentialUpdates) { |
There was a problem hiding this comment.
[Bug — verified] Rotated token is discarded when the run fails — persistence must not be gated on success.
Evidence chain: Microsoft rotates the refresh token at the token exchange near the start of the run (Source.js:294-306, emit at 316-318) → credentialUpdates is accumulated (connector-executor.service.ts:261-263) → but persisted only inside if (success) (lines 335-344). Any later failure — storage error, API quota, abort, non-zero exit (catch at line 354) — silently drops the freshest token.
Impact: the whole feature exists to survive the sliding inactivity window; a data mart failing for two weeks near token expiry loses exactly the tokens that would have kept the credential alive. Correctness currently rests on Microsoft's undocumented family-of-tokens semantics (superseded tokens staying valid); if MS tightens rotation, every failed run permanently bricks the credential.
Fix: persist credentialUpdates regardless of run outcome — eagerly in the CREDENTIALS_UPDATE message handler (the way REQUESTED_DATE already persists at lines 240-259), or in the finally block. Credential persistence is a side effect that is valid the moment the message is received, not a run outcome.
| } | ||
| } | ||
|
|
||
| _setGeneratedRefreshToken(refreshToken) { |
There was a problem hiding this comment.
[Bug — verified] Write/read asymmetry: the in-run token update writes to a slot the OAuth read path never prefers, so subsequent token exchanges in the same run reuse the pre-rotation token.
Evidence chain: for OAuth-credentialed configs the backend injects the token under AuthType (connector-credential-injector.service.ts:111-114) → SourceConfigDto.recursiveValueTransform (packages/connectors/src/Core/Dto/ConfigDto.js:8-27) shapes it into AuthType.items.GeneratedRefreshToken.{value} at runtime → the read at Source.js:273 prefers AuthType?.items?.GeneratedRefreshToken?.value → but this setter writes only top-level this.config.GeneratedRefreshToken → the second getAccessToken() call (Source.js:381, :619 — e.g. after the 60-min access-token expiry) redeems the stale pre-rotation token again.
The externalized _secrets_id path injects top-level (connector-credential-injector.service.ts:226-228) where write and read agree — which is exactly the only path the new unit test covers, so the asymmetry is invisible to the test suite. If Microsoft enforces strict one-time rotation for the token family, the second exchange fails with invalid_grant mid-run; combined with the if (success) gating the rotated token is then also discarded.
Fix: mirror the read precedence here — when this.config.AuthType?.items?.GeneratedRefreshToken exists, update it too; or flip the read precedence at line 273 so the top-level (in-run, always freshest) slot wins.
🔵 Review notes (non-blocking) — follow-up passDeep multi-agent re-review of the same head. New findings only — the earlier notes (lost-update race, executor test coverage, changeset) still stand and are not repeated here.
Automated OWOX review · worktree multi-agent passes · all blockers adversarially verified. |
| existing.credentials = secrets; | ||
| existing.credentials = { | ||
| ...secrets, | ||
| ...(existing.credentials.generated_refresh_token !== undefined && |
There was a problem hiding this comment.
🔴 Blocking: This preserves the previous generated_refresh_token whenever regular secrets are updated, but Microsoft Ads now prefers generated_refresh_token over the configured RefreshToken. If a user replaces the refresh token, the stale generated token is kept and continues to be used, so the credential update cannot take effect. Please drop or regenerate generated_refresh_token when the underlying refresh-token secret changes.
🤖 Reviewed by Codex (GPT-5.5)
| ); | ||
|
|
||
| if (success) { | ||
| if (credentialUpdates) { |
There was a problem hiding this comment.
🔴 Blocking: Captured credential updates are saved only when the overall configuration finishes successfully. Microsoft Ads emits this update immediately after the token endpoint returns a rotated refresh token, before later data fetch/storage work can fail; if that later work fails, the new token is discarded and the next run starts from stale credentials. Persist captured credential updates independently of the run success status once the connector process has emitted them.
🤖 Reviewed by Codex (GPT-5.5)
| dataMart.projectId, | ||
| credentials | ||
| ); | ||
| } catch (error) { |
There was a problem hiding this comment.
🔴 Blocking: saveConnectorCredentials swallows persistence failures after logging. If updateCredentialFields fails, the run is still marked successful even though the rotated refresh token was not persisted, leaving the next run to fail with no visible signal in run status. Let this error fail the configuration or add it to configErrors so the run does not report success when credential persistence failed.
🤖 Reviewed by Codex (GPT-5.5)
| if (success) { | ||
| if (credentialUpdates) { | ||
| await this.saveConnectorCredentials( | ||
| config as Record<string, unknown>, |
There was a problem hiding this comment.
🟡 Non-blocking: This uses the original config to locate the credential row even though refreshCredentialsForConfig may return refreshedConfig with a replacement _source_credential_id. If a connector refreshes to a new credential id and then emits updateCredentials, the update will be written to the stale credential row. Use refreshedConfig when resolving the credential id or include the target id in the update message.
🤖 Reviewed by Codex (GPT-5.5)
…or microsoft connector
…nhance credential handling
4a6e831 to
00d9c83
Compare
|
✅ No blocking issues found — looks ready to approve (a maintainer should give the formal approval). Full worktree re-review of head Prior blockers — all resolved ✅The five previously-flagged blockers (and the overlapping Codex/@max-voloshyn findings) are genuinely fixed in this head, so the earlier
Verified clean as part of this pass: no token reaches run logs / API responses / persisted definitions (CREDENTIALS_UPDATE is accumulated in-memory and never pushed to 🔵 Review notes (non-blocking)
Automated OWOX review · worktree multi-agent passes · all candidate blockers adversarially verified (0 confirmed, prior 5 confirmed fixed). |
updateCredentials.generated_refresh_tokeninconnector_source_credentials.generated_refresh_tokenas runtime-onlyGeneratedRefreshToken, and Microsoft Ads uses it before falling back to the original refresh token.updateCredentialsmessages with an allowlist: onlygenerated_refresh_tokencan be saved from runtime messages.generated_refresh_tokenfor externalized secret configs, but kept it hidden from API responses._secrets_idor_source_credential_id; legacy inline configs without credential references are skipped quietly.