Skip to content

Commit

Permalink
Merge pull request #1163 from forcedotcom/cd/fix-oauth-refresh-token
Browse files Browse the repository at this point in the history
fix(oauth): bubble up new token when refreshing it
  • Loading branch information
WillieRuemmele authored Jan 17, 2025
2 parents 0919f28 + b6bdd36 commit 73ffd0c
Show file tree
Hide file tree
Showing 5 changed files with 50 additions and 20 deletions.
2 changes: 1 addition & 1 deletion LICENSE.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright (c) 2024, Salesforce.com, Inc.
Copyright (c) 2025, Salesforce.com, Inc.
All rights reserved.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Expand Down
11 changes: 9 additions & 2 deletions src/org/authInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,16 +940,23 @@ export class AuthInfo extends AsyncOptionalCreatable<AuthInfo.Options> {
// A callback function for a connection to refresh an access token. This is used
// both for a JWT connection and an OAuth connection.
private async refreshFn(
conn: Connection,
_conn: Connection,
callback: (err: Nullable<Error | SfError>, accessToken?: string, res?: Record<string, unknown>) => Promise<void>
): Promise<void> {
this.logger.info('Access token has expired. Updating...');

try {
const fields = this.getFields(true);

// This method will request the new access token and save to the current AuthInfo instance (but don't persist them!).
await this.initAuthOptions(fields);
// Persist fields with refreshed access token to auth file.
await this.save();
return await callback(null, fields.accessToken);

// Pass new access token to the jsforce's session-refresh callback for proper propagation:
// https://jsforce.github.io/jsforce/types/session_refresh_delegate.SessionRefreshFunc.html
const { accessToken } = this.getFields(true);
return await callback(null, accessToken);
} catch (err) {
const error = err as Error;
if (error?.message?.includes('Data Not Available')) {
Expand Down
14 changes: 9 additions & 5 deletions src/org/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { asString, ensure, isString, JsonMap, Optional } from '@salesforce/ts-ty
import {
Connection as JSForceConnection,
ConnectionConfig,
HttpMethods,
HttpRequest,
QueryOptions,
QueryResult,
Expand Down Expand Up @@ -414,14 +413,19 @@ export class Connection<S extends Schema = Schema> extends JSForceConnection<S>
}

/**
* Executes a get request on the baseUrl to force an auth refresh
* Useful for the raw methods (request, requestRaw) that use the accessToken directly and don't handle refreshes
* Executes a HEAD request on the baseUrl to force an auth refresh.
* This is useful for the raw methods (request, requestRaw) that use the accessToken directly and don't handle refreshes.
*
* This method issues a request using the current access token to check if it is still valid.
* If the request returns 200, no refresh happens, and we keep the token.
* If it returns 401, jsforce will request a new token and set it in the connection instance.
*/

public async refreshAuth(): Promise<void> {
this.logger.debug('Refreshing auth for org.');
const requestInfo = {
const requestInfo: HttpRequest = {
url: this.baseUrl(),
method: 'GET' as HttpMethods,
method: 'HEAD',
};
await this.request(requestInfo);
}
Expand Down
9 changes: 7 additions & 2 deletions src/org/org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -792,13 +792,18 @@ export class Org extends AsyncOptionalCreatable<Org.Options> {
}

/**
* Refreshes the auth for this org's instance by calling HTTP GET on the baseUrl of the connection object.
* Executes a HEAD request on the baseUrl to force an auth refresh.
* This is useful for the raw methods (request, requestRaw) that use the accessToken directly and don't handle refreshes.
*
* This method issues a request using the current access token to check if it is still valid.
* If the request returns 200, no refresh happens, and we keep the token.
* If it returns 401, jsforce will request a new token and set it in the connection instance.
*/
public async refreshAuth(): Promise<void> {
this.logger.debug('Refreshing auth for org.');
const requestInfo: HttpRequest = {
url: this.getConnection().baseUrl(),
method: 'GET',
method: 'HEAD',
};

await this.getConnection().request(requestInfo);
Expand Down
34 changes: 24 additions & 10 deletions test/unit/org/authInfo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1096,14 +1096,25 @@ describe('AuthInfo', () => {

describe('refreshFn', () => {
it('should call init() and save()', async () => {
const refreshedToken = '123456789abc';

const context = {
getUsername: () => '',
getFields: (decrypt = false) => ({
loginUrl: testOrg.loginUrl,
clientId: testOrg.clientId,
privateKey: 'authInfoTest/jwt/server.key',
accessToken: decrypt ? testOrg.accessToken : testOrg.encryptedAccessToken,
}),
getFields: $$.SANDBOX.stub()
.onFirstCall()
.callsFake((decrypt = false) => ({
loginUrl: testOrg.loginUrl,
clientId: testOrg.clientId,
privateKey: 'authInfoTest/jwt/server.key',
accessToken: decrypt ? testOrg.accessToken : testOrg.encryptedAccessToken,
}))
.onSecondCall()
.callsFake((decrypt = false) => ({
loginUrl: testOrg.loginUrl,
clientId: testOrg.clientId,
privateKey: 'authInfoTest/jwt/server.key',
accessToken: decrypt ? refreshedToken : testOrg.encryptedAccessToken,
})),
initAuthOptions: $$.SANDBOX.stub(),
save: $$.SANDBOX.stub(),
logger: $$.TEST_LOGGER,
Expand All @@ -1119,15 +1130,18 @@ describe('AuthInfo', () => {
expect(context.initAuthOptions.called, 'Should have called AuthInfo.initAuthOptions() during refreshFn()').to.be
.true;
const expectedInitArgs = {
loginUrl: context.getFields().loginUrl,
clientId: context.getFields().clientId,
privateKey: context.getFields().privateKey,
loginUrl: testOrg.loginUrl,
clientId: testOrg.clientId,
privateKey: testOrg.privateKey,
accessToken: testOrg.accessToken,
};
expect(context.initAuthOptions.firstCall.args[0]).to.deep.equal(expectedInitArgs);
expect(context.save.called, 'Should have called AuthInfo.save() during refreshFn()').to.be.true;
expect(testCallback.called, 'Should have called the callback passed to refreshFn()').to.be.true;
expect(testCallback.firstCall.args[1]).to.equal(testOrg.accessToken);
expect(
testCallback.firstCall.args[1],
'Should have passed the new access token to the refreshFn callback'
).to.equal(refreshedToken);
});

it('should path.resolve jwtkeyfilepath', async () => {
Expand Down

0 comments on commit 73ffd0c

Please sign in to comment.