| /** |
| * @license |
| * Copyright (C) 2017 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| import {getBaseUrl} from '../../utils/url-util'; |
| import {EventEmitterService} from '../gr-event-interface/gr-event-interface'; |
| import { |
| AuthRequestInit, |
| AuthService, |
| AuthStatus, |
| AuthType, |
| DefaultAuthOptions, |
| GetTokenCallback, |
| Token, |
| } from './gr-auth'; |
| |
| const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s |
| const MAX_GET_TOKEN_RETRIES = 2; |
| |
| interface ValidToken extends Token { |
| access_token: string; |
| expires_at: string; |
| } |
| |
| interface AuthRequestInitWithHeaders extends AuthRequestInit { |
| // RequestInit define headers as optional property with a type |
| // Headers | string[][] | Record<string, string> |
| // In Auth class headers property is always set and has type Headers |
| headers: Headers; |
| } |
| |
| /** |
| * Auth class. |
| */ |
| export class Auth implements AuthService { |
| // TODO(dmfilippov): Remove Type and Status properties, expose AuthType and |
| // AuthStatus to API |
| static TYPE = { |
| XSRF_TOKEN: AuthType.XSRF_TOKEN, |
| ACCESS_TOKEN: AuthType.ACCESS_TOKEN, |
| }; |
| |
| static STATUS = { |
| UNDETERMINED: AuthStatus.UNDETERMINED, |
| AUTHED: AuthStatus.AUTHED, |
| NOT_AUTHED: AuthStatus.NOT_AUTHED, |
| ERROR: AuthStatus.ERROR, |
| }; |
| |
| static CREDS_EXPIRED_MSG = 'Credentials expired.'; |
| |
| private _authCheckPromise?: Promise<Response>; |
| |
| private _last_auth_check_time: number = Date.now(); |
| |
| private _status = AuthStatus.UNDETERMINED; |
| |
| private _retriesLeft = MAX_GET_TOKEN_RETRIES; |
| |
| private _cachedTokenPromise: Promise<Token | null> | null = null; |
| |
| private _type?: AuthType; |
| |
| private _defaultOptions: AuthRequestInit = {}; |
| |
| private _getToken: GetTokenCallback; |
| |
| public eventEmitter: EventEmitterService; |
| |
| constructor(eventEmitter: EventEmitterService) { |
| this._getToken = () => Promise.resolve(this._cachedTokenPromise); |
| this.eventEmitter = eventEmitter; |
| } |
| |
| get baseUrl() { |
| return getBaseUrl(); |
| } |
| |
| /** |
| * Returns if user is authed or not. |
| */ |
| authCheck(): Promise<boolean> { |
| if ( |
| !this._authCheckPromise || |
| Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS |
| ) { |
| // Refetch after last check expired |
| this._authCheckPromise = fetch(`${this.baseUrl}/auth-check`); |
| this._last_auth_check_time = Date.now(); |
| } |
| |
| return this._authCheckPromise |
| .then(res => { |
| // auth-check will return 204 if authed |
| // treat the rest as unauthed |
| if (res.status === 204) { |
| this._setStatus(Auth.STATUS.AUTHED); |
| return true; |
| } else { |
| this._setStatus(Auth.STATUS.NOT_AUTHED); |
| return false; |
| } |
| }) |
| .catch(() => { |
| this._setStatus(AuthStatus.ERROR); |
| // Reset _authCheckPromise to avoid caching the failed promise |
| this._authCheckPromise = undefined; |
| return false; |
| }); |
| } |
| |
| clearCache() { |
| this._authCheckPromise = undefined; |
| } |
| |
| private _setStatus(status: AuthStatus) { |
| if (this._status === status) return; |
| |
| if (this._status === AuthStatus.AUTHED) { |
| this.eventEmitter.emit('auth-error', { |
| message: Auth.CREDS_EXPIRED_MSG, |
| action: 'Refresh credentials', |
| }); |
| } |
| this._status = status; |
| } |
| |
| get status() { |
| return this._status; |
| } |
| |
| get isAuthed() { |
| return this._status === Auth.STATUS.AUTHED; |
| } |
| |
| /** |
| * Enable cross-domain authentication using OAuth access token. |
| */ |
| setup(getToken: GetTokenCallback, defaultOptions: DefaultAuthOptions) { |
| this._retriesLeft = MAX_GET_TOKEN_RETRIES; |
| if (getToken) { |
| this._type = AuthType.ACCESS_TOKEN; |
| this._cachedTokenPromise = null; |
| this._getToken = getToken; |
| } |
| this._defaultOptions = {}; |
| if (defaultOptions) { |
| this._defaultOptions.credentials = defaultOptions.credentials; |
| } |
| } |
| |
| /** |
| * Perform network fetch with authentication. |
| */ |
| fetch(url: string, opt_options?: AuthRequestInit): Promise<Response> { |
| const options: AuthRequestInitWithHeaders = { |
| headers: new Headers(), |
| ...this._defaultOptions, |
| ...opt_options, |
| }; |
| if (this._type === AuthType.ACCESS_TOKEN) { |
| return this._getAccessToken().then(accessToken => |
| this._fetchWithAccessToken(url, options, accessToken) |
| ); |
| } else { |
| return this._fetchWithXsrfToken(url, options); |
| } |
| } |
| |
| private _getCookie(name: string): string { |
| const key = name + '='; |
| let result = ''; |
| document.cookie.split(';').some(c => { |
| c = c.trim(); |
| if (c.startsWith(key)) { |
| result = c.substring(key.length); |
| return true; |
| } |
| return false; |
| }); |
| return result; |
| } |
| |
| private _isTokenValid(token: Token | null): token is ValidToken { |
| if (!token) { |
| return false; |
| } |
| if (!token.access_token || !token.expires_at) { |
| return false; |
| } |
| |
| const expiration = new Date(Number(token.expires_at) * 1000); |
| if (Date.now() >= expiration.getTime()) { |
| return false; |
| } |
| |
| return true; |
| } |
| |
| private _fetchWithXsrfToken( |
| url: string, |
| options: AuthRequestInitWithHeaders |
| ): Promise<Response> { |
| if (options.method && options.method !== 'GET') { |
| const token = this._getCookie('XSRF_TOKEN'); |
| if (token) { |
| options.headers.append('X-Gerrit-Auth', token); |
| } |
| } |
| options.credentials = 'same-origin'; |
| return fetch(url, options); |
| } |
| |
| private _getAccessToken(): Promise<string | null> { |
| if (!this._cachedTokenPromise) { |
| this._cachedTokenPromise = this._getToken(); |
| } |
| return this._cachedTokenPromise.then(token => { |
| if (this._isTokenValid(token)) { |
| this._retriesLeft = MAX_GET_TOKEN_RETRIES; |
| return token.access_token; |
| } |
| if (this._retriesLeft > 0) { |
| this._retriesLeft--; |
| this._cachedTokenPromise = null; |
| return this._getAccessToken(); |
| } |
| // Fall back to anonymous access. |
| return null; |
| }); |
| } |
| |
| private _fetchWithAccessToken( |
| url: string, |
| options: AuthRequestInitWithHeaders, |
| accessToken: string | null |
| ): Promise<Response> { |
| const params = []; |
| |
| if (accessToken) { |
| params.push(`access_token=${accessToken}`); |
| const baseUrl = this.baseUrl; |
| const pathname = baseUrl |
| ? url.substring(url.indexOf(baseUrl) + baseUrl.length) |
| : url; |
| if (!pathname.startsWith('/a/')) { |
| url = url.replace(pathname, '/a' + pathname); |
| } |
| } |
| |
| const method = options.method || 'GET'; |
| let contentType = options.headers.get('Content-Type'); |
| |
| // For all requests with body, ensure json content type. |
| if (!contentType && options.body) { |
| contentType = 'application/json'; |
| } |
| |
| if (method !== 'GET') { |
| options.method = 'POST'; |
| params.push(`$m=${method}`); |
| // If a request is not GET, and does not have a body, ensure text/plain |
| // content type. |
| if (!contentType) { |
| contentType = 'text/plain'; |
| } |
| } |
| |
| if (contentType) { |
| options.headers.set('Content-Type', 'text/plain'); |
| params.push(`$ct=${encodeURIComponent(contentType)}`); |
| } |
| |
| if (params.length) { |
| url = url + (url.indexOf('?') === -1 ? '?' : '&') + params.join('&'); |
| } |
| return fetch(url, options); |
| } |
| } |