blob: 08f2e25296d577835c1d4260e81d31158a13ad8a [file] [log] [blame]
/**
* @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';
export 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);
}
}