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(request, public grantType: GrantType) { 166 super(request); 167 this.clientId = request.clientId; 168 this.clientSecret = request.clientSecret; 169 this.extraParams = request.extraParams; 170 this.scopes = request.scopes; 171 } 172 173 getHeaders(): Headers { 174 const headers: Headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; 175 if (typeof this.clientSecret !== 'undefined') { 176 // If client secret exists, it should be converted to base64 177 // https://tools.ietf.org/html/rfc6749#section-2.3.1 178 const encodedClientId = encodeURIComponent(this.clientId); 179 const encodedClientSecret = encodeURIComponent(this.clientSecret); 180 const credentials = `${encodedClientId}:${encodedClientSecret}`; 181 const basicAuth = Base64.encodeNoWrap(credentials); 182 headers.Authorization = `Basic ${basicAuth}`; 183 } 184 185 return headers; 186 } 187 188 async performAsync(discovery: Pick<ServiceConfig.DiscoveryDocument, 'tokenEndpoint'>) { 189 // redirect URI must not be nil 190 invariant( 191 discovery.tokenEndpoint, 192 `Cannot invoke \`performAsync()\` without a valid tokenEndpoint` 193 ); 194 const response = await requestAsync<ServerTokenResponseConfig | ResponseErrorConfig>( 195 discovery.tokenEndpoint, 196 { 197 dataType: 'json', 198 method: 'POST', 199 headers: this.getHeaders(), 200 body: this.getQueryBody(), 201 } 202 ); 203 204 if ('error' in response) { 205 throw new TokenError(response); 206 } 207 208 return new TokenResponse({ 209 accessToken: response.access_token, 210 tokenType: response.token_type, 211 expiresIn: response.expires_in, 212 refreshToken: response.refresh_token, 213 scope: response.scope, 214 idToken: response.id_token, 215 issuedAt: response.issued_at, 216 }); 217 } 218 219 getQueryBody() { 220 const queryBody: Record<string, string> = { 221 grant_type: this.grantType, 222 }; 223 224 if (!this.clientSecret) { 225 // Only add the client ID if client secret is not present, otherwise pass the client id with the secret in the request body. 226 queryBody.client_id = this.clientId; 227 } 228 229 if (this.scopes) { 230 queryBody.scope = this.scopes.join(' '); 231 } 232 233 if (this.extraParams) { 234 for (const extra in this.extraParams) { 235 if (extra in this.extraParams && !(extra in queryBody)) { 236 queryBody[extra] = this.extraParams[extra]; 237 } 238 } 239 } 240 return queryBody; 241 } 242} 243 244/** 245 * Access token request. Exchange an authorization code for a user access token. 246 * 247 * [Section 4.1.3](https://tools.ietf.org/html/rfc6749#section-4.1.3) 248 */ 249export class AccessTokenRequest 250 extends TokenRequest<AccessTokenRequestConfig> 251 implements AccessTokenRequestConfig 252{ 253 readonly code: string; 254 readonly redirectUri: string; 255 256 constructor(options: AccessTokenRequestConfig) { 257 invariant( 258 options.redirectUri, 259 `\`AccessTokenRequest\` requires a valid \`redirectUri\` (it must also match the one used in the auth request). Example: ${Platform.select( 260 { 261 web: 'https://yourwebsite.com/redirect', 262 default: 'myapp://redirect', 263 } 264 )}` 265 ); 266 267 invariant( 268 options.code, 269 `\`AccessTokenRequest\` requires a valid authorization \`code\`. This is what's received from the authorization server after an auth request.` 270 ); 271 super(options, GrantType.AuthorizationCode); 272 this.code = options.code; 273 this.redirectUri = options.redirectUri; 274 } 275 276 getQueryBody() { 277 const queryBody: Record<string, string> = super.getQueryBody(); 278 279 if (this.redirectUri) { 280 queryBody.redirect_uri = this.redirectUri; 281 } 282 283 if (this.code) { 284 queryBody.code = this.code; 285 } 286 287 return queryBody; 288 } 289 290 getRequestConfig() { 291 return { 292 clientId: this.clientId, 293 clientSecret: this.clientSecret, 294 grantType: this.grantType, 295 code: this.code, 296 redirectUri: this.redirectUri, 297 extraParams: this.extraParams, 298 scopes: this.scopes, 299 }; 300 } 301} 302 303/** 304 * Refresh request. 305 * 306 * [Section 6](https://tools.ietf.org/html/rfc6749#section-6) 307 */ 308export class RefreshTokenRequest 309 extends TokenRequest<RefreshTokenRequestConfig> 310 implements RefreshTokenRequestConfig 311{ 312 readonly refreshToken?: string; 313 314 constructor(options: RefreshTokenRequestConfig) { 315 invariant(options.refreshToken, `\`RefreshTokenRequest\` requires a valid \`refreshToken\`.`); 316 super(options, GrantType.RefreshToken); 317 this.refreshToken = options.refreshToken; 318 } 319 320 getQueryBody() { 321 const queryBody = super.getQueryBody(); 322 323 if (this.refreshToken) { 324 queryBody.refresh_token = this.refreshToken; 325 } 326 327 return queryBody; 328 } 329 330 getRequestConfig() { 331 return { 332 clientId: this.clientId, 333 clientSecret: this.clientSecret, 334 grantType: this.grantType, 335 refreshToken: this.refreshToken, 336 extraParams: this.extraParams, 337 scopes: this.scopes, 338 }; 339 } 340} 341 342/** 343 * Revocation request for a given token. 344 * 345 * [Section 2.1](https://tools.ietf.org/html/rfc7009#section-2.1) 346 */ 347export class RevokeTokenRequest 348 extends Request<RevokeTokenRequestConfig, boolean> 349 implements RevokeTokenRequestConfig 350{ 351 readonly clientId?: string; 352 readonly clientSecret?: string; 353 readonly token: string; 354 readonly tokenTypeHint?: TokenTypeHint; 355 356 constructor(request: RevokeTokenRequestConfig) { 357 super(request); 358 invariant(request.token, `\`RevokeTokenRequest\` requires a valid \`token\` to revoke.`); 359 this.clientId = request.clientId; 360 this.clientSecret = request.clientSecret; 361 this.token = request.token; 362 this.tokenTypeHint = request.tokenTypeHint; 363 } 364 365 getHeaders(): Headers { 366 const headers: Headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; 367 if (typeof this.clientSecret !== 'undefined' && this.clientId) { 368 // If client secret exists, it should be converted to base64 369 // https://tools.ietf.org/html/rfc6749#section-2.3.1 370 const encodedClientId = encodeURIComponent(this.clientId); 371 const encodedClientSecret = encodeURIComponent(this.clientSecret); 372 const credentials = `${encodedClientId}:${encodedClientSecret}`; 373 const basicAuth = Base64.encodeNoWrap(credentials); 374 headers.Authorization = `Basic ${basicAuth}`; 375 } 376 377 return headers; 378 } 379 380 /** 381 * Perform a token revocation request. 382 * 383 * @param discovery The `revocationEndpoint` for a provider. 384 */ 385 async performAsync(discovery: Pick<ServiceConfig.DiscoveryDocument, 'revocationEndpoint'>) { 386 invariant( 387 discovery.revocationEndpoint, 388 `Cannot invoke \`performAsync()\` without a valid revocationEndpoint` 389 ); 390 await requestAsync<boolean>(discovery.revocationEndpoint, { 391 method: 'POST', 392 headers: this.getHeaders(), 393 body: this.getQueryBody(), 394 }); 395 396 return true; 397 } 398 399 getRequestConfig() { 400 return { 401 clientId: this.clientId, 402 clientSecret: this.clientSecret, 403 token: this.token, 404 tokenTypeHint: this.tokenTypeHint, 405 }; 406 } 407 408 getQueryBody(): Record<string, string> { 409 const queryBody: Record<string, string> = { token: this.token }; 410 if (this.tokenTypeHint) { 411 queryBody.token_type_hint = this.tokenTypeHint; 412 } 413 // Include client creds https://tools.ietf.org/html/rfc6749#section-2.3.1 414 if (this.clientId) { 415 queryBody.client_id = this.clientId; 416 } 417 if (this.clientSecret) { 418 queryBody.client_secret = this.clientSecret; 419 } 420 return queryBody; 421 } 422} 423 424// @needsAudit 425/** 426 * Exchange an authorization code for an access token that can be used to get data from the provider. 427 * 428 * @param config Configuration used to exchange the code for a token. 429 * @param discovery The `tokenEndpoint` for a provider. 430 * @return Returns a discovery document with a valid `tokenEndpoint` URL. 431 */ 432export function exchangeCodeAsync( 433 config: AccessTokenRequestConfig, 434 discovery: Pick<ServiceConfig.DiscoveryDocument, 'tokenEndpoint'> 435): Promise<TokenResponse> { 436 const request = new AccessTokenRequest(config); 437 return request.performAsync(discovery); 438} 439 440// @needsAudit 441/** 442 * Refresh an access token. 443 * - If the provider didn't return a `refresh_token` then the access token may not be refreshed. 444 * - If the provider didn't return a `expires_in` then it's assumed that the token does not expire. 445 * - Determine if a token needs to be refreshed via `TokenResponse.isTokenFresh()` or `shouldRefresh()` on an instance of `TokenResponse`. 446 * 447 * @see [Section 6](https://tools.ietf.org/html/rfc6749#section-6). 448 * 449 * @param config Configuration used to refresh the given access token. 450 * @param discovery The `tokenEndpoint` for a provider. 451 * @return Returns a discovery document with a valid `tokenEndpoint` URL. 452 */ 453export function refreshAsync( 454 config: RefreshTokenRequestConfig, 455 discovery: Pick<ServiceConfig.DiscoveryDocument, 'tokenEndpoint'> 456): Promise<TokenResponse> { 457 const request = new RefreshTokenRequest(config); 458 return request.performAsync(discovery); 459} 460 461// @needsAudit 462/** 463 * Revoke a token with a provider. This makes the token unusable, effectively requiring the user to login again. 464 * 465 * @param config Configuration used to revoke a refresh or access token. 466 * @param discovery The `revocationEndpoint` for a provider. 467 * @return Returns a discovery document with a valid `revocationEndpoint` URL. Many providers do not support this feature. 468 */ 469export function revokeAsync( 470 config: RevokeTokenRequestConfig, 471 discovery: Pick<ServiceConfig.DiscoveryDocument, 'revocationEndpoint'> 472): Promise<boolean> { 473 const request = new RevokeTokenRequest(config); 474 return request.performAsync(discovery); 475} 476 477/** 478 * Fetch generic user info from the provider's OpenID Connect `userInfoEndpoint` (if supported). 479 * 480 * @see [UserInfo](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo). 481 * 482 * @param config The `accessToken` for a user, returned from a code exchange or auth request. 483 * @param discovery The `userInfoEndpoint` for a provider. 484 */ 485export function fetchUserInfoAsync( 486 config: Pick<TokenResponse, 'accessToken'>, 487 discovery: Pick<ServiceConfig.DiscoveryDocument, 'userInfoEndpoint'> 488): Promise<Record<string, any>> { 489 if (!discovery.userInfoEndpoint) { 490 throw new Error('User info endpoint is not defined in the service config discovery document'); 491 } 492 return requestAsync<Record<string, any>>(discovery.userInfoEndpoint, { 493 headers: { 494 'Content-Type': 'application/x-www-form-urlencoded', 495 Authorization: `Bearer ${config.accessToken}`, 496 }, 497 dataType: 'json', 498 method: 'GET', 499 }); 500} 501