UNPKG

expo-auth-session

Version:

Expo module for browser-based authentication

515 lines (464 loc) 15.7 kB
import invariant from 'invariant'; import { Platform } from 'react-native'; import * as Base64 from './Base64'; import * as ServiceConfig from './Discovery'; import { ResponseErrorConfig, TokenError } from './Errors'; import { Headers, requestAsync } from './Fetch'; import { AccessTokenRequestConfig, GrantType, RefreshTokenRequestConfig, RevokeTokenRequestConfig, ServerTokenResponseConfig, TokenRequestConfig, TokenResponseConfig, TokenType, TokenTypeHint, } from './TokenRequest.types'; /** * Returns the current time in seconds. */ export function getCurrentTimeInSeconds(): number { return Math.floor(Date.now() / 1000); } /** * Token Response. * * [Section 5.1](https://tools.ietf.org/html/rfc6749#section-5.1) */ export class TokenResponse implements TokenResponseConfig { /** * Determines whether a token refresh request must be made to refresh the tokens * * @param token * @param secondsMargin */ static isTokenFresh( token: Pick<TokenResponse, 'expiresIn' | 'issuedAt'>, /** * -10 minutes in seconds */ secondsMargin: number = 60 * 10 * -1 ): boolean { if (!token) { return false; } if (token.expiresIn) { const now = getCurrentTimeInSeconds(); return now < token.issuedAt + token.expiresIn + secondsMargin; } // if there is no expiration time but we have an access token, it is assumed to never expire return true; } /** * Creates a `TokenResponse` from query parameters returned from an `AuthRequest`. * * @param params */ static fromQueryParams(params: Record<string, any>): TokenResponse { return new TokenResponse({ accessToken: params.access_token, refreshToken: params.refresh_token, scope: params.scope, state: params.state, idToken: params.id_token, tokenType: params.token_type, expiresIn: params.expires_in, issuedAt: params.issued_at, }); } accessToken: string; tokenType: TokenType; expiresIn?: number; refreshToken?: string; scope?: string; state?: string; idToken?: string; issuedAt: number; /** * Contains the unprocessed token response. Use it to access properties which aren't part of RFC 6749. * */ rawResponse?: unknown; constructor(response: TokenResponseConfig, rawResponse?: unknown) { this.rawResponse = rawResponse; this.accessToken = response.accessToken; this.tokenType = response.tokenType ?? 'bearer'; this.expiresIn = response.expiresIn; this.refreshToken = response.refreshToken; this.scope = response.scope; this.state = response.state; this.idToken = response.idToken; this.issuedAt = response.issuedAt ?? getCurrentTimeInSeconds(); } private applyResponseConfig(response: TokenResponseConfig) { this.accessToken = response.accessToken ?? this.accessToken; this.tokenType = response.tokenType ?? this.tokenType ?? 'bearer'; this.expiresIn = response.expiresIn ?? this.expiresIn; this.refreshToken = response.refreshToken ?? this.refreshToken; this.scope = response.scope ?? this.scope; this.state = response.state ?? this.state; this.idToken = response.idToken ?? this.idToken; this.issuedAt = response.issuedAt ?? this.issuedAt ?? getCurrentTimeInSeconds(); } getRequestConfig(): TokenResponseConfig { return { accessToken: this.accessToken, idToken: this.idToken, refreshToken: this.refreshToken, scope: this.scope, state: this.state, tokenType: this.tokenType, issuedAt: this.issuedAt, expiresIn: this.expiresIn, }; } async refreshAsync( config: Omit<TokenRequestConfig, 'grantType' | 'refreshToken'>, discovery: Pick<ServiceConfig.DiscoveryDocument, 'tokenEndpoint'> ): Promise<TokenResponse> { const request = new RefreshTokenRequest({ ...config, refreshToken: this.refreshToken, }); const response = await request.performAsync(discovery); // Custom: reuse the refresh token if one wasn't returned response.refreshToken = response.refreshToken ?? this.refreshToken; const json = response.getRequestConfig(); this.applyResponseConfig(json); return this; } shouldRefresh(): boolean { // no refresh token available and token has expired return !(TokenResponse.isTokenFresh(this) || !this.refreshToken); } } export class Request<T, B> { constructor(protected request: T) {} async performAsync(discovery: ServiceConfig.DiscoveryDocument): Promise<B> { throw new Error('performAsync must be extended'); } getRequestConfig(): T { throw new Error('getRequestConfig must be extended'); } getQueryBody(): Record<string, string> { throw new Error('getQueryBody must be extended'); } } /** * A generic token request. */ export class TokenRequest<T extends TokenRequestConfig> extends Request<T, TokenResponse> implements TokenRequestConfig { readonly clientId: string; readonly clientSecret?: string; readonly scopes?: string[]; readonly extraParams?: Record<string, string>; constructor( request: T, public grantType: GrantType ) { super(request); this.clientId = request.clientId; this.clientSecret = request.clientSecret; this.extraParams = request.extraParams; this.scopes = request.scopes; } getHeaders(): Headers { const headers: Headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; if (typeof this.clientSecret !== 'undefined') { // If client secret exists, it should be converted to base64 // https://tools.ietf.org/html/rfc6749#section-2.3.1 const encodedClientId = encodeURIComponent(this.clientId); const encodedClientSecret = encodeURIComponent(this.clientSecret); const credentials = `${encodedClientId}:${encodedClientSecret}`; const basicAuth = Base64.encodeNoWrap(credentials); headers.Authorization = `Basic ${basicAuth}`; } return headers; } async performAsync(discovery: Pick<ServiceConfig.DiscoveryDocument, 'tokenEndpoint'>) { // redirect URI must not be nil invariant( discovery.tokenEndpoint, `Cannot invoke \`performAsync()\` without a valid tokenEndpoint` ); const response = await requestAsync<ServerTokenResponseConfig | ResponseErrorConfig>( discovery.tokenEndpoint, { dataType: 'json', method: 'POST', headers: this.getHeaders(), body: this.getQueryBody(), } ); if ('error' in response) { throw new TokenError(response); } return new TokenResponse( { accessToken: response.access_token, tokenType: response.token_type, expiresIn: response.expires_in, refreshToken: response.refresh_token, scope: response.scope, idToken: response.id_token, issuedAt: response.issued_at, }, response ); } getQueryBody() { const queryBody: Record<string, string> = { grant_type: this.grantType, }; if (!this.clientSecret) { // Only add the client ID if client secret is not present, otherwise pass the client id with the secret in the request body. queryBody.client_id = this.clientId; } if (this.scopes) { queryBody.scope = this.scopes.join(' '); } if (this.extraParams) { for (const extra in this.extraParams) { if (extra in this.extraParams && !(extra in queryBody)) { queryBody[extra] = this.extraParams[extra]; } } } return queryBody; } } /** * Access token request. Exchange an authorization code for a user access token. * * [Section 4.1.3](https://tools.ietf.org/html/rfc6749#section-4.1.3) */ export class AccessTokenRequest extends TokenRequest<AccessTokenRequestConfig> implements AccessTokenRequestConfig { readonly code: string; readonly redirectUri: string; constructor(options: AccessTokenRequestConfig) { invariant( options.redirectUri, `\`AccessTokenRequest\` requires a valid \`redirectUri\` (it must also match the one used in the auth request). Example: ${Platform.select( { web: 'https://yourwebsite.com/redirect', default: 'myapp://redirect', } )}` ); invariant( options.code, `\`AccessTokenRequest\` requires a valid authorization \`code\`. This is what's received from the authorization server after an auth request.` ); super(options, GrantType.AuthorizationCode); this.code = options.code; this.redirectUri = options.redirectUri; } getQueryBody() { const queryBody: Record<string, string> = super.getQueryBody(); if (this.redirectUri) { queryBody.redirect_uri = this.redirectUri; } if (this.code) { queryBody.code = this.code; } return queryBody; } getRequestConfig() { return { clientId: this.clientId, clientSecret: this.clientSecret, grantType: this.grantType, code: this.code, redirectUri: this.redirectUri, extraParams: this.extraParams, scopes: this.scopes, }; } } /** * Refresh request. * * [Section 6](https://tools.ietf.org/html/rfc6749#section-6) */ export class RefreshTokenRequest extends TokenRequest<RefreshTokenRequestConfig> implements RefreshTokenRequestConfig { readonly refreshToken?: string; constructor(options: RefreshTokenRequestConfig) { invariant(options.refreshToken, `\`RefreshTokenRequest\` requires a valid \`refreshToken\`.`); super(options, GrantType.RefreshToken); this.refreshToken = options.refreshToken; } getQueryBody() { const queryBody = super.getQueryBody(); if (this.refreshToken) { queryBody.refresh_token = this.refreshToken; } return queryBody; } getRequestConfig() { return { clientId: this.clientId, clientSecret: this.clientSecret, grantType: this.grantType, refreshToken: this.refreshToken, extraParams: this.extraParams, scopes: this.scopes, }; } } /** * Revocation request for a given token. * * [Section 2.1](https://tools.ietf.org/html/rfc7009#section-2.1) */ export class RevokeTokenRequest extends Request<RevokeTokenRequestConfig, boolean> implements RevokeTokenRequestConfig { readonly clientId?: string; readonly clientSecret?: string; readonly token: string; readonly tokenTypeHint?: TokenTypeHint; constructor(request: RevokeTokenRequestConfig) { super(request); invariant(request.token, `\`RevokeTokenRequest\` requires a valid \`token\` to revoke.`); this.clientId = request.clientId; this.clientSecret = request.clientSecret; this.token = request.token; this.tokenTypeHint = request.tokenTypeHint; } getHeaders(): Headers { const headers: Headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; if (typeof this.clientSecret !== 'undefined' && this.clientId) { // If client secret exists, it should be converted to base64 // https://tools.ietf.org/html/rfc6749#section-2.3.1 const encodedClientId = encodeURIComponent(this.clientId); const encodedClientSecret = encodeURIComponent(this.clientSecret); const credentials = `${encodedClientId}:${encodedClientSecret}`; const basicAuth = Base64.encodeNoWrap(credentials); headers.Authorization = `Basic ${basicAuth}`; } return headers; } /** * Perform a token revocation request. * * @param discovery The `revocationEndpoint` for a provider. */ async performAsync(discovery: Pick<ServiceConfig.DiscoveryDocument, 'revocationEndpoint'>) { invariant( discovery.revocationEndpoint, `Cannot invoke \`performAsync()\` without a valid revocationEndpoint` ); await requestAsync<boolean>(discovery.revocationEndpoint, { method: 'POST', headers: this.getHeaders(), body: this.getQueryBody(), }); return true; } getRequestConfig() { return { clientId: this.clientId, clientSecret: this.clientSecret, token: this.token, tokenTypeHint: this.tokenTypeHint, }; } getQueryBody(): Record<string, string> { const queryBody: Record<string, string> = { token: this.token }; if (this.tokenTypeHint) { queryBody.token_type_hint = this.tokenTypeHint; } // Include client creds https://tools.ietf.org/html/rfc6749#section-2.3.1 if (this.clientId) { queryBody.client_id = this.clientId; } if (this.clientSecret) { queryBody.client_secret = this.clientSecret; } return queryBody; } } // @needsAudit /** * Exchange an authorization code for an access token that can be used to get data from the provider. * * @param config Configuration used to exchange the code for a token. * @param discovery The `tokenEndpoint` for a provider. * @return Returns a discovery document with a valid `tokenEndpoint` URL. */ export function exchangeCodeAsync( config: AccessTokenRequestConfig, discovery: Pick<ServiceConfig.DiscoveryDocument, 'tokenEndpoint'> ): Promise<TokenResponse> { const request = new AccessTokenRequest(config); return request.performAsync(discovery); } // @needsAudit /** * Refresh an access token. * - If the provider didn't return a `refresh_token` then the access token may not be refreshed. * - If the provider didn't return a `expires_in` then it's assumed that the token does not expire. * - Determine if a token needs to be refreshed via `TokenResponse.isTokenFresh()` or `shouldRefresh()` on an instance of `TokenResponse`. * * @see [Section 6](https://tools.ietf.org/html/rfc6749#section-6). * * @param config Configuration used to refresh the given access token. * @param discovery The `tokenEndpoint` for a provider. * @return Returns a discovery document with a valid `tokenEndpoint` URL. */ export function refreshAsync( config: RefreshTokenRequestConfig, discovery: Pick<ServiceConfig.DiscoveryDocument, 'tokenEndpoint'> ): Promise<TokenResponse> { const request = new RefreshTokenRequest(config); return request.performAsync(discovery); } // @needsAudit /** * Revoke a token with a provider. This makes the token unusable, effectively requiring the user to login again. * * @param config Configuration used to revoke a refresh or access token. * @param discovery The `revocationEndpoint` for a provider. * @return Returns a discovery document with a valid `revocationEndpoint` URL. Many providers do not support this feature. */ export function revokeAsync( config: RevokeTokenRequestConfig, discovery: Pick<ServiceConfig.DiscoveryDocument, 'revocationEndpoint'> ): Promise<boolean> { const request = new RevokeTokenRequest(config); return request.performAsync(discovery); } /** * Fetch generic user info from the provider's OpenID Connect `userInfoEndpoint` (if supported). * * @see [UserInfo](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo). * * @param config The `accessToken` for a user, returned from a code exchange or auth request. * @param discovery The `userInfoEndpoint` for a provider. */ export function fetchUserInfoAsync( config: Pick<TokenResponse, 'accessToken'>, discovery: Pick<ServiceConfig.DiscoveryDocument, 'userInfoEndpoint'> ): Promise<Record<string, any>> { if (!discovery.userInfoEndpoint) { throw new Error('User info endpoint is not defined in the service config discovery document'); } return requestAsync<Record<string, any>>(discovery.userInfoEndpoint, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Bearer ${config.accessToken}`, }, dataType: 'json', method: 'GET', }); }