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