blob: 2312fc960ab34b7c8e9ad8a2316352fc77a5136d [file] [log] [blame]
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {AuthRequestInit} from '../../types/types';
import {fire} from '../../utils/event-util';
import {getBaseUrl} from '../../utils/url-util';
import {Finalizable} from '../registry';
import {
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, Finalizable {
// 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<boolean>;
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;
constructor() {
this.getToken = () => Promise.resolve(this.cachedTokenPromise);
}
get baseUrl() {
return getBaseUrl();
}
finalize() {}
/**
* 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`)
.then(res => {
// Make a call that requires loading the body of the request. This makes it so that the browser
// can close the request even though callers of this method might only ever read headers.
// See https://stackoverflow.com/questions/45816743/how-to-solve-this-caution-request-is-not-finished-yet-in-chrome
try {
res.clone().text();
} catch {
// Ignore error
}
// 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;
});
this._last_auth_check_time = Date.now();
}
return this.authCheckPromise;
}
clearCache() {
this.authCheckPromise = undefined;
}
private _setStatus(status: AuthStatus) {
if (this._status === status) return;
if (this._status === AuthStatus.AUTHED) {
fire(document, '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, options?: AuthRequestInit): Promise<Response> {
const optionsWithHeaders: AuthRequestInitWithHeaders = {
headers: new Headers(),
...this.defaultOptions,
...options,
};
if (this.type === AuthType.ACCESS_TOKEN) {
return this._getAccessToken().then(accessToken =>
this._fetchWithAccessToken(url, optionsWithHeaders, accessToken)
);
} else {
return this._fetchWithXsrfToken(url, optionsWithHeaders);
}
}
// private but used in test
_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 but used in test
_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 this._ensureBodyLoaded(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 this._ensureBodyLoaded(fetch(url, options));
}
private _ensureBodyLoaded(response: Promise<Response>): Promise<Response> {
return response.then(response => {
if (!response.ok) {
// Make a call that requires loading the body of the request. This makes it so that the browser
// can close the request even though callers of this method might only ever read headers.
// See https://stackoverflow.com/questions/45816743/how-to-solve-this-caution-request-is-not-finished-yet-in-chrome
try {
response.clone().text();
} catch {
// Ignore error
}
}
return response;
});
}
}