fetch-mw-oauth2
Advanced tools
Comparing version
@@ -1,2 +0,2 @@ | ||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.fetchMwOAuth2=t():e.fetchMwOAuth2=t()}(self,(function(){return(()=>{"use strict";var e={909:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.encode=void 0,t.encode=function(e){return btoa(e)}},70:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0});class o extends Error{constructor(e,t,o){super(e),this.oauth2Code=t,this.httpCode=o}}t.default=o},267:function(e,t,o){var n=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const r=o(909),s=n(o(70)),i=o(517);async function c(e,t){return e.headers.set("Authorization","Bearer "+t),await fetch(e)}t.default=class{constructor(e){this.options=e,this.token={accessToken:e.accessToken||"",expiresAt:e.accessToken?null:0,refreshToken:e.refreshToken||null}}async fetch(e,t){const o=new Request(e,t);let n=await this.getAccessToken(),r=await c(o.clone(),n);return r.ok||401!==r.status||(n=await this.refreshToken(),r=await c(o,n),!r.ok&&401===r.status&&this.options.onAuthError&&this.options.onAuthError(new Error("Authentication failed with 401 error"))),r}async fetchMw(e,t){let o=await this.getAccessToken(),n=e.clone();n.headers.set("Authorization","Bearer "+o);let r=await t(n);return r.ok||401!==r.status||(o=await this.refreshToken(),n=e.clone(),n.headers.set("Authorization","Bearer "+o),r=await t(n)),r}async getOptions(){const e=await this.getToken();return{clientId:this.options.clientId,grantType:void 0,accessToken:e.accessToken,refreshToken:e.refreshToken||void 0,tokenEndpoint:this.options.tokenEndpoint}}async getToken(){return await this.getAccessToken(),this.token}async getAccessToken(){return null===this.token.expiresAt||this.token.expiresAt>Date.now()?this.token.accessToken:this.refreshToken()}async refreshToken(){let e;const t=this.token;if(t.refreshToken)e={grant_type:"refresh_token",refresh_token:t.refreshToken},void 0===this.options.clientSecret&&(e.client_id=this.options.clientId);else switch(this.options.grantType){case"client_credentials":e={grant_type:"client_credentials"},this.options.scope&&(e.scope=this.options.scope.join(" "));break;case"password":e={grant_type:"password",username:this.options.userName,password:this.options.password},this.options.scope&&(e.scope=this.options.scope.join(" "));break;case"authorization_code":e={grant_type:"authorization_code",code:this.options.code,redirect_uri:this.options.redirectUri,client_id:this.options.clientId,code_verifier:this.options.codeVerifier};break;default:throw new Error("Unknown grantType: "+this.options.grantType)}const o={"Content-Type":"application/x-www-form-urlencoded"};if(void 0!==this.options.clientSecret){const e=r.encode(this.options.clientId+":"+this.options.clientSecret);o.Authorization="Basic "+e}const n=await fetch(this.options.tokenEndpoint,{method:"POST",headers:o,body:i.objToQueryString(e)}),c=await n.json();if(!n.ok){if("refresh_token"===e.grant_type&&this.options.grantType)return this.token={accessToken:"",expiresAt:0,refreshToken:null},this.getAccessToken();let t="OAuth2 error "+c.error+".";throw c.error_description&&(t+=" "+c.error_description),new s.default(t,c.error,401)}return this.token={accessToken:c.access_token,expiresAt:c.expires_in?Date.now()+1e3*c.expires_in:null,refreshToken:c.refresh_token?c.refresh_token:null},this.options.onTokenUpdate&&this.options.onTokenUpdate(this.token),this.token.accessToken}}},22:function(e,t,o){var n=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.OAuth2Error=t.OAuth2=t.fetchMwOAuth2=t.default=void 0;var r=o(267);Object.defineProperty(t,"default",{enumerable:!0,get:function(){return n(r).default}}),Object.defineProperty(t,"fetchMwOAuth2",{enumerable:!0,get:function(){return n(r).default}}),Object.defineProperty(t,"OAuth2",{enumerable:!0,get:function(){return n(r).default}});var s=o(70);Object.defineProperty(t,"OAuth2Error",{enumerable:!0,get:function(){return n(s).default}})},517:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.objToQueryString=void 0,t.objToQueryString=function(e){return Object.entries(e).map((([e,t])=>void 0===t?"":encodeURIComponent(e)+"="+encodeURIComponent(t))).join("&")}}},t={};return function o(n){if(t[n])return t[n].exports;var r=t[n]={exports:{}};return e[n].call(r.exports,r,r.exports,o),r.exports}(22)})()})); | ||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.fetchMwOAuth2=t():e.fetchMwOAuth2=t()}(self,(function(){return(()=>{"use strict";var e={909:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.encode=void 0,t.encode=function(e){return btoa(e)}},70:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0});class r extends Error{constructor(e,t,r){super(e),this.oauth2Code=t,this.httpCode=r}}t.default=r},267:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0});const n=r(517);t.default=class{constructor(e,t){if(!e.grantType&&!t&&!e.accessToken)throw new Error("If no grantType is specified, a token must be provided");this.options=e,e.accessToken&&(console.warn("[fetch-mw-oauth2] Specifying accessToken via the options argument in the constructor of OAuth2 is deprecated. Please supply the options in the second argument. Backwards compatability will be removed in a future version of this library"),t={accessToken:e.accessToken,refreshToken:e.refreshToken||null,expiresAt:null}),this.token=t||{accessToken:"",expiresAt:null,refreshToken:null},this.activeRefresh=null,this.refreshTimer=null,this.scheduleRefresh()}async fetch(e,t){const r=new Request(e,t);return this.fetchMw(r,(e=>fetch(e)))}async fetchMw(e,t){const r=await this.getAccessToken();let n=e.clone();n.headers.set("Authorization","Bearer "+r);let o=await t(n);return o.ok||401!==o.status||(await this.refreshToken(),n=e.clone(),n.headers.set("Authorization","Bearer "+this.token.accessToken),o=await t(n)),o}async getToken(){return await this.getAccessToken(),this.token}async getAccessToken(){return null===this.token.expiresAt||this.token.expiresAt>Date.now()||await this.refreshToken(),this.token.accessToken}async refreshToken(){if(this.activeRefresh)return this.activeRefresh;this.activeRefresh=n.refreshToken(this.options,this.token);try{const e=await this.activeRefresh;return this.token=e,this.scheduleRefresh(),e}finally{this.activeRefresh=null}}scheduleRefresh(){this.refreshTimer&&(clearTimeout(this.refreshTimer),this.refreshTimer=null),this.token.expiresAt&&this.token.refreshToken&&(this.token.expiresAt-Date.now()<12e4||(this.refreshTimer=setTimeout((async()=>{try{await this.refreshToken()}catch(e){console.error("[fetch-mw-oauth2] error while doing a background OAuth2 auto-refresh",e)}}))))}}},22:function(e,t,r){var n=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.OAuth2Error=t.OAuth2=t.fetchMwOAuth2=t.default=void 0;var o=r(267);Object.defineProperty(t,"default",{enumerable:!0,get:function(){return n(o).default}}),Object.defineProperty(t,"fetchMwOAuth2",{enumerable:!0,get:function(){return n(o).default}}),Object.defineProperty(t,"OAuth2",{enumerable:!0,get:function(){return n(o).default}});var s=r(70);Object.defineProperty(t,"OAuth2Error",{enumerable:!0,get:function(){return n(s).default}})},517:function(e,t,r){var n=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.refreshToken=t.objToQueryString=void 0;const o=r(909),s=n(r(70));function i(e){return Object.entries(e).map((([e,t])=>void 0===t?"":encodeURIComponent(e)+"="+encodeURIComponent(t))).join("&")}t.objToQueryString=i,t.refreshToken=async function e(t,r){let n;const c=r;if(null==c?void 0:c.refreshToken)n={grant_type:"refresh_token",refresh_token:c.refreshToken},void 0===t.clientSecret&&(n.client_id=t.clientId);else switch(t.grantType){case"client_credentials":n={grant_type:"client_credentials"},t.scope&&(n.scope=t.scope.join(" "));break;case"password":n={grant_type:"password",username:t.userName,password:t.password},t.scope&&(n.scope=t.scope.join(" "));break;case"authorization_code":n={grant_type:"authorization_code",code:t.code,redirect_uri:t.redirectUri,client_id:t.clientId,code_verifier:t.codeVerifier};break;default:throw"string"==typeof t.grantType?new Error("Unknown grantType: "+t.grantType):new Error('Cannot obtain an access token if no "grantType" is specified')}const a={"Content-Type":"application/x-www-form-urlencoded"};if(void 0!==t.clientSecret){const e=o.encode(t.clientId+":"+t.clientSecret);a.Authorization="Basic "+e}const u=await fetch(t.tokenEndpoint,{method:"POST",headers:a,body:i(n)}),h=await u.json();if(!u.ok){if("refresh_token"===n.grant_type&&t.grantType)return e(t,null);let r="OAuth2 error "+h.error+".";throw h.error_description&&(r+=" "+h.error_description),new s.default(r,h.error,401)}const f={accessToken:h.access_token,expiresAt:h.expires_in?Date.now()+1e3*h.expires_in:null,refreshToken:h.refresh_token?h.refresh_token:null};return t.onTokenUpdate&&t.onTokenUpdate(f),f}}},t={};return function r(n){if(t[n])return t[n].exports;var o=t[n]={exports:{}};return e[n].call(o.exports,o,o.exports,r),o.exports}(22)})()})); | ||
//# sourceMappingURL=fetch-mw-oauth2.min.js.map |
Changelog | ||
========= | ||
0.7.0 (2020-11-30) | ||
------------------ | ||
* Ensure that only 1 refresh operation will happen in parallel. If there are | ||
multiple things triggering the refresh, all will wait for the first one | ||
to finish. | ||
* Automatically schedule a refresh operation 1 minute before the access token | ||
expires, if the expiry time is known. | ||
* BC Break: If a token is known when setting up OAuth2, this now needs to be | ||
passed as the second argument. The old behavior still works but will emit | ||
a warning, and will be removed in a future release. | ||
* 'OAuth2Token' type is now exported. | ||
0.6.1 (2020-11-19) | ||
@@ -5,0 +19,0 @@ ------------------ |
@@ -1,7 +0,18 @@ | ||
import { OAuth2Options, Token } from './types'; | ||
import { OAuth2Options as Options, OAuth2Token as Token } from './types'; | ||
export default class OAuth2 { | ||
options: OAuth2Options; | ||
options: Options; | ||
token: Token; | ||
constructor(options: OAuth2Options); | ||
/** | ||
* Keeping track of an active refreshToken operation. | ||
* | ||
* This will allow us to ensure only 1 such operation happens at any | ||
* given time. | ||
*/ | ||
private activeRefresh; | ||
/** | ||
* Timer trigger for the next automated refresh | ||
*/ | ||
private refreshTimer; | ||
constructor(options: Options & Partial<Token>, token?: Token); | ||
/** | ||
* Does a fetch request and adds a Bearer / access token. | ||
@@ -23,13 +34,2 @@ * | ||
/** | ||
* After authenticating, this functions returns a set of options that may be | ||
* used when authenticating the next time. | ||
* | ||
* You might for example want to store this in LocalStorage, allowing your | ||
* application to remember any refresh / access tokens for next time. | ||
* | ||
* The result of this function can be used as the constructor argument for | ||
* this object. | ||
*/ | ||
getOptions(): Promise<OAuth2Options>; | ||
/** | ||
* Returns current token information. | ||
@@ -53,3 +53,4 @@ * | ||
*/ | ||
refreshToken(): Promise<string>; | ||
refreshToken(): Promise<Token>; | ||
private scheduleRefresh; | ||
} |
"use strict"; | ||
var __importDefault = (this && this.__importDefault) || function (mod) { | ||
return (mod && mod.__esModule) ? mod : { "default": mod }; | ||
}; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
const base64_1 = require("./base64"); | ||
const error_1 = __importDefault(require("./error")); | ||
const util_1 = require("./util"); | ||
class OAuth2 { | ||
constructor(options) { | ||
constructor(options, token) { | ||
if (!options.grantType && !token && !options.accessToken) { | ||
throw new Error('If no grantType is specified, a token must be provided'); | ||
} | ||
this.options = options; | ||
this.token = { | ||
accessToken: options.accessToken || '', | ||
// If there was an accessToken we want to mark it as _not_ expired. | ||
// If there wasn't an access token we pretend it immediately expired. | ||
expiresAt: options.accessToken ? null : 0, | ||
refreshToken: options.refreshToken || null | ||
// Backwards compatibility | ||
if (options.accessToken) { | ||
// eslint-disable-next-line no-console | ||
console.warn('[fetch-mw-oauth2] Specifying accessToken via the options argument ' + | ||
'in the constructor of OAuth2 is deprecated. Please supply the ' + | ||
'options in the second argument. Backwards compatability will be ' + | ||
'removed in a future version of this library'); | ||
token = { | ||
accessToken: options.accessToken, | ||
refreshToken: options.refreshToken || null, | ||
expiresAt: null, | ||
}; | ||
} | ||
this.token = token || { | ||
accessToken: '', | ||
expiresAt: null, | ||
refreshToken: null | ||
}; | ||
this.activeRefresh = null; | ||
this.refreshTimer = null; | ||
this.scheduleRefresh(); | ||
} | ||
@@ -31,13 +43,3 @@ /** | ||
const request = new Request(input, init); | ||
let accessToken = await this.getAccessToken(); | ||
let response = await requestWithBearerToken(request.clone(), accessToken); | ||
if (!response.ok && response.status === 401) { | ||
accessToken = await this.refreshToken(); | ||
// We will try one more time | ||
response = await requestWithBearerToken(request, accessToken); | ||
if (!response.ok && response.status === 401 && this.options.onAuthError) { | ||
this.options.onAuthError(new Error('Authentication failed with 401 error')); | ||
} | ||
} | ||
return response; | ||
return this.fetchMw(request, req => fetch(req)); | ||
} | ||
@@ -52,3 +54,3 @@ /** | ||
async fetchMw(request, next) { | ||
let accessToken = await this.getAccessToken(); | ||
const accessToken = await this.getAccessToken(); | ||
let authenticatedRequest = request.clone(); | ||
@@ -58,5 +60,5 @@ authenticatedRequest.headers.set('Authorization', 'Bearer ' + accessToken); | ||
if (!response.ok && response.status === 401) { | ||
accessToken = await this.refreshToken(); | ||
await this.refreshToken(); | ||
authenticatedRequest = request.clone(); | ||
authenticatedRequest.headers.set('Authorization', 'Bearer ' + accessToken); | ||
authenticatedRequest.headers.set('Authorization', 'Bearer ' + this.token.accessToken); | ||
response = await next(authenticatedRequest); | ||
@@ -67,22 +69,2 @@ } | ||
/** | ||
* After authenticating, this functions returns a set of options that may be | ||
* used when authenticating the next time. | ||
* | ||
* You might for example want to store this in LocalStorage, allowing your | ||
* application to remember any refresh / access tokens for next time. | ||
* | ||
* The result of this function can be used as the constructor argument for | ||
* this object. | ||
*/ | ||
async getOptions() { | ||
const token = await this.getToken(); | ||
return { | ||
clientId: this.options.clientId, | ||
grantType: undefined, | ||
accessToken: token.accessToken, | ||
refreshToken: token.refreshToken || undefined, | ||
tokenEndpoint: this.options.tokenEndpoint, | ||
}; | ||
} | ||
/** | ||
* Returns current token information. | ||
@@ -113,3 +95,4 @@ * | ||
} | ||
return this.refreshToken(); | ||
await this.refreshToken(); | ||
return this.token.accessToken; | ||
} | ||
@@ -120,96 +103,46 @@ /** | ||
async refreshToken() { | ||
// The request body for the OAuth2 token endpoint | ||
let body; | ||
const previousToken = this.token; | ||
if (previousToken.refreshToken) { | ||
body = { | ||
grant_type: 'refresh_token', | ||
refresh_token: previousToken.refreshToken | ||
}; | ||
if (this.options.clientSecret !== undefined) { | ||
// If there is no secret, it means we need to send the clientId along | ||
// in the body. | ||
body.client_id = this.options.clientId; | ||
} | ||
if (this.activeRefresh) { | ||
// If we are currently already doing this operation, | ||
// make sure we don't do it twice in parallel. | ||
return this.activeRefresh; | ||
} | ||
else { | ||
switch (this.options.grantType) { | ||
case 'client_credentials': | ||
body = { | ||
grant_type: 'client_credentials', | ||
}; | ||
if (this.options.scope) { | ||
body.scope = this.options.scope.join(' '); | ||
} | ||
break; | ||
case 'password': | ||
body = { | ||
grant_type: 'password', | ||
username: this.options.userName, | ||
password: this.options.password, | ||
}; | ||
if (this.options.scope) { | ||
body.scope = this.options.scope.join(' '); | ||
} | ||
break; | ||
case 'authorization_code': | ||
body = { | ||
grant_type: 'authorization_code', | ||
code: this.options.code, | ||
redirect_uri: this.options.redirectUri, | ||
client_id: this.options.clientId, | ||
code_verifier: this.options.codeVerifier, | ||
}; | ||
break; | ||
default: | ||
throw new Error('Unknown grantType: ' + this.options.grantType); | ||
} | ||
this.activeRefresh = util_1.refreshToken(this.options, this.token); | ||
try { | ||
const token = await this.activeRefresh; | ||
this.token = token; | ||
this.scheduleRefresh(); | ||
return token; | ||
} | ||
const headers = { | ||
'Content-Type': 'application/x-www-form-urlencoded', | ||
}; | ||
if (this.options.clientSecret !== undefined) { | ||
const basicAuthStr = base64_1.encode(this.options.clientId + ':' + this.options.clientSecret); | ||
headers.Authorization = 'Basic ' + basicAuthStr; | ||
finally { | ||
// Make sure we clear the current refresh operation. | ||
this.activeRefresh = null; | ||
} | ||
const authResult = await fetch(this.options.tokenEndpoint, { | ||
method: 'POST', | ||
headers, | ||
body: util_1.objToQueryString(body), | ||
}); | ||
const jsonResult = await authResult.json(); | ||
if (!authResult.ok) { | ||
// If we failed with a refresh_token grant_type, we're going to make one | ||
// more attempt doing a full re-auth | ||
if (body.grant_type === 'refresh_token' && this.options.grantType) { | ||
// Wiping out all old token info | ||
this.token = { | ||
accessToken: '', | ||
expiresAt: 0, | ||
refreshToken: null, | ||
}; | ||
return this.getAccessToken(); | ||
} | ||
scheduleRefresh() { | ||
if (this.refreshTimer) { | ||
clearTimeout(this.refreshTimer); | ||
this.refreshTimer = null; | ||
} | ||
if (!this.token.expiresAt || !this.token.refreshToken) { | ||
// If we don't know when the token expires, or don't have a refresh_token, don't bother. | ||
return; | ||
} | ||
const expiresIn = this.token.expiresAt - Date.now(); | ||
// We only schedule this event if it happens more than 2 minutes in the future. | ||
if (expiresIn < 120 * 1000) { | ||
return; | ||
} | ||
// Schedule 1 minute before expiry | ||
this.refreshTimer = setTimeout(async () => { | ||
try { | ||
await this.refreshToken(); | ||
} | ||
let errorMessage = 'OAuth2 error ' + jsonResult.error + '.'; | ||
if (jsonResult.error_description) { | ||
errorMessage += ' ' + jsonResult.error_description; | ||
catch (err) { | ||
// eslint-disable-next-line no-console | ||
console.error('[fetch-mw-oauth2] error while doing a background OAuth2 auto-refresh', err); | ||
} | ||
throw new error_1.default(errorMessage, jsonResult.error, 401); | ||
} | ||
this.token = { | ||
accessToken: jsonResult.access_token, | ||
expiresAt: jsonResult.expires_in ? Date.now() + (jsonResult.expires_in * 1000) : null, | ||
refreshToken: jsonResult.refresh_token ? jsonResult.refresh_token : null, | ||
}; | ||
if (this.options.onTokenUpdate) { | ||
this.options.onTokenUpdate(this.token); | ||
} | ||
return this.token.accessToken; | ||
}); | ||
} | ||
} | ||
exports.default = OAuth2; | ||
async function requestWithBearerToken(request, accessToken) { | ||
request.headers.set('Authorization', 'Bearer ' + accessToken); | ||
return await fetch(request); | ||
} | ||
//# sourceMappingURL=fetch-wrapper.js.map |
export { default as default, default as fetchMwOAuth2, default as OAuth2 } from './fetch-wrapper'; | ||
export { OAuth2Options } from './types'; | ||
export { OAuth2Options, OAuth2Token } from './types'; | ||
export { default as OAuth2Error } from './error'; |
/** | ||
* Token information | ||
*/ | ||
export declare type Token = { | ||
export declare type OAuth2Token = { | ||
/** | ||
* OAuth2 Access Token | ||
*/ | ||
accessToken: string; | ||
/** | ||
* When the Access Token expires. | ||
* | ||
* This is expressed as a unix timestamp in milliseconds. | ||
*/ | ||
expiresAt: number | null; | ||
/** | ||
* OAuth2 refresh token | ||
*/ | ||
refreshToken: string | null; | ||
@@ -39,15 +50,5 @@ }; | ||
/** | ||
* If there's a previously valid access token, use this. | ||
* | ||
* If specified, it won't use the standard OAuth2 flow unless the token is invalid. | ||
*/ | ||
accessToken?: string; | ||
/** | ||
* Previously obtained refresh token (if any) | ||
*/ | ||
refreshToken?: string; | ||
/** | ||
* Callback to trigger when a new access/refresh token pair was obtained. | ||
*/ | ||
onTokenUpdate?: (token: Token) => void; | ||
onTokenUpdate?: (token: OAuth2Token) => void; | ||
/** | ||
@@ -83,15 +84,5 @@ * If authentication fails without a chance of recovery, this gets triggered. | ||
/** | ||
* If there's a previously valid access token, use this. | ||
* | ||
* If specified, it won't use the standard OAuth2 flow unless the token is invalid. | ||
*/ | ||
accessToken?: string; | ||
/** | ||
* Previously obtained refresh token (if any) | ||
*/ | ||
refreshToken?: string; | ||
/** | ||
* Callback to trigger when a new access/refresh token pair was obtained. | ||
*/ | ||
onTokenUpdate?: (token: Token) => void; | ||
onTokenUpdate?: (token: OAuth2Token) => void; | ||
/** | ||
@@ -130,15 +121,5 @@ * If authentication fails without a chance of recovery, this gets triggered. | ||
/** | ||
* If there's a previously valid access token, use this. | ||
* | ||
* If specified, it won't use the standard OAuth2 flow unless the token is invalid. | ||
*/ | ||
accessToken?: string; | ||
/** | ||
* Previously obtained refresh token (if any) | ||
*/ | ||
refreshToken?: string; | ||
/** | ||
* Callback to trigger when a new access/refresh token pair was obtained. | ||
*/ | ||
onTokenUpdate?: (token: Token) => void; | ||
onTokenUpdate?: (token: OAuth2Token) => void; | ||
/** | ||
@@ -171,13 +152,5 @@ * If authentication fails without a chance of recovery, this gets triggered. | ||
/** | ||
* Previously obtained access token | ||
*/ | ||
accessToken: string; | ||
/** | ||
* Previously obtained refresh token (if any) | ||
*/ | ||
refreshToken?: string; | ||
/** | ||
* Callback to trigger when a new access/refresh token pair was obtained. | ||
*/ | ||
onTokenUpdate?: (token: Token) => void; | ||
onTokenUpdate?: (token: OAuth2Token) => void; | ||
/** | ||
@@ -184,0 +157,0 @@ * If authentication fails without a chance of recovery, this gets triggered. |
@@ -0,1 +1,2 @@ | ||
import { OAuth2Token, OAuth2Options } from './types'; | ||
/** | ||
@@ -8,1 +9,6 @@ * A simple querystring.stringify alternative, so we don't need to include | ||
}): string; | ||
/** | ||
* This function either obtains a new access token, or refreshes an old | ||
* one. | ||
*/ | ||
export declare function refreshToken(options: OAuth2Options, token: OAuth2Token | null): Promise<OAuth2Token>; |
101
dist/util.js
"use strict"; | ||
var __importDefault = (this && this.__importDefault) || function (mod) { | ||
return (mod && mod.__esModule) ? mod : { "default": mod }; | ||
}; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.objToQueryString = void 0; | ||
exports.refreshToken = exports.objToQueryString = void 0; | ||
const base64_1 = require("./base64"); | ||
const error_1 = __importDefault(require("./error")); | ||
/** | ||
@@ -20,2 +25,96 @@ * A simple querystring.stringify alternative, so we don't need to include | ||
exports.objToQueryString = objToQueryString; | ||
/** | ||
* This function either obtains a new access token, or refreshes an old | ||
* one. | ||
*/ | ||
async function refreshToken(options, token) { | ||
// The request body for the OAuth2 token endpoint | ||
let body; | ||
const previousToken = token; | ||
if (previousToken === null || previousToken === void 0 ? void 0 : previousToken.refreshToken) { | ||
body = { | ||
grant_type: 'refresh_token', | ||
refresh_token: previousToken.refreshToken | ||
}; | ||
if (options.clientSecret === undefined) { | ||
// If there is no secret, it means we need to send the clientId along | ||
// in the body. | ||
body.client_id = options.clientId; | ||
} | ||
} | ||
else { | ||
switch (options.grantType) { | ||
case 'client_credentials': | ||
body = { | ||
grant_type: 'client_credentials', | ||
}; | ||
if (options.scope) { | ||
body.scope = options.scope.join(' '); | ||
} | ||
break; | ||
case 'password': | ||
body = { | ||
grant_type: 'password', | ||
username: options.userName, | ||
password: options.password, | ||
}; | ||
if (options.scope) { | ||
body.scope = options.scope.join(' '); | ||
} | ||
break; | ||
case 'authorization_code': | ||
body = { | ||
grant_type: 'authorization_code', | ||
code: options.code, | ||
redirect_uri: options.redirectUri, | ||
client_id: options.clientId, | ||
code_verifier: options.codeVerifier, | ||
}; | ||
break; | ||
default: | ||
if (typeof options.grantType === 'string') { | ||
throw new Error('Unknown grantType: ' + options.grantType); | ||
} | ||
else { | ||
throw new Error('Cannot obtain an access token if no "grantType" is specified'); | ||
} | ||
break; | ||
} | ||
} | ||
const headers = { | ||
'Content-Type': 'application/x-www-form-urlencoded', | ||
}; | ||
if (options.clientSecret !== undefined) { | ||
const basicAuthStr = base64_1.encode(options.clientId + ':' + options.clientSecret); | ||
headers.Authorization = 'Basic ' + basicAuthStr; | ||
} | ||
const authResult = await fetch(options.tokenEndpoint, { | ||
method: 'POST', | ||
headers, | ||
body: objToQueryString(body), | ||
}); | ||
const jsonResult = await authResult.json(); | ||
if (!authResult.ok) { | ||
// If we failed with a refresh_token grant_type, we're going to make one | ||
// more attempt doing a full re-auth | ||
if (body.grant_type === 'refresh_token' && options.grantType) { | ||
return refreshToken(options, null); | ||
} | ||
let errorMessage = 'OAuth2 error ' + jsonResult.error + '.'; | ||
if (jsonResult.error_description) { | ||
errorMessage += ' ' + jsonResult.error_description; | ||
} | ||
throw new error_1.default(errorMessage, jsonResult.error, 401); | ||
} | ||
const newToken = { | ||
accessToken: jsonResult.access_token, | ||
expiresAt: jsonResult.expires_in ? Date.now() + (jsonResult.expires_in * 1000) : null, | ||
refreshToken: jsonResult.refresh_token ? jsonResult.refresh_token : null, | ||
}; | ||
if (options.onTokenUpdate) { | ||
options.onTokenUpdate(newToken); | ||
} | ||
return newToken; | ||
} | ||
exports.refreshToken = refreshToken; | ||
//# sourceMappingURL=util.js.map |
{ | ||
"name": "fetch-mw-oauth2", | ||
"version": "0.6.1", | ||
"version": "0.7.0", | ||
"description": "Fetch middleware to add OAuth2 support", | ||
@@ -25,10 +25,10 @@ "main": "dist/index.js", | ||
"devDependencies": { | ||
"@types/node": "^12.19.6", | ||
"@typescript-eslint/eslint-plugin": "^4.8.1", | ||
"@typescript-eslint/parser": "^4.8.1", | ||
"@types/node": "^12.19.8", | ||
"@typescript-eslint/eslint-plugin": "^4.9.0", | ||
"@typescript-eslint/parser": "^4.9.0", | ||
"awesome-typescript-loader": "^5.2.1", | ||
"eslint": "^7.13.0", | ||
"eslint": "^7.14.0", | ||
"node-fetch": "^2.6.1", | ||
"typescript": "^4.1.2", | ||
"webpack": "^5.6.0", | ||
"webpack": "^5.9.0", | ||
"webpack-cli": "^4.2.0" | ||
@@ -35,0 +35,0 @@ }, |
@@ -33,5 +33,6 @@ # fetch-mw-oauth2 | ||
clientSecret: '...', // Optional in some cases | ||
tokenEndpoint: 'https://auth.example.org/token', | ||
}, { | ||
accessToken: '...', | ||
refreshToken: '...', | ||
tokenEndpoint: 'https://auth.example.org/token', | ||
}); | ||
@@ -38,0 +39,0 @@ |
@@ -1,22 +0,59 @@ | ||
import { encode as base64Encode } from './base64'; | ||
import OAuthError from './error'; | ||
import { AccessTokenRequest, OAuth2Options, Token } from './types'; | ||
import { objToQueryString } from './util'; | ||
import { | ||
OAuth2Options as Options, | ||
OAuth2Token as Token | ||
} from './types'; | ||
import { refreshToken } from './util'; | ||
export default class OAuth2 { | ||
options: OAuth2Options; | ||
options: Options; | ||
token: Token; | ||
constructor(options: OAuth2Options) { | ||
/** | ||
* Keeping track of an active refreshToken operation. | ||
* | ||
* This will allow us to ensure only 1 such operation happens at any | ||
* given time. | ||
*/ | ||
private activeRefresh: Promise<Token> | null; | ||
/** | ||
* Timer trigger for the next automated refresh | ||
*/ | ||
private refreshTimer: number | null; | ||
constructor(options: Options & Partial<Token>, token?: Token) { | ||
if (!options.grantType && !token && !options.accessToken) { | ||
throw new Error('If no grantType is specified, a token must be provided'); | ||
} | ||
this.options = options; | ||
this.token = { | ||
accessToken: options.accessToken || '', | ||
// If there was an accessToken we want to mark it as _not_ expired. | ||
// If there wasn't an access token we pretend it immediately expired. | ||
expiresAt: options.accessToken ? null : 0, | ||
refreshToken: options.refreshToken || null | ||
// Backwards compatibility | ||
if (options.accessToken) { | ||
// eslint-disable-next-line no-console | ||
console.warn( | ||
'[fetch-mw-oauth2] Specifying accessToken via the options argument ' + | ||
'in the constructor of OAuth2 is deprecated. Please supply the ' + | ||
'options in the second argument. Backwards compatability will be ' + | ||
'removed in a future version of this library'); | ||
token = { | ||
accessToken: options.accessToken, | ||
refreshToken: options.refreshToken || null, | ||
expiresAt: null, | ||
}; | ||
} | ||
this.token = token || { | ||
accessToken: '', | ||
expiresAt: null, | ||
refreshToken: null | ||
}; | ||
this.activeRefresh = null; | ||
this.refreshTimer = null; | ||
this.scheduleRefresh(); | ||
} | ||
@@ -37,20 +74,7 @@ | ||
let accessToken = await this.getAccessToken(); | ||
return this.fetchMw( | ||
request, | ||
req => fetch(req) | ||
); | ||
let response = await requestWithBearerToken(request.clone(), accessToken); | ||
if (!response.ok && response.status === 401) { | ||
accessToken = await this.refreshToken(); | ||
// We will try one more time | ||
response = await requestWithBearerToken(request, accessToken); | ||
if (!response.ok && response.status === 401 && this.options.onAuthError) { | ||
this.options.onAuthError(new Error('Authentication failed with 401 error')); | ||
} | ||
} | ||
return response; | ||
} | ||
@@ -67,3 +91,3 @@ | ||
let accessToken = await this.getAccessToken(); | ||
const accessToken = await this.getAccessToken(); | ||
@@ -76,6 +100,6 @@ let authenticatedRequest = request.clone(); | ||
accessToken = await this.refreshToken(); | ||
await this.refreshToken(); | ||
authenticatedRequest = request.clone(); | ||
authenticatedRequest.headers.set('Authorization', 'Bearer ' + accessToken); | ||
authenticatedRequest.headers.set('Authorization', 'Bearer ' + this.token.accessToken); | ||
response = await next(authenticatedRequest); | ||
@@ -89,26 +113,2 @@ | ||
/** | ||
* After authenticating, this functions returns a set of options that may be | ||
* used when authenticating the next time. | ||
* | ||
* You might for example want to store this in LocalStorage, allowing your | ||
* application to remember any refresh / access tokens for next time. | ||
* | ||
* The result of this function can be used as the constructor argument for | ||
* this object. | ||
*/ | ||
async getOptions(): Promise<OAuth2Options> { | ||
const token = await this.getToken(); | ||
return { | ||
clientId: this.options.clientId, | ||
grantType: undefined, | ||
accessToken: token.accessToken, | ||
refreshToken: token.refreshToken || undefined, | ||
tokenEndpoint: this.options.tokenEndpoint, | ||
}; | ||
} | ||
/** | ||
* Returns current token information. | ||
@@ -146,3 +146,4 @@ * | ||
return this.refreshToken(); | ||
await this.refreshToken(); | ||
return this.token.accessToken; | ||
@@ -154,116 +155,55 @@ } | ||
*/ | ||
async refreshToken(): Promise<string> { | ||
async refreshToken(): Promise<Token> { | ||
// The request body for the OAuth2 token endpoint | ||
let body: AccessTokenRequest; | ||
if (this.activeRefresh) { | ||
// If we are currently already doing this operation, | ||
// make sure we don't do it twice in parallel. | ||
return this.activeRefresh; | ||
} | ||
const previousToken = this.token; | ||
this.activeRefresh = refreshToken(this.options, this.token); | ||
if (previousToken.refreshToken) { | ||
body = { | ||
grant_type: 'refresh_token', | ||
refresh_token: previousToken.refreshToken | ||
}; | ||
if ((this.options as any).clientSecret === undefined) { | ||
// If there is no secret, it means we need to send the clientId along | ||
// in the body. | ||
body.client_id = this.options.clientId; | ||
} | ||
try { | ||
const token = await this.activeRefresh; | ||
this.token = token; | ||
this.scheduleRefresh(); | ||
return token; | ||
} finally { | ||
// Make sure we clear the current refresh operation. | ||
this.activeRefresh = null; | ||
} | ||
} else { | ||
} | ||
switch (this.options.grantType) { | ||
private scheduleRefresh() { | ||
case 'client_credentials': | ||
body = { | ||
grant_type: 'client_credentials', | ||
}; | ||
if (this.options.scope) { | ||
body.scope = this.options.scope.join(' '); | ||
} | ||
break; | ||
case 'password': | ||
body = { | ||
grant_type: 'password', | ||
username: this.options.userName, | ||
password: this.options.password, | ||
}; | ||
if (this.options.scope) { | ||
body.scope = this.options.scope.join(' '); | ||
} | ||
break; | ||
case 'authorization_code' : | ||
body = { | ||
grant_type: 'authorization_code', | ||
code: this.options.code, | ||
redirect_uri: this.options.redirectUri, | ||
client_id: this.options.clientId, | ||
code_verifier: this.options.codeVerifier, | ||
}; | ||
break; | ||
default : | ||
throw new Error('Unknown grantType: ' + this.options.grantType); | ||
} | ||
if (this.refreshTimer) { | ||
clearTimeout(this.refreshTimer); | ||
this.refreshTimer = null; | ||
} | ||
if (!this.token.expiresAt || !this.token.refreshToken) { | ||
// If we don't know when the token expires, or don't have a refresh_token, don't bother. | ||
return; | ||
} | ||
const headers: {[s: string]: string} = { | ||
'Content-Type' : 'application/x-www-form-urlencoded', | ||
}; | ||
const expiresIn = this.token.expiresAt - Date.now(); | ||
if ((this.options as any).clientSecret !== undefined) { | ||
const basicAuthStr = base64Encode(this.options.clientId + ':' + (this.options as any).clientSecret); | ||
headers.Authorization = 'Basic ' + basicAuthStr; | ||
// We only schedule this event if it happens more than 2 minutes in the future. | ||
if (expiresIn < 120*1000) { | ||
return; | ||
} | ||
const authResult = await fetch(this.options.tokenEndpoint, { | ||
method: 'POST', | ||
headers, | ||
body: objToQueryString(body), | ||
// Schedule 1 minute before expiry | ||
this.refreshTimer = setTimeout(async () => { | ||
try { | ||
await this.refreshToken(); | ||
} catch (err) { | ||
// eslint-disable-next-line no-console | ||
console.error('[fetch-mw-oauth2] error while doing a background OAuth2 auto-refresh', err); | ||
} | ||
}); | ||
const jsonResult = await authResult.json(); | ||
if (!authResult.ok) { | ||
// If we failed with a refresh_token grant_type, we're going to make one | ||
// more attempt doing a full re-auth | ||
if (body.grant_type === 'refresh_token' && this.options.grantType) { | ||
// Wiping out all old token info | ||
this.token = { | ||
accessToken: '', | ||
expiresAt: 0, | ||
refreshToken: null, | ||
}; | ||
return this.getAccessToken(); | ||
} | ||
let errorMessage = 'OAuth2 error ' + jsonResult.error + '.'; | ||
if (jsonResult.error_description) { | ||
errorMessage += ' ' + jsonResult.error_description; | ||
} | ||
throw new OAuthError(errorMessage, jsonResult.error, 401); | ||
} | ||
this.token = { | ||
accessToken: jsonResult.access_token, | ||
expiresAt: jsonResult.expires_in ? Date.now() + (jsonResult.expires_in * 1000) : null, | ||
refreshToken: jsonResult.refresh_token ? jsonResult.refresh_token : null, | ||
}; | ||
if (this.options.onTokenUpdate) { | ||
this.options.onTokenUpdate(this.token); | ||
} | ||
return this.token.accessToken; | ||
} | ||
} | ||
async function requestWithBearerToken(request: Request, accessToken: string) { | ||
request.headers.set('Authorization', 'Bearer ' + accessToken); | ||
return await fetch(request); | ||
} |
@@ -8,3 +8,4 @@ export { | ||
export { | ||
OAuth2Options | ||
OAuth2Options, | ||
OAuth2Token | ||
} from './types'; | ||
@@ -11,0 +12,0 @@ |
/** | ||
* Token information | ||
*/ | ||
export type Token = { | ||
export type OAuth2Token = { | ||
/** | ||
* OAuth2 Access Token | ||
*/ | ||
accessToken: string, | ||
/** | ||
* When the Access Token expires. | ||
* | ||
* This is expressed as a unix timestamp in milliseconds. | ||
*/ | ||
expiresAt: number | null, | ||
/** | ||
* OAuth2 refresh token | ||
*/ | ||
refreshToken: string | null, | ||
@@ -47,17 +61,5 @@ }; | ||
/** | ||
* If there's a previously valid access token, use this. | ||
* | ||
* If specified, it won't use the standard OAuth2 flow unless the token is invalid. | ||
*/ | ||
accessToken?: string, | ||
/** | ||
* Previously obtained refresh token (if any) | ||
*/ | ||
refreshToken?: string, | ||
/** | ||
* Callback to trigger when a new access/refresh token pair was obtained. | ||
*/ | ||
onTokenUpdate?: (token: Token) => void, | ||
onTokenUpdate?: (token: OAuth2Token) => void, | ||
@@ -100,17 +102,5 @@ /** | ||
/** | ||
* If there's a previously valid access token, use this. | ||
* | ||
* If specified, it won't use the standard OAuth2 flow unless the token is invalid. | ||
*/ | ||
accessToken?: string, | ||
/** | ||
* Previously obtained refresh token (if any) | ||
*/ | ||
refreshToken?: string, | ||
/** | ||
* Callback to trigger when a new access/refresh token pair was obtained. | ||
*/ | ||
onTokenUpdate?: (token: Token) => void, | ||
onTokenUpdate?: (token: OAuth2Token) => void, | ||
@@ -156,17 +146,5 @@ /** | ||
/** | ||
* If there's a previously valid access token, use this. | ||
* | ||
* If specified, it won't use the standard OAuth2 flow unless the token is invalid. | ||
*/ | ||
accessToken?: string, | ||
/** | ||
* Previously obtained refresh token (if any) | ||
*/ | ||
refreshToken?: string, | ||
/** | ||
* Callback to trigger when a new access/refresh token pair was obtained. | ||
*/ | ||
onTokenUpdate?: (token: Token) => void, | ||
onTokenUpdate?: (token: OAuth2Token) => void, | ||
@@ -204,15 +182,5 @@ /** | ||
/** | ||
* Previously obtained access token | ||
*/ | ||
accessToken: string, | ||
/** | ||
* Previously obtained refresh token (if any) | ||
*/ | ||
refreshToken?: string, | ||
/** | ||
* Callback to trigger when a new access/refresh token pair was obtained. | ||
*/ | ||
onTokenUpdate?: (token: Token) => void, | ||
onTokenUpdate?: (token: OAuth2Token) => void, | ||
@@ -252,2 +220,1 @@ /** | ||
}; | ||
120
src/util.ts
@@ -0,1 +1,9 @@ | ||
import { | ||
AccessTokenRequest, | ||
OAuth2Token, | ||
OAuth2Options, | ||
} from './types'; | ||
import { encode as base64Encode } from './base64'; | ||
import OAuthError from './error'; | ||
/** | ||
@@ -18,1 +26,113 @@ * A simple querystring.stringify alternative, so we don't need to include | ||
} | ||
/** | ||
* This function either obtains a new access token, or refreshes an old | ||
* one. | ||
*/ | ||
export async function refreshToken(options: OAuth2Options, token: OAuth2Token | null): Promise<OAuth2Token> { | ||
// The request body for the OAuth2 token endpoint | ||
let body: AccessTokenRequest; | ||
const previousToken = token; | ||
if (previousToken?.refreshToken) { | ||
body = { | ||
grant_type: 'refresh_token', | ||
refresh_token: previousToken.refreshToken | ||
}; | ||
if ((options as any).clientSecret === undefined) { | ||
// If there is no secret, it means we need to send the clientId along | ||
// in the body. | ||
body.client_id = options.clientId; | ||
} | ||
} else { | ||
switch (options.grantType) { | ||
case 'client_credentials': | ||
body = { | ||
grant_type: 'client_credentials', | ||
}; | ||
if (options.scope) { | ||
body.scope = options.scope.join(' '); | ||
} | ||
break; | ||
case 'password': | ||
body = { | ||
grant_type: 'password', | ||
username: options.userName, | ||
password: options.password, | ||
}; | ||
if (options.scope) { | ||
body.scope = options.scope.join(' '); | ||
} | ||
break; | ||
case 'authorization_code' : | ||
body = { | ||
grant_type: 'authorization_code', | ||
code: options.code, | ||
redirect_uri: options.redirectUri, | ||
client_id: options.clientId, | ||
code_verifier: options.codeVerifier, | ||
}; | ||
break; | ||
default : | ||
if (typeof options.grantType === 'string') { | ||
throw new Error('Unknown grantType: ' + options.grantType); | ||
} else { | ||
throw new Error('Cannot obtain an access token if no "grantType" is specified'); | ||
} | ||
break; | ||
} | ||
} | ||
const headers: {[s: string]: string} = { | ||
'Content-Type' : 'application/x-www-form-urlencoded', | ||
}; | ||
if ((options as any).clientSecret !== undefined) { | ||
const basicAuthStr = base64Encode(options.clientId + ':' + (options as any).clientSecret); | ||
headers.Authorization = 'Basic ' + basicAuthStr; | ||
} | ||
const authResult = await fetch(options.tokenEndpoint, { | ||
method: 'POST', | ||
headers, | ||
body: objToQueryString(body), | ||
}); | ||
const jsonResult = await authResult.json(); | ||
if (!authResult.ok) { | ||
// If we failed with a refresh_token grant_type, we're going to make one | ||
// more attempt doing a full re-auth | ||
if (body.grant_type === 'refresh_token' && options.grantType) { | ||
return refreshToken(options, null); | ||
} | ||
let errorMessage = 'OAuth2 error ' + jsonResult.error + '.'; | ||
if (jsonResult.error_description) { | ||
errorMessage += ' ' + jsonResult.error_description; | ||
} | ||
throw new OAuthError(errorMessage, jsonResult.error, 401); | ||
} | ||
const newToken: OAuth2Token = { | ||
accessToken: jsonResult.access_token, | ||
expiresAt: jsonResult.expires_in ? Date.now() + (jsonResult.expires_in * 1000) : null, | ||
refreshToken: jsonResult.refresh_token ? jsonResult.refresh_token : null, | ||
}; | ||
if (options.onTokenUpdate) { | ||
options.onTokenUpdate(newToken); | ||
} | ||
return newToken; | ||
} | ||
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
71308
4.17%1105
3.56%137
0.74%