1import invariant from 'invariant'; 2import { Platform } from 'react-native'; 3import * as Base64 from './Base64'; 4import { TokenError } from './Errors'; 5import { requestAsync } from './Fetch'; 6import { GrantType, } from './TokenRequest.types'; 7/** 8 * Returns the current time in seconds. 9 */ 10export function getCurrentTimeInSeconds() { 11 return Math.floor(Date.now() / 1000); 12} 13/** 14 * Token Response. 15 * 16 * [Section 5.1](https://tools.ietf.org/html/rfc6749#section-5.1) 17 */ 18export class TokenResponse { 19 /** 20 * Determines whether a token refresh request must be made to refresh the tokens 21 * 22 * @param token 23 * @param secondsMargin 24 */ 25 static isTokenFresh(token, 26 /** 27 * -10 minutes in seconds 28 */ 29 secondsMargin = 60 * 10 * -1) { 30 if (!token) { 31 return false; 32 } 33 if (token.expiresIn) { 34 const now = getCurrentTimeInSeconds(); 35 return now < token.issuedAt + token.expiresIn + secondsMargin; 36 } 37 // if there is no expiration time but we have an access token, it is assumed to never expire 38 return true; 39 } 40 /** 41 * Creates a `TokenResponse` from query parameters returned from an `AuthRequest`. 42 * 43 * @param params 44 */ 45 static fromQueryParams(params) { 46 return new TokenResponse({ 47 accessToken: params.access_token, 48 refreshToken: params.refresh_token, 49 scope: params.scope, 50 state: params.state, 51 idToken: params.id_token, 52 tokenType: params.token_type, 53 expiresIn: params.expires_in, 54 issuedAt: params.issued_at, 55 }); 56 } 57 accessToken; 58 tokenType; 59 expiresIn; 60 refreshToken; 61 scope; 62 state; 63 idToken; 64 issuedAt; 65 constructor(response) { 66 this.accessToken = response.accessToken; 67 this.tokenType = response.tokenType ?? 'bearer'; 68 this.expiresIn = response.expiresIn; 69 this.refreshToken = response.refreshToken; 70 this.scope = response.scope; 71 this.state = response.state; 72 this.idToken = response.idToken; 73 this.issuedAt = response.issuedAt ?? getCurrentTimeInSeconds(); 74 } 75 applyResponseConfig(response) { 76 this.accessToken = response.accessToken ?? this.accessToken; 77 this.tokenType = response.tokenType ?? this.tokenType ?? 'bearer'; 78 this.expiresIn = response.expiresIn ?? this.expiresIn; 79 this.refreshToken = response.refreshToken ?? this.refreshToken; 80 this.scope = response.scope ?? this.scope; 81 this.state = response.state ?? this.state; 82 this.idToken = response.idToken ?? this.idToken; 83 this.issuedAt = response.issuedAt ?? this.issuedAt ?? getCurrentTimeInSeconds(); 84 } 85 getRequestConfig() { 86 return { 87 accessToken: this.accessToken, 88 idToken: this.idToken, 89 refreshToken: this.refreshToken, 90 scope: this.scope, 91 state: this.state, 92 tokenType: this.tokenType, 93 issuedAt: this.issuedAt, 94 expiresIn: this.expiresIn, 95 }; 96 } 97 async refreshAsync(config, discovery) { 98 const request = new RefreshTokenRequest({ 99 ...config, 100 refreshToken: this.refreshToken, 101 }); 102 const response = await request.performAsync(discovery); 103 // Custom: reuse the refresh token if one wasn't returned 104 response.refreshToken = response.refreshToken ?? this.refreshToken; 105 const json = response.getRequestConfig(); 106 this.applyResponseConfig(json); 107 return this; 108 } 109 shouldRefresh() { 110 // no refresh token available and token has expired 111 return !(TokenResponse.isTokenFresh(this) || !this.refreshToken); 112 } 113} 114class Request { 115 request; 116 constructor(request) { 117 this.request = request; 118 } 119 async performAsync(discovery) { 120 throw new Error('performAsync must be extended'); 121 } 122 getRequestConfig() { 123 throw new Error('getRequestConfig must be extended'); 124 } 125 getQueryBody() { 126 throw new Error('getQueryBody must be extended'); 127 } 128} 129/** 130 * A generic token request. 131 */ 132class TokenRequest extends Request { 133 grantType; 134 clientId; 135 clientSecret; 136 scopes; 137 extraParams; 138 constructor(request, grantType) { 139 super(request); 140 this.grantType = grantType; 141 this.clientId = request.clientId; 142 this.clientSecret = request.clientSecret; 143 this.extraParams = request.extraParams; 144 this.scopes = request.scopes; 145 } 146 getHeaders() { 147 const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; 148 if (typeof this.clientSecret !== 'undefined') { 149 // If client secret exists, it should be converted to base64 150 // https://tools.ietf.org/html/rfc6749#section-2.3.1 151 const encodedClientId = encodeURIComponent(this.clientId); 152 const encodedClientSecret = encodeURIComponent(this.clientSecret); 153 const credentials = `${encodedClientId}:${encodedClientSecret}`; 154 const basicAuth = Base64.encodeNoWrap(credentials); 155 headers.Authorization = `Basic ${basicAuth}`; 156 } 157 return headers; 158 } 159 async performAsync(discovery) { 160 // redirect URI must not be nil 161 invariant(discovery.tokenEndpoint, `Cannot invoke \`performAsync()\` without a valid tokenEndpoint`); 162 const response = await requestAsync(discovery.tokenEndpoint, { 163 dataType: 'json', 164 method: 'POST', 165 headers: this.getHeaders(), 166 body: this.getQueryBody(), 167 }); 168 if ('error' in response) { 169 throw new TokenError(response); 170 } 171 return new TokenResponse({ 172 accessToken: response.access_token, 173 tokenType: response.token_type, 174 expiresIn: response.expires_in, 175 refreshToken: response.refresh_token, 176 scope: response.scope, 177 idToken: response.id_token, 178 issuedAt: response.issued_at, 179 }); 180 } 181 getQueryBody() { 182 const queryBody = { 183 grant_type: this.grantType, 184 }; 185 if (!this.clientSecret) { 186 // Only add the client ID if client secret is not present, otherwise pass the client id with the secret in the request body. 187 queryBody.client_id = this.clientId; 188 } 189 if (this.scopes) { 190 queryBody.scope = this.scopes.join(' '); 191 } 192 if (this.extraParams) { 193 for (const extra in this.extraParams) { 194 if (extra in this.extraParams && !(extra in queryBody)) { 195 queryBody[extra] = this.extraParams[extra]; 196 } 197 } 198 } 199 return queryBody; 200 } 201} 202/** 203 * Access token request. Exchange an authorization code for a user access token. 204 * 205 * [Section 4.1.3](https://tools.ietf.org/html/rfc6749#section-4.1.3) 206 */ 207export class AccessTokenRequest extends TokenRequest { 208 code; 209 redirectUri; 210 constructor(options) { 211 invariant(options.redirectUri, `\`AccessTokenRequest\` requires a valid \`redirectUri\` (it must also match the one used in the auth request). Example: ${Platform.select({ 212 web: 'https://yourwebsite.com/redirect', 213 default: 'myapp://redirect', 214 })}`); 215 invariant(options.code, `\`AccessTokenRequest\` requires a valid authorization \`code\`. This is what's received from the authorization server after an auth request.`); 216 super(options, GrantType.AuthorizationCode); 217 this.code = options.code; 218 this.redirectUri = options.redirectUri; 219 } 220 getQueryBody() { 221 const queryBody = super.getQueryBody(); 222 if (this.redirectUri) { 223 queryBody.redirect_uri = this.redirectUri; 224 } 225 if (this.code) { 226 queryBody.code = this.code; 227 } 228 return queryBody; 229 } 230 getRequestConfig() { 231 return { 232 clientId: this.clientId, 233 clientSecret: this.clientSecret, 234 grantType: this.grantType, 235 code: this.code, 236 redirectUri: this.redirectUri, 237 extraParams: this.extraParams, 238 scopes: this.scopes, 239 }; 240 } 241} 242/** 243 * Refresh request. 244 * 245 * [Section 6](https://tools.ietf.org/html/rfc6749#section-6) 246 */ 247export class RefreshTokenRequest extends TokenRequest { 248 refreshToken; 249 constructor(options) { 250 invariant(options.refreshToken, `\`RefreshTokenRequest\` requires a valid \`refreshToken\`.`); 251 super(options, GrantType.RefreshToken); 252 this.refreshToken = options.refreshToken; 253 } 254 getQueryBody() { 255 const queryBody = super.getQueryBody(); 256 if (this.refreshToken) { 257 queryBody.refresh_token = this.refreshToken; 258 } 259 return queryBody; 260 } 261 getRequestConfig() { 262 return { 263 clientId: this.clientId, 264 clientSecret: this.clientSecret, 265 grantType: this.grantType, 266 refreshToken: this.refreshToken, 267 extraParams: this.extraParams, 268 scopes: this.scopes, 269 }; 270 } 271} 272/** 273 * Revocation request for a given token. 274 * 275 * [Section 2.1](https://tools.ietf.org/html/rfc7009#section-2.1) 276 */ 277export class RevokeTokenRequest extends Request { 278 clientId; 279 clientSecret; 280 token; 281 tokenTypeHint; 282 constructor(request) { 283 super(request); 284 invariant(request.token, `\`RevokeTokenRequest\` requires a valid \`token\` to revoke.`); 285 this.clientId = request.clientId; 286 this.clientSecret = request.clientSecret; 287 this.token = request.token; 288 this.tokenTypeHint = request.tokenTypeHint; 289 } 290 getHeaders() { 291 const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; 292 if (typeof this.clientSecret !== 'undefined' && this.clientId) { 293 // If client secret exists, it should be converted to base64 294 // https://tools.ietf.org/html/rfc6749#section-2.3.1 295 const encodedClientId = encodeURIComponent(this.clientId); 296 const encodedClientSecret = encodeURIComponent(this.clientSecret); 297 const credentials = `${encodedClientId}:${encodedClientSecret}`; 298 const basicAuth = Base64.encodeNoWrap(credentials); 299 headers.Authorization = `Basic ${basicAuth}`; 300 } 301 return headers; 302 } 303 /** 304 * Perform a token revocation request. 305 * 306 * @param discovery The `revocationEndpoint` for a provider. 307 */ 308 async performAsync(discovery) { 309 invariant(discovery.revocationEndpoint, `Cannot invoke \`performAsync()\` without a valid revocationEndpoint`); 310 await requestAsync(discovery.revocationEndpoint, { 311 method: 'POST', 312 headers: this.getHeaders(), 313 body: this.getQueryBody(), 314 }); 315 return true; 316 } 317 getRequestConfig() { 318 return { 319 clientId: this.clientId, 320 clientSecret: this.clientSecret, 321 token: this.token, 322 tokenTypeHint: this.tokenTypeHint, 323 }; 324 } 325 getQueryBody() { 326 const queryBody = { token: this.token }; 327 if (this.tokenTypeHint) { 328 queryBody.token_type_hint = this.tokenTypeHint; 329 } 330 // Include client creds https://tools.ietf.org/html/rfc6749#section-2.3.1 331 if (this.clientId) { 332 queryBody.client_id = this.clientId; 333 } 334 if (this.clientSecret) { 335 queryBody.client_secret = this.clientSecret; 336 } 337 return queryBody; 338 } 339} 340// @needsAudit 341/** 342 * Exchange an authorization code for an access token that can be used to get data from the provider. 343 * 344 * @param config Configuration used to exchange the code for a token. 345 * @param discovery The `tokenEndpoint` for a provider. 346 * @return Returns a discovery document with a valid `tokenEndpoint` URL. 347 */ 348export function exchangeCodeAsync(config, discovery) { 349 const request = new AccessTokenRequest(config); 350 return request.performAsync(discovery); 351} 352// @needsAudit 353/** 354 * Refresh an access token. 355 * - If the provider didn't return a `refresh_token` then the access token may not be refreshed. 356 * - If the provider didn't return a `expires_in` then it's assumed that the token does not expire. 357 * - Determine if a token needs to be refreshed via `TokenResponse.isTokenFresh()` or `shouldRefresh()` on an instance of `TokenResponse`. 358 * 359 * @see [Section 6](https://tools.ietf.org/html/rfc6749#section-6). 360 * 361 * @param config Configuration used to refresh the given access token. 362 * @param discovery The `tokenEndpoint` for a provider. 363 * @return Returns a discovery document with a valid `tokenEndpoint` URL. 364 */ 365export function refreshAsync(config, discovery) { 366 const request = new RefreshTokenRequest(config); 367 return request.performAsync(discovery); 368} 369// @needsAudit 370/** 371 * Revoke a token with a provider. This makes the token unusable, effectively requiring the user to login again. 372 * 373 * @param config Configuration used to revoke a refresh or access token. 374 * @param discovery The `revocationEndpoint` for a provider. 375 * @return Returns a discovery document with a valid `revocationEndpoint` URL. Many providers do not support this feature. 376 */ 377export function revokeAsync(config, discovery) { 378 const request = new RevokeTokenRequest(config); 379 return request.performAsync(discovery); 380} 381/** 382 * Fetch generic user info from the provider's OpenID Connect `userInfoEndpoint` (if supported). 383 * 384 * @see [UserInfo](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo). 385 * 386 * @param config The `accessToken` for a user, returned from a code exchange or auth request. 387 * @param discovery The `userInfoEndpoint` for a provider. 388 */ 389export function fetchUserInfoAsync(config, discovery) { 390 if (!discovery.userInfoEndpoint) { 391 throw new Error('User info endpoint is not defined in the service config discovery document'); 392 } 393 return requestAsync(discovery.userInfoEndpoint, { 394 headers: { 395 'Content-Type': 'application/x-www-form-urlencoded', 396 Authorization: `Bearer ${config.accessToken}`, 397 }, 398 dataType: 'json', 399 method: 'GET', 400 }); 401} 402//# sourceMappingURL=TokenRequest.js.map