Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(oauth): bubble up new token when refreshing it #1163

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
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,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed these context.getFields() calls to simplify the stubbing (see the new stub only cares about the first and second call)

};
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
Loading