Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/microsoft-ads-refresh-token-rotation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'owox': minor
---

# Microsoft Ads refresh token rotation

Persist rotated Microsoft Ads refresh tokens returned by Microsoft OAuth responses without overwriting the original user-provided refresh token.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from 'zod';
import { MessageLogSchema } from './types/message-log.schema';
import { MessageStatusSchema } from './types/message-status.schema';
import { MessageStateSchema } from './types/message-state.schema';
import { MessageCredentialsUpdateSchema } from './types/message-credentials-update.schema';
import { MessageRequestedDateSchema } from './types/message-requested-date.schema';
import { MessageWarningSchema } from './types/message-warning.schema';
import { MessageUnknownSchema } from './types/message-unknown.schema';
Expand All @@ -14,6 +15,7 @@ export const ConnectorMessageSchema = z
MessageLogSchema,
MessageStatusSchema,
MessageStateSchema,
MessageCredentialsUpdateSchema,
MessageRequestedDateSchema,
MessageWarningSchema,
MessageUnknownSchema,
Expand All @@ -33,6 +35,10 @@ export const ConnectorMessageSchema = z
return `[STATE] ${data.date}`;
}

case ConnectorMessageType.CREDENTIALS_UPDATE: {
return `[CREDENTIALS] ${Object.keys(data.credentials).join(', ')}`;
}

case ConnectorMessageType.REQUESTED_DATE: {
const requestedDate = new Date(data.date).toLocaleString();
return `[REQUESTED_DATE] ${requestedDate}`;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { z } from 'zod';
import { ConnectorMessageType } from '../../../enums/connector-message-type-enum';

export const MessageCredentialsUpdateSchema = z.object({
type: z.literal(ConnectorMessageType.CREDENTIALS_UPDATE),
at: z.string(),
credentials: z.record(z.string(), z.unknown()),
});

export type MessageCredentialsUpdate = z.infer<typeof MessageCredentialsUpdateSchema>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Logger } from '@nestjs/common';
import { ConnectorMessageType } from '../../enums/connector-message-type-enum';
import { ConnectorMessageParserService } from './connector-message-parser.service';

describe('ConnectorMessageParserService', () => {
const createService = () => new ConnectorMessageParserService();

it('redacts malformed credential update messages parsed as unknown', () => {
const service = createService();

const result = service.parse(
'{"type":"updateCredentials","credentials":{"generated_refresh_token":"secret-token"'
);

expect(result.type).toBe(ConnectorMessageType.UNKNOWN);
expect(result.toFormattedString()).toContain('[REDACTED updateCredentials message]');
expect(result.toFormattedString()).not.toContain('secret-token');
});

it('redacts schema-invalid credential update messages parsed as unknown', () => {
const service = createService();

const result = service.parse(
JSON.stringify({
type: ConnectorMessageType.CREDENTIALS_UPDATE,
credentials: { generated_refresh_token: 'secret-token' },
})
);

expect(result.type).toBe(ConnectorMessageType.UNKNOWN);
expect(result.toFormattedString()).toContain('[REDACTED updateCredentials message]');
expect(result.toFormattedString()).not.toContain('secret-token');
});

it('redacts parsed json when credential content appears under another message type', () => {
const service = createService();
const warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);

try {
const result = service.parse(
JSON.stringify({
type: ConnectorMessageType.LOG,
message: 'hello',
credentials: { generated_refresh_token: 'secret-token' },
})
);

expect(result.type).toBe(ConnectorMessageType.UNKNOWN);
expect(JSON.stringify(warnSpy.mock.calls)).toContain('[REDACTED updateCredentials message]');
expect(JSON.stringify(warnSpy.mock.calls)).not.toContain('secret-token');
} finally {
warnSpy.mockRestore();
}
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConnectorMessage, ConnectorMessageSchema } from '../schemas/connector-message.schema';
import { ConnectorMessageType } from '../../enums/connector-message-type-enum';
// @ts-expect-error - Package lacks TypeScript declarations
import { Core } from '@owox/connectors';

const { GENERATED_REFRESH_TOKEN_CREDENTIAL_FIELD } = Core;

@Injectable()
export class ConnectorMessageParserService {
Expand All @@ -12,8 +16,8 @@ export class ConnectorMessageParserService {
const parsedMessage = ConnectorMessageSchema.safeParse(asJson);
if (!parsedMessage.success) {
this.logger.warn(`Schema validation failed for message:`, {
message: message,
parsedJson: asJson,
message: this.redactCredentialUpdateMessage(message),
parsedJson: this.redactCredentialUpdateJson(asJson),
errors: parsedMessage.error.errors,
});
return this.parseAsUnknown(message);
Expand All @@ -25,11 +29,56 @@ export class ConnectorMessageParserService {
}

private parseAsUnknown(message: string): ConnectorMessage {
const safeMessage = this.redactCredentialUpdateMessage(message).trim();

return {
type: ConnectorMessageType.UNKNOWN,
at: new Date().toISOString(),
message: message.trim(),
toFormattedString: () => `[UNKNOWN] ${message.trim()}`,
message: safeMessage,
toFormattedString: () => `[UNKNOWN] ${safeMessage}`,
};
}

private redactCredentialUpdateMessage(message: string): string {
if (
!message.includes('updateCredentials') &&
!message.includes(GENERATED_REFRESH_TOKEN_CREDENTIAL_FIELD)
) {
return message;
}

return '[REDACTED updateCredentials message]';
}

private redactCredentialUpdateJson(value: unknown): unknown {
if (!value || typeof value !== 'object') {
return value;
}

if (!this.hasCredentialUpdateContent(value)) {
return value;
}

return '[REDACTED updateCredentials message]';
}

private hasCredentialUpdateContent(value: unknown): boolean {
if (!value || typeof value !== 'object') {
return false;
}

if (Array.isArray(value)) {
return value.some(item => this.hasCredentialUpdateContent(item));
}

const objectValue = value as Record<string, unknown>;
if (
objectValue.type === ConnectorMessageType.CREDENTIALS_UPDATE ||
Object.prototype.hasOwnProperty.call(objectValue, GENERATED_REFRESH_TOKEN_CREDENTIAL_FIELD)
) {
return true;
}

return Object.values(objectValue).some(item => this.hasCredentialUpdateContent(item));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export enum ConnectorMessageType {
IS_IN_PROGRESS = 'isInProgress',
STATUS = 'updateCurrentStatus',
STATE = 'updateLastImportDate',
CREDENTIALS_UPDATE = 'updateCredentials',
REQUESTED_DATE = 'updateLastRequstedDate',
WARNING = 'addWarningToCurrentStatus',
UNKNOWN = 'unknown',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ describe('ConnectorCredentialInjectorService', () => {
id: 'cred-1',
projectId: 'proj-1',
connectorName: 'TestConnector',
credentials: { accessToken: 'token123' },
credentials: {
accessToken: 'token123',
generated_refresh_token: 'generated-refresh-token',
},
});
(connectorSourceCredentialsService.isExpired as jest.Mock).mockResolvedValue(false);
(connectorService.getItemByFieldPath as jest.Mock).mockResolvedValue({
Expand All @@ -59,6 +62,10 @@ describe('ConnectorCredentialInjectorService', () => {
const authType = result.AuthType as Record<string, Record<string, unknown>>;
expect(authType.oauth2).not.toHaveProperty('_source_credential_id');
expect(authType.oauth2.AccessToken).toBe('token123');
expect(authType.oauth2.GeneratedRefreshToken).toEqual({
value: 'generated-refresh-token',
});
expect(authType.oauth2).not.toHaveProperty('generated_refresh_token');
});

it('returns config unchanged when credential not found', async () => {
Expand Down Expand Up @@ -96,7 +103,10 @@ describe('ConnectorCredentialInjectorService', () => {
id: 'cred-1',
projectId: 'proj-1',
connectorName: 'TestConnector',
credentials: { accessToken: 'token123' },
credentials: {
accessToken: 'token123',
generated_refresh_token: 'generated-refresh-token',
},
});
(connectorSourceCredentialsService.isExpired as jest.Mock).mockResolvedValue(false);
(connectorService.getItemByFieldPath as jest.Mock).mockResolvedValue({
Expand All @@ -106,6 +116,8 @@ describe('ConnectorCredentialInjectorService', () => {
const result = await service.injectOAuthCredentials(config, 'TestConnector', 'proj-1');

expect(result.accessToken).toBe('token123');
expect(result.GeneratedRefreshToken).toEqual({ value: 'generated-refresh-token' });
expect(result).not.toHaveProperty('generated_refresh_token');
expect(result).not.toHaveProperty('_source_credential_id');
});
});
Expand Down Expand Up @@ -144,6 +156,34 @@ describe('ConnectorCredentialInjectorService', () => {
);
});

it('injects generated refresh token from externalized secrets', async () => {
const { service, connectorSourceCredentialsService, connectorSecretService } =
createService();
const config = { _secrets_id: 'secret-1', field1: 'value1' };
const secrets = {
'AuthType.oauth2.RefreshToken': 'original-refresh-token',
generated_refresh_token: 'generated-refresh-token',
};

(connectorSourceCredentialsService.getCredentialsById as jest.Mock).mockResolvedValue({
id: 'secret-1',
projectId: 'proj-1',
credentials: secrets,
});
(connectorSecretService.injectSecretsAtPaths as jest.Mock).mockImplementation(
() => undefined
);

const result = await service.injectSecrets(config, 'proj-1');

expect(result.GeneratedRefreshToken).toEqual({ value: 'generated-refresh-token' });
expect(result).not.toHaveProperty('generated_refresh_token');
expect(connectorSecretService.injectSecretsAtPaths).toHaveBeenCalledWith(
expect.objectContaining({ field1: 'value1' }),
{ 'AuthType.oauth2.RefreshToken': 'original-refresh-token' }
);
});

it('returns config when secrets entity not found', async () => {
const { service, connectorSourceCredentialsService } = createService();
const config = { _secrets_id: 'missing-secret', field1: 'value1' };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { Injectable, Logger } from '@nestjs/common';
import { ConnectorSourceCredentialsService } from './connector-source-credentials.service';
import { ConnectorService } from './connector.service';
import { ConnectorSecretService } from './connector-secret.service';
// @ts-expect-error - Package lacks TypeScript declarations
import { Core } from '@owox/connectors';

const { GENERATED_REFRESH_TOKEN_CREDENTIAL_FIELD, GENERATED_REFRESH_TOKEN_CONFIG_FIELD } = Core;

@Injectable()
export class ConnectorCredentialInjectorService {
Expand Down Expand Up @@ -91,13 +95,30 @@ export class ConnectorCredentialInjectorService {
this.logger.warn(
`No mapping found for OAuth field ${currentPath}. Using credentials directly.`
);
return { ...restObj, ...credentialsEntity.credentials };
const {
[GENERATED_REFRESH_TOKEN_CREDENTIAL_FIELD]: generatedRefreshToken,
...credentials
} = credentialsEntity.credentials;
return {
...restObj,
...credentials,
...(typeof generatedRefreshToken === 'string' && generatedRefreshToken
? { [GENERATED_REFRESH_TOKEN_CONFIG_FIELD]: { value: generatedRefreshToken } }
: {}),
};
}

const resolvedConfig: Record<string, unknown> = {};
for (const [key, mappingConfig] of Object.entries(mapping)) {
resolvedConfig[key] = this.resolveMapping(mappingConfig, credentialsEntity.credentials);
}
const generatedRefreshToken =
credentialsEntity.credentials[GENERATED_REFRESH_TOKEN_CREDENTIAL_FIELD];
if (typeof generatedRefreshToken === 'string' && generatedRefreshToken) {
resolvedConfig[GENERATED_REFRESH_TOKEN_CONFIG_FIELD] = {
value: generatedRefreshToken,
};
}

return { ...restObj, ...resolvedConfig };
} catch (error) {
Expand Down Expand Up @@ -205,8 +226,15 @@ export class ConnectorCredentialInjectorService {

const { _secrets_id: _, ...restConfig } = config;
const result = JSON.parse(JSON.stringify(restConfig)) as Record<string, unknown>;
const { [GENERATED_REFRESH_TOKEN_CREDENTIAL_FIELD]: generatedRefreshToken, ...credentials } =
secretsEntity.credentials;

this.connectorSecretService.injectSecretsAtPaths(result, secretsEntity.credentials);
this.connectorSecretService.injectSecretsAtPaths(result, credentials);
if (typeof generatedRefreshToken === 'string' && generatedRefreshToken) {
result[GENERATED_REFRESH_TOKEN_CONFIG_FIELD] = {
value: generatedRefreshToken,
};
}

return result;
} catch (error) {
Expand Down
Loading
Loading