Skip to content

Commit

Permalink
feat: support refresh token apps
Browse files Browse the repository at this point in the history
  • Loading branch information
santese committed Nov 15, 2023
1 parent 9fde208 commit 96fa293
Show file tree
Hide file tree
Showing 8 changed files with 192 additions and 113 deletions.
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@
}
},
"dependencies": {
"axios": "1.6.0",
"axios": "1.6.2",
"tslib": "2.6.2"
},
"description": "",
"devDependencies": {
"@digitalroute/cz-conventional-changelog-for-jira": "8.0.1",
"@pliancy/eslint-config-ts": "1.1.0",
"@pliancy/semantic-release-config-npm": "2.2.0",
"@types/jest": "29.5.7",
"@types/node": "20.8.10",
"@types/jest": "29.5.8",
"@types/node": "20.9.0",
"commitizen": "4.3.0",
"cpy-cli": "5.0.0",
"husky": "8.0.3",
Expand Down
11 changes: 9 additions & 2 deletions src/lib/microsoft-partnercenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ import { Invoice } from './types/invoices.types'
import { OrderLineItem, OrderLineItemOptions, OrderResponse } from './types/orders.types'
import { Sku } from './types/sku.types'
import { Subscription } from './types/subscriptions.types'
import { createHttpAgent } from './utils/create-http-agent'
import { TokenManager, initializeHttpAndTokenManager } from './utils/http-token-manager'

export class MicrosoftPartnerCenter {
private readonly httpAgent: AxiosInstance
private readonly tokenManager: TokenManager
constructor(config: IPartnerCenterConfig) {
this.httpAgent = createHttpAgent(config)
const { agent, tokenManager } = initializeHttpAndTokenManager(config)
this.httpAgent = agent
this.tokenManager = tokenManager
}

async getRefreshToken() {
return this.tokenManager.getInitializedRefreshToken()
}

async getAllCustomers(): Promise<Customer[]> {
Expand Down
6 changes: 6 additions & 0 deletions src/lib/types/common.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ export interface IPartnerCenterConfig {
/** The maximum number of retries */
maximumRetries?: number
}
/**
* Callback function to update the refresh token in your database
*/
onUpdateRefreshToken?: (newRefreshToken: string) => void
}
export interface ClientAuth {
clientId: string
clientSecret: string
refreshToken?: string
}

export interface IOAuthResponse {
Expand All @@ -26,6 +31,7 @@ export interface IOAuthResponse {
not_before: string
resource: string
access_token: string
refresh_token?: string
}

export interface LinksBase {
Expand Down
69 changes: 0 additions & 69 deletions src/lib/utils/create-http-agent.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createHttpAgent } from './create-http-agent'
import { initializeHttpAndTokenManager } from './http-token-manager'
import mockAxios from 'jest-mock-axios'
import { AxiosInstance } from 'axios'

Expand All @@ -14,7 +14,8 @@ describe('HttpAgent', () => {
}

beforeEach(() => {
instance = createHttpAgent(conf)
const { agent } = initializeHttpAndTokenManager(conf)
instance = agent
jest.spyOn(mockAxios, 'create')
})

Expand Down
125 changes: 125 additions & 0 deletions src/lib/utils/http-token-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import axios, { AxiosRequestConfig } from 'axios'
import qs from 'querystring'
import { IOAuthResponse, IPartnerCenterConfig } from '../types/common.types'

export class TokenManager {
private accessToken = ''
private _refreshToken = ''
private reAuthed = false
private retry = 0

constructor(private config: IPartnerCenterConfig) {}

async getInitializedRefreshToken() {
if (!this.config.authentication.refreshToken) {
return null
}

if (!this._refreshToken) {
await this.authenticate()
}

return this._refreshToken
}

async getAccessToken() {
if (!this.accessToken) {
await this.authenticate()
}
return this.accessToken
}

async handleAuthenticationError(err: any, requestConfig: AxiosRequestConfig) {
const maxRetries = this.config.conflict?.maximumRetries ?? 3
const retryAfter = this.config.conflict?.retryOnConflictDelayMs ?? 1000

if (err.response?.status === 401 && !this.reAuthed) {
this.reAuthed = true
await this.authenticate()
requestConfig.headers = requestConfig.headers || {}
requestConfig.headers.authorization = `Bearer ${this.accessToken}`
return axios(requestConfig)
} else if (
err.response?.status === 409 &&
this.config?.conflict?.retryOnConflict &&
this.retry < maxRetries
) {
this.retry++
await new Promise((resolve) => setTimeout(resolve, retryAfter))
return axios(requestConfig)
}

this.retry = 0
this.reAuthed = false
throw err
}

private async authenticate() {
let authData = this.prepareAuthData()
try {
const res = await axios.post(
`https://login.microsoftonline.com/${this.config.partnerDomain}/oauth2/token`,
authData,
{
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
},
)

const body: IOAuthResponse = res.data
this.accessToken = body.access_token

if (body.refresh_token) {
this.config.authentication.refreshToken = body.refresh_token
this._refreshToken = body.refresh_token
}
} catch (error) {
throw new Error('Failed to authenticate with the Microsoft Partner Center.')
}
}

private prepareAuthData() {
if (this.config.authentication.refreshToken) {
return qs.stringify({
grant_type: 'refresh_token',
refresh_token: this.config.authentication.refreshToken,
client_id: this.config.authentication.clientId,
client_secret: this.config.authentication.clientSecret,
scope: 'https://api.partnercenter.microsoft.com/.default',
})
}
return qs.stringify({
grant_type: 'client_credentials',
resource: 'https://graph.windows.net',
client_id: this.config.authentication.clientId,
client_secret: this.config.authentication.clientSecret,
})
}

private isTokenExpired() {
// TODO: Implement token expiration check
return false
}
}

export function initializeHttpAndTokenManager(config: IPartnerCenterConfig) {
const baseURL = 'https://api.partnercenter.microsoft.com/v1/'
const tokenManager = new TokenManager(config)
const agent = axios.create({ baseURL, timeout: config.timeoutMs })

agent.interceptors.request.use(async (req) => {
req.headers.authorization = `Bearer ${await tokenManager.getAccessToken()}`
return req
})

agent.interceptors.response.use(
(res) => res,
async (err) => tokenManager.handleAuthenticationError(err, err.config),
)

return {
agent,
tokenManager,
}
}
59 changes: 34 additions & 25 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@


{
"compilerOptions": {
"lib": ["es2019", "es2020.promise", "es2020.bigint", "es2020.string"], // Node.js 12
"target": "es2019", // Node.js 12
"module": "commonjs",
"moduleResolution": "node",
"resolveJsonModule": true,
"declaration": true,
"pretty": true,
"newLine": "lf",
"checkJs": true,
"allowJs": true,
"esModuleInterop": true,
"removeComments": false,
"stripInternal": true,
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noEmitOnError": true,
"useDefineForClassFields": false,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "./dist",
"types": ["node", "jest"]
"lib": [
"es2019",
"es2020.promise",
"es2020.bigint",
"es2020.string"
], // Node.js 12
"target": "es2019", // Node.js 12
"module": "commonjs",
"moduleResolution": "node",
"resolveJsonModule": true,
"declaration": true,
"pretty": true,
"newLine": "lf",
"checkJs": true,
"allowJs": true,
"esModuleInterop": true,
"removeComments": false,
"stripInternal": true,
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noEmitOnError": true,
"useDefineForClassFields": false,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "./dist",
"types": [
"node",
"jest"
]
},
"include": ["src", "__mocks__"]
"include": [
"src",
"__mocks__"
]
}
24 changes: 12 additions & 12 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1280,10 +1280,10 @@
dependencies:
"@types/istanbul-lib-report" "*"

"@types/[email protected].7":
version "29.5.7"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.7.tgz#2c0dafe2715dd958a455bc10e2ec3e1ec47b5036"
integrity sha512-HLyetab6KVPSiF+7pFcUyMeLsx25LDNDemw9mGsJBkai/oouwrjTycocSDYopMEwFhN2Y4s9oPyOCZNofgSt2g==
"@types/[email protected].8":
version "29.5.8"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.8.tgz#ed5c256fe2bc7c38b1915ee5ef1ff24a3427e120"
integrity sha512-fXEFTxMV2Co8ZF5aYFJv+YeA08RTYJfhtN5c9JSv/mFEMe+xxjufCb+PHL+bJcMs/ebPUsBu+UNTEz+ydXrR6g==
dependencies:
expect "^29.0.0"
pretty-format "^29.0.0"
Expand All @@ -1303,10 +1303,10 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.3.1.tgz#24691fa2b0c3ec8c0d34bfcfd495edac5593ebb4"
integrity sha512-N87VuQi7HEeRJkhzovao/JviiqKjDKMVKxKMfUvSKw+MbkbW8R0nA3fi/MQhhlxV2fQ+2ReM+/Nt4efdrJx3zA==

"@types/node@20.8.10":
version "20.8.10"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.10.tgz#a5448b895c753ae929c26ce85cab557c6d4a365e"
integrity sha512-TlgT8JntpcbmKUFzjhsyhGfP2fsiz1Mv56im6enJ905xG1DAYesxJaeSbGqQmAw8OWPdhyJGhGSQGKRNJ45u9w==
"@types/node@20.9.0":
version "20.9.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.9.0.tgz#bfcdc230583aeb891cf51e73cfdaacdd8deae298"
integrity sha512-nekiGu2NDb1BcVofVcEKMIwzlx4NjHlcjhoxxKBNLtz15Y1z7MYf549DFvkHSId02Ax6kGwWntIBPC3l/JZcmw==
dependencies:
undici-types "~5.26.4"

Expand Down Expand Up @@ -1691,10 +1691,10 @@ aws4@^1.8.0:
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.12.0.tgz#ce1c9d143389679e253b314241ea9aa5cec980d3"
integrity sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==

[email protected].0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.0.tgz#f1e5292f26b2fd5c2e66876adc5b06cdbd7d2102"
integrity sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==
[email protected].2:
version "1.6.2"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.2.tgz#de67d42c755b571d3e698df1b6504cde9b0ee9f2"
integrity sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==
dependencies:
follow-redirects "^1.15.0"
form-data "^4.0.0"
Expand Down

0 comments on commit 96fa293

Please sign in to comment.