mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) add an access token mechanism to help with attachments in custom widgets
Summary: With this, a custom widget can render an attachment by doing: ``` const tokenInfo = await grist.docApi.getAccessToken({readOnly: true}); const img = document.getElementById('the_image'); const id = record.C[0]; // get an id of an attachment const src = `${tokenInfo.baseUrl}/attachments/${id}/download?auth=${tokenInfo.token}`; img.setAttribute('src', src) ``` The access token expires after a few mins, so if a user right-clicks on an image to save it, they may get access denied unless they refresh the page. A little awkward, but s3 pre-authorized links behave similarly and it generally isn't a deal-breaker. Test Plan: added tests Reviewers: dsagal Reviewed By: dsagal Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3488
This commit is contained in:
parent
5c0a250309
commit
dd8d2e18f5
@ -46,6 +46,7 @@ export class DocComm extends Disposable implements ActiveDocAPI {
|
|||||||
public getAclResources = this._wrapMethod("getAclResources");
|
public getAclResources = this._wrapMethod("getAclResources");
|
||||||
public waitForInitialization = this._wrapMethod("waitForInitialization");
|
public waitForInitialization = this._wrapMethod("waitForInitialization");
|
||||||
public getUsersForViewAs = this._wrapMethod("getUsersForViewAs");
|
public getUsersForViewAs = this._wrapMethod("getUsersForViewAs");
|
||||||
|
public getAccessToken = this._wrapMethod("getAccessToken");
|
||||||
|
|
||||||
public changeUrlIdEmitter = this.autoDispose(new Emitter());
|
public changeUrlIdEmitter = this.autoDispose(new Emitter());
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ import {AccessLevel, isSatisfied} from 'app/common/CustomWidget';
|
|||||||
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||||
import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions';
|
import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions';
|
||||||
import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes';
|
import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes';
|
||||||
import {CustomSectionAPI, GristDocAPI, GristView,
|
import {AccessTokenOptions, CustomSectionAPI, GristDocAPI, GristView,
|
||||||
InteractionOptionsRequest, WidgetAPI, WidgetColumnMap} from 'app/plugin/grist-plugin-api';
|
InteractionOptionsRequest, WidgetAPI, WidgetColumnMap} from 'app/plugin/grist-plugin-api';
|
||||||
import {MsgType, Rpc} from 'grain-rpc';
|
import {MsgType, Rpc} from 'grain-rpc';
|
||||||
import {Computed, Disposable, dom, Observable} from 'grainjs';
|
import {Computed, Disposable, dom, Observable} from 'grainjs';
|
||||||
@ -318,6 +318,21 @@ export class GristDocAPIImpl implements GristDocAPI {
|
|||||||
public async applyUserActions(actions: any[][], options?: any) {
|
public async applyUserActions(actions: any[][], options?: any) {
|
||||||
return this._doc.docComm.applyUserActions(actions, {desc: undefined, ...options});
|
return this._doc.docComm.applyUserActions(actions, {desc: undefined, ...options});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get a token for out-of-band access to the document.
|
||||||
|
// Currently will require the custom widget to have full access to the
|
||||||
|
// document.
|
||||||
|
// It would be great to support this with read_table rights. This could be
|
||||||
|
// possible to do by adding a tableId setting to AccessTokenOptions,
|
||||||
|
// encoding that limitation in the access token, and ensuring the back-end
|
||||||
|
// respects it. But the current motivating use for adding access tokens is
|
||||||
|
// showing attachments, and they aren't currently something that logically
|
||||||
|
// lives within a specific table.
|
||||||
|
public async getAccessToken(options: AccessTokenOptions) {
|
||||||
|
return this._doc.docComm.getAccessToken({
|
||||||
|
readOnly: options.readOnly,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -4,6 +4,7 @@ import {FormulaProperties} from 'app/common/GranularAccessClause';
|
|||||||
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
||||||
import {DocStateComparison, PermissionData, UserAccessData} from 'app/common/UserAPI';
|
import {DocStateComparison, PermissionData, UserAccessData} from 'app/common/UserAPI';
|
||||||
import {ParseOptions} from 'app/plugin/FileParserAPI';
|
import {ParseOptions} from 'app/plugin/FileParserAPI';
|
||||||
|
import {AccessTokenOptions, AccessTokenResult} from 'app/plugin/GristAPI';
|
||||||
import {IMessage} from 'grain-rpc';
|
import {IMessage} from 'grain-rpc';
|
||||||
|
|
||||||
export interface ApplyUAOptions {
|
export interface ApplyUAOptions {
|
||||||
@ -316,6 +317,11 @@ export interface ActiveDocAPI {
|
|||||||
*/
|
*/
|
||||||
checkAclFormula(text: string): Promise<FormulaProperties>;
|
checkAclFormula(text: string): Promise<FormulaProperties>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a token for out-of-band access to the document.
|
||||||
|
*/
|
||||||
|
getAccessToken(options: AccessTokenOptions): Promise<AccessTokenResult>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the full set of tableIds, with the list of colIds for each table. This is intended
|
* Returns the full set of tableIds, with the list of colIds for each table. This is intended
|
||||||
* for editing ACLs. It is only available to users who can edit ACLs, and lists all resources
|
* for editing ACLs. It is only available to users who can edit ACLs, and lists all resources
|
||||||
|
@ -178,7 +178,12 @@ export function getOrgUrlInfo(newOrg: string, currentHost: string, options: OrgU
|
|||||||
* localhost:8080/o/<org>
|
* localhost:8080/o/<org>
|
||||||
*/
|
*/
|
||||||
export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
|
export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
|
||||||
state: IGristUrlState, baseLocation: Location | URL): string {
|
state: IGristUrlState, baseLocation: Location | URL,
|
||||||
|
options: {
|
||||||
|
// make an api url - warning: just barely works, and
|
||||||
|
// only for documents
|
||||||
|
api?: boolean
|
||||||
|
} = {}): string {
|
||||||
const url = new URL(baseLocation.href);
|
const url = new URL(baseLocation.href);
|
||||||
const parts = ['/'];
|
const parts = ['/'];
|
||||||
|
|
||||||
@ -193,9 +198,14 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.api) {
|
||||||
|
parts.push(`api/`);
|
||||||
|
}
|
||||||
if (state.ws) { parts.push(`ws/${state.ws}/`); }
|
if (state.ws) { parts.push(`ws/${state.ws}/`); }
|
||||||
if (state.doc) {
|
if (state.doc) {
|
||||||
if (state.slug) {
|
if (options.api) {
|
||||||
|
parts.push(`docs/${encodeURIComponent(state.doc)}`);
|
||||||
|
} else if (state.slug) {
|
||||||
parts.push(`${encodeURIComponent(state.doc)}/${encodeURIComponent(state.slug)}`);
|
parts.push(`${encodeURIComponent(state.doc)}/${encodeURIComponent(state.slug)}`);
|
||||||
} else {
|
} else {
|
||||||
parts.push(`doc/${encodeURIComponent(state.doc)}`);
|
parts.push(`doc/${encodeURIComponent(state.doc)}`);
|
||||||
|
@ -7,6 +7,7 @@ import { HomeDBManager } from "app/gen-server/lib/HomeDBManager";
|
|||||||
import { assertAccess, getOrSetDocAuth, getTransitiveHeaders, RequestWithLogin } from 'app/server/lib/Authorizer';
|
import { assertAccess, getOrSetDocAuth, getTransitiveHeaders, RequestWithLogin } from 'app/server/lib/Authorizer';
|
||||||
import { IDocWorkerMap } from "app/server/lib/DocWorkerMap";
|
import { IDocWorkerMap } from "app/server/lib/DocWorkerMap";
|
||||||
import { expressWrap } from "app/server/lib/expressWrap";
|
import { expressWrap } from "app/server/lib/expressWrap";
|
||||||
|
import { GristServer } from "app/server/lib/GristServer";
|
||||||
import { getAssignmentId } from "app/server/lib/idUtils";
|
import { getAssignmentId } from "app/server/lib/idUtils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -25,7 +26,8 @@ import { getAssignmentId } from "app/server/lib/idUtils";
|
|||||||
*/
|
*/
|
||||||
export class DocApiForwarder {
|
export class DocApiForwarder {
|
||||||
|
|
||||||
constructor(private _docWorkerMap: IDocWorkerMap, private _dbManager: HomeDBManager) {
|
constructor(private _docWorkerMap: IDocWorkerMap, private _dbManager: HomeDBManager,
|
||||||
|
private _gristServer: GristServer) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public addEndpoints(app: express.Application) {
|
public addEndpoints(app: express.Application) {
|
||||||
@ -61,7 +63,8 @@ export class DocApiForwarder {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let docId: string|null = null;
|
let docId: string|null = null;
|
||||||
if (withDocId) {
|
if (withDocId) {
|
||||||
const docAuth = await getOrSetDocAuth(req as RequestWithLogin, this._dbManager, req.params.docId);
|
const docAuth = await getOrSetDocAuth(req as RequestWithLogin, this._dbManager,
|
||||||
|
this._gristServer, req.params.docId);
|
||||||
if (role) {
|
if (role) {
|
||||||
assertAccess(role, docAuth, {allowRemoved: true});
|
assertAccess(role, docAuth, {allowRemoved: true});
|
||||||
}
|
}
|
||||||
|
@ -137,8 +137,8 @@ class DummyDocWorkerMap implements IDocWorkerMap {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRedisClient(): RedisClient {
|
public getRedisClient() {
|
||||||
throw new Error("No redis client here");
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ export const GristDocAPI = t.iface([], {
|
|||||||
"listTables": t.func(t.array("string")),
|
"listTables": t.func(t.array("string")),
|
||||||
"fetchTable": t.func("any", t.param("tableId", "string")),
|
"fetchTable": t.func("any", t.param("tableId", "string")),
|
||||||
"applyUserActions": t.func("any", t.param("actions", t.array(t.array("any"))), t.param("options", "any", true)),
|
"applyUserActions": t.func("any", t.param("actions", t.array(t.array("any"))), t.param("options", "any", true)),
|
||||||
|
"getAccessToken": t.func("AccessTokenResult", t.param("options", "AccessTokenOptions")),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GristView = t.iface([], {
|
export const GristView = t.iface([], {
|
||||||
@ -27,10 +28,22 @@ export const GristView = t.iface([], {
|
|||||||
"setSelectedRows": t.func("void", t.param("rowIds", t.array("number"))),
|
"setSelectedRows": t.func("void", t.param("rowIds", t.array("number"))),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const AccessTokenOptions = t.iface([], {
|
||||||
|
"readOnly": t.opt("boolean"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AccessTokenResult = t.iface([], {
|
||||||
|
"token": "string",
|
||||||
|
"baseUrl": "string",
|
||||||
|
"ttlMsecs": "number",
|
||||||
|
});
|
||||||
|
|
||||||
const exportedTypeSuite: t.ITypeSuite = {
|
const exportedTypeSuite: t.ITypeSuite = {
|
||||||
ComponentKind,
|
ComponentKind,
|
||||||
GristAPI,
|
GristAPI,
|
||||||
GristDocAPI,
|
GristDocAPI,
|
||||||
GristView,
|
GristView,
|
||||||
|
AccessTokenOptions,
|
||||||
|
AccessTokenResult,
|
||||||
};
|
};
|
||||||
export default exportedTypeSuite;
|
export default exportedTypeSuite;
|
||||||
|
@ -97,6 +97,11 @@ export interface GristDocAPI {
|
|||||||
applyUserActions(actions: any[][], options?: any): Promise<any>;
|
applyUserActions(actions: any[][], options?: any): Promise<any>;
|
||||||
// TODO: return type should be Promise<ApplyUAResult>, but this requires importing
|
// TODO: return type should be Promise<ApplyUAResult>, but this requires importing
|
||||||
// modules from `app/common` which is not currently supported by the build.
|
// modules from `app/common` which is not currently supported by the build.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a token for out-of-band access to the document.
|
||||||
|
*/
|
||||||
|
getAccessToken(options: AccessTokenOptions): Promise<AccessTokenResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -127,3 +132,13 @@ export interface GristView {
|
|||||||
*/
|
*/
|
||||||
setSelectedRows(rowIds: number[]): Promise<void>;
|
setSelectedRows(rowIds: number[]): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AccessTokenOptions {
|
||||||
|
readOnly?: boolean; // restrict use of token to reading.
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessTokenResult {
|
||||||
|
token: string; // token string
|
||||||
|
baseUrl: string; // url of document api, like https://..../api/docs/DOCID
|
||||||
|
ttlMsecs: number; // number of milliseconds token will be valid for (typically several minutes)
|
||||||
|
}
|
||||||
|
@ -20,7 +20,8 @@
|
|||||||
|
|
||||||
import { ColumnsToMap, CustomSectionAPI, InteractionOptions, InteractionOptionsRequest,
|
import { ColumnsToMap, CustomSectionAPI, InteractionOptions, InteractionOptionsRequest,
|
||||||
WidgetColumnMap } from './CustomSectionAPI';
|
WidgetColumnMap } from './CustomSectionAPI';
|
||||||
import { GristAPI, GristDocAPI, GristView, RPC_GRISTAPI_INTERFACE } from './GristAPI';
|
import { AccessTokenOptions, AccessTokenResult, GristAPI, GristDocAPI,
|
||||||
|
GristView, RPC_GRISTAPI_INTERFACE } from './GristAPI';
|
||||||
import { RowRecord } from './GristData';
|
import { RowRecord } from './GristData';
|
||||||
import { ImportSource, ImportSourceAPI, InternalImportSourceAPI } from './InternalImportSourceAPI';
|
import { ImportSource, ImportSourceAPI, InternalImportSourceAPI } from './InternalImportSourceAPI';
|
||||||
import { decodeObject, mapValues } from './objtypes';
|
import { decodeObject, mapValues } from './objtypes';
|
||||||
@ -158,6 +159,14 @@ export function getTable(tableId?: string): TableOperations {
|
|||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an access token, for making API calls outside of the custom widget
|
||||||
|
* API. There is no caching of tokens.
|
||||||
|
*/
|
||||||
|
export async function getAccessToken(options?: AccessTokenOptions): Promise<AccessTokenResult> {
|
||||||
|
return docApi.getAccessToken(options || {});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current selected table (for custom widgets).
|
* Get the current selected table (for custom widgets).
|
||||||
*/
|
*/
|
||||||
|
267
app/server/lib/AccessTokens.ts
Normal file
267
app/server/lib/AccessTokens.ts
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
import { ApiError } from 'app/common/ApiError';
|
||||||
|
import { MapWithTTL } from 'app/common/AsyncCreate';
|
||||||
|
import { KeyedMutex } from 'app/common/KeyedMutex';
|
||||||
|
import { AccessTokenOptions } from 'app/plugin/GristAPI';
|
||||||
|
import { makeId } from 'app/server/lib/idUtils';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
import { RedisClient } from 'redis';
|
||||||
|
|
||||||
|
export const Deps = {
|
||||||
|
// Signed tokens expire after this length of time.
|
||||||
|
TOKEN_TTL_MSECS: 15 * 60 * 1000, // 15 minutes.
|
||||||
|
MAX_SECRETS_KEPT: 3, // Maximum number of secrets stored per doc.
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-optional information embedded in an access token. Currently
|
||||||
|
* access tokens are tied to an individual user and document. In
|
||||||
|
* future, they could be used outside of the context of a single
|
||||||
|
* document.
|
||||||
|
*
|
||||||
|
* Includes fields from AccessTokenOptions.
|
||||||
|
*/
|
||||||
|
export interface AccessTokenInfo extends AccessTokenOptions {
|
||||||
|
userId: number;
|
||||||
|
docId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access token services.
|
||||||
|
*/
|
||||||
|
export interface IAccessTokens {
|
||||||
|
/**
|
||||||
|
* Sign the content of an access token, returning a plain jwt-format
|
||||||
|
* string. A per-document secret will be used for signing.
|
||||||
|
*/
|
||||||
|
sign(content: AccessTokenInfo): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the content of a token, verifying its signature.
|
||||||
|
*/
|
||||||
|
verify(token: string): Promise<AccessTokenInfo>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check how long access tokens remain valid, once minted.
|
||||||
|
*/
|
||||||
|
getNominalTTLInMsec(): number;
|
||||||
|
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of access token services. Write operations should
|
||||||
|
* be done by a doc worker responsible for the document involved.
|
||||||
|
* Read operations can occur anywhere, such as home servers.
|
||||||
|
* This class has caches for _reads and _writes that are kept
|
||||||
|
* separate so that we don't have to reason about interactions
|
||||||
|
* between them. The class could be separated into two, one just
|
||||||
|
* for reading, and one just for writing.
|
||||||
|
*
|
||||||
|
* Token lifetime is handled by JWT expiration. Secret lifetime is
|
||||||
|
* handled by maintaining a rolling list of secrets (per document)
|
||||||
|
* that are replaced over time.
|
||||||
|
*
|
||||||
|
* Redis is used if available so that tokens issued by a worker will
|
||||||
|
* be honored by its replacement (within the token's period of validity).
|
||||||
|
*
|
||||||
|
* Secrets may last for a while. How long they last may vary with usage.
|
||||||
|
* A new secret is added when a local cache of signing secrets expires.
|
||||||
|
* Older secrets rotate out. For example, if we sign a token, then don't
|
||||||
|
* sign another until 0.9 * factor * TOKEN_TTL_MSECS later, a new token
|
||||||
|
* will used but the older one preserved. We could do the same
|
||||||
|
* MAX_SECRETS_KEPT-2 more times until the original secret is lost.
|
||||||
|
* This gives an overall lifetime of about factor * TOKEN_TTL_MSECS * MAX_SECRETS_KEPT.
|
||||||
|
* A secret could have a shorter lifetime of about factor * TOKEN_TTL_MSECS
|
||||||
|
* if we didn't sign anything else for a bit longer. So there's quite some
|
||||||
|
* variation, but the important thing is that secrets aren't lingering for
|
||||||
|
* many orders of magnitude more than the lifetime of the tokens they sign.
|
||||||
|
*/
|
||||||
|
export class AccessTokens implements IAccessTokens {
|
||||||
|
private _store: IAccessTokenSignerStore; // a redis or in-memory "back end".
|
||||||
|
private _reads: MapWithTTL<string, string[]>; // a cache of recent reads.
|
||||||
|
private _writes: MapWithTTL<string, string[]>; // a cache of recent writes.
|
||||||
|
private _dtMsec: number; // the duration for which tokens must be honored.
|
||||||
|
private _mutex = new KeyedMutex(); // logic is simpler if serialized.
|
||||||
|
|
||||||
|
// Use redis if available. Cache reads or writes for some multiple of the duration for which
|
||||||
|
// tokens must be honored. Cache is of a list of secrets. It is important to allow multiple
|
||||||
|
// secrets so we can change the secret we are signing with and still honor tokens signed with
|
||||||
|
// a previous secret.
|
||||||
|
constructor(cli: RedisClient|null, private _factor: number = 10) {
|
||||||
|
this._store = cli ? new RedisAccessTokenSignerStore(cli) : new InMemoryAccessTokenSignerStore();
|
||||||
|
this._dtMsec = Deps.TOKEN_TTL_MSECS;
|
||||||
|
this._reads = new MapWithTTL<string, string[]>(this._dtMsec * _factor * 0.5);
|
||||||
|
this._writes = new MapWithTTL<string, string[]>(this._dtMsec * _factor * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the duration we promise to honor a token for (although we may
|
||||||
|
// honor it for longer).
|
||||||
|
public getNominalTTLInMsec() {
|
||||||
|
return this._dtMsec;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign a token. We use JWT, and use its built-in expiration time.
|
||||||
|
public async sign(content: AccessTokenInfo): Promise<string> {
|
||||||
|
const encoder = await this._getOrCreateSecret(content.docId);
|
||||||
|
return jwt.sign(content, encoder, { expiresIn: this._dtMsec / 1000.0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a token is valid. Since the secret used to sign it is dependent
|
||||||
|
* on the docId, we decode the token first to see what document it is claiming
|
||||||
|
* to be for. Then we try to verify the token with all the secrets known for
|
||||||
|
* that doc. Upon failure, we make sure the secret list is up to date and try
|
||||||
|
* again. There is room for optimizing here!
|
||||||
|
*/
|
||||||
|
public async verify(token: string): Promise<AccessTokenInfo> {
|
||||||
|
const content = jwt.decode(token);
|
||||||
|
if (typeof content !== 'object') {
|
||||||
|
throw new ApiError('Broken token', 401);
|
||||||
|
}
|
||||||
|
const docId = content?.docId as string;
|
||||||
|
if (typeof docId !== 'string' || !docId) {
|
||||||
|
throw new ApiError('Broken token', 401);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Try to verify with the secrets we already know about.
|
||||||
|
return await this._verifyWithGivenDoc(docId, token);
|
||||||
|
} catch (e) {
|
||||||
|
// Retry with up-to-date secrets.
|
||||||
|
await this._refreshSecrets(docId);
|
||||||
|
return await this._verifyWithGivenDoc(docId, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async close() {
|
||||||
|
await this._store.close();
|
||||||
|
this._reads.clear();
|
||||||
|
this._writes.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _verifyWithGivenDoc(docId: string, token: string): Promise<AccessTokenInfo> {
|
||||||
|
const secrets = this._reads.get(docId) || [];
|
||||||
|
for (const secret of secrets) {
|
||||||
|
try {
|
||||||
|
return this._verifyWithGivenSecret(secret, token);
|
||||||
|
} catch (e) {
|
||||||
|
if (String(e).match(/Token has expired/)) {
|
||||||
|
// Give specific error about token expiration.
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
// continue, to try another secret.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new ApiError('Cannot verify token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _verifyWithGivenSecret(secret: string, token: string): AccessTokenInfo {
|
||||||
|
try {
|
||||||
|
const content: any = jwt.verify(token, secret);
|
||||||
|
if (typeof content !== 'object') {
|
||||||
|
throw new ApiError('Token mismatch', 401);
|
||||||
|
}
|
||||||
|
const userId = content.userId;
|
||||||
|
const docId = content.docId;
|
||||||
|
if (!userId) { throw new ApiError('no userId in access token', 401); }
|
||||||
|
if (!docId) { throw new ApiError('no docId in access token', 401); }
|
||||||
|
return content as AccessTokenInfo;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'TokenExpiredError') {
|
||||||
|
throw new ApiError('Token has expired', 401);
|
||||||
|
}
|
||||||
|
throw new ApiError('Cannot verify token', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a secret to sign with. The secret needs to be
|
||||||
|
* valid for longer than dtMsec, so it is available
|
||||||
|
* for verifying the signed token throughout its
|
||||||
|
* lifetime.
|
||||||
|
*
|
||||||
|
* We maintain a truncated list of secrets, signing
|
||||||
|
* with the most recent, and verifying against any.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
private async _getOrCreateSecret(docId: string): Promise<string> {
|
||||||
|
return this._mutex.runExclusive(docId, async () => {
|
||||||
|
let secrets = this._writes.get(docId);
|
||||||
|
if (secrets && secrets.length >= 1) {
|
||||||
|
return secrets[0];
|
||||||
|
}
|
||||||
|
// Our local cache of secrets to sign with is empty.
|
||||||
|
secrets = await this._store.getSigners(docId);
|
||||||
|
secrets.unshift(this._mintSecret());
|
||||||
|
secrets.splice(Deps.MAX_SECRETS_KEPT);
|
||||||
|
this._writes.set(docId, secrets);
|
||||||
|
await this._store.setSigners(docId, secrets, this._dtMsec * this._factor);
|
||||||
|
return secrets[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _refreshSecrets(docId: string): Promise<void> {
|
||||||
|
const inv = await this._store.getSigners(docId);
|
||||||
|
this._reads.set(docId, inv);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _mintSecret(): string {
|
||||||
|
return makeId() + makeId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a list of signing secrets globally. Light wrapper over redis or memory.
|
||||||
|
*/
|
||||||
|
export interface IAccessTokenSignerStore {
|
||||||
|
getSigners(docId: string): Promise<string[]>;
|
||||||
|
setSigners(docId: string, secret: string[], ttlMsec: number): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory implementation of IAccessTokenSignerStore, usable for single-process Grist.
|
||||||
|
// One limitation is that restarted processes won't honor tokens created by predecessor.
|
||||||
|
export class InMemoryAccessTokenSignerStore implements IAccessTokenSignerStore {
|
||||||
|
private static _keys = new MapWithTTL<string, string[]>(Deps.TOKEN_TTL_MSECS);
|
||||||
|
private static _refCount: number = 0;
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
InMemoryAccessTokenSignerStore._refCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSigners(docId: string): Promise<string[]> {
|
||||||
|
return InMemoryAccessTokenSignerStore._keys.get(docId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setSigners(docId: string, secrets: string[], ttlMsec: number): Promise<void> {
|
||||||
|
InMemoryAccessTokenSignerStore._keys.setWithCustomTTL(docId, secrets, ttlMsec);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async close() {
|
||||||
|
InMemoryAccessTokenSignerStore._refCount--;
|
||||||
|
if (InMemoryAccessTokenSignerStore._refCount <= 0) {
|
||||||
|
InMemoryAccessTokenSignerStore._keys.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis based implementation of IAccessTokenSignerStore, for multi process/instance
|
||||||
|
// Grist.
|
||||||
|
export class RedisAccessTokenSignerStore implements IAccessTokenSignerStore {
|
||||||
|
constructor(private _cli: RedisClient) { }
|
||||||
|
|
||||||
|
public async getSigners(docId: string): Promise<string[]> {
|
||||||
|
const keys = await this._cli.getAsync(this._getKey(docId));
|
||||||
|
return keys?.split(',') || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setSigners(docId: string, secrets: string[], ttlMsec: number): Promise<void> {
|
||||||
|
await this._cli.setexAsync(this._getKey(docId), ttlMsec, secrets.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async close() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getKey(docId: string) {
|
||||||
|
return `token-doc-decoder-${docId}`;
|
||||||
|
}
|
||||||
|
}
|
@ -59,6 +59,7 @@ import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccess
|
|||||||
import {parseUrlId} from 'app/common/gristUrls';
|
import {parseUrlId} from 'app/common/gristUrls';
|
||||||
import {byteString, countIf, retryOnce, safeJsonParse} from 'app/common/gutil';
|
import {byteString, countIf, retryOnce, safeJsonParse} from 'app/common/gutil';
|
||||||
import {InactivityTimer} from 'app/common/InactivityTimer';
|
import {InactivityTimer} from 'app/common/InactivityTimer';
|
||||||
|
import * as roles from 'app/common/roles';
|
||||||
import {schema, SCHEMA_VERSION} from 'app/common/schema';
|
import {schema, SCHEMA_VERSION} from 'app/common/schema';
|
||||||
import {MetaRowRecord, SingleCell} from 'app/common/TableData';
|
import {MetaRowRecord, SingleCell} from 'app/common/TableData';
|
||||||
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
||||||
@ -67,7 +68,7 @@ import {convertFromColumn} from 'app/common/ValueConverter';
|
|||||||
import {guessColInfoWithDocData} from 'app/common/ValueGuesser';
|
import {guessColInfoWithDocData} from 'app/common/ValueGuesser';
|
||||||
import {parseUserAction} from 'app/common/ValueParser';
|
import {parseUserAction} from 'app/common/ValueParser';
|
||||||
import {ParseOptions} from 'app/plugin/FileParserAPI';
|
import {ParseOptions} from 'app/plugin/FileParserAPI';
|
||||||
import {GristDocAPI} from 'app/plugin/GristAPI';
|
import {AccessTokenOptions, AccessTokenResult, GristDocAPI} from 'app/plugin/GristAPI';
|
||||||
import {compileAclFormula} from 'app/server/lib/ACLFormula';
|
import {compileAclFormula} from 'app/server/lib/ACLFormula';
|
||||||
import {Authorizer} from 'app/server/lib/Authorizer';
|
import {Authorizer} from 'app/server/lib/Authorizer';
|
||||||
import {checksumFile} from 'app/server/lib/checksumFile';
|
import {checksumFile} from 'app/server/lib/checksumFile';
|
||||||
@ -102,6 +103,7 @@ import {DocClients} from './DocClients';
|
|||||||
import {DocPluginManager} from './DocPluginManager';
|
import {DocPluginManager} from './DocPluginManager';
|
||||||
import {
|
import {
|
||||||
DocSession,
|
DocSession,
|
||||||
|
getDocSessionAccess,
|
||||||
getDocSessionUser,
|
getDocSessionUser,
|
||||||
getDocSessionUserId,
|
getDocSessionUserId,
|
||||||
makeExceptionalDocSession,
|
makeExceptionalDocSession,
|
||||||
@ -1350,6 +1352,33 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
return forkIds;
|
return forkIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getAccessToken(docSession: OptDocSession, options: AccessTokenOptions): Promise<AccessTokenResult> {
|
||||||
|
const tokens = this._docManager.gristServer.getAccessTokens();
|
||||||
|
const userId = getDocSessionUserId(docSession);
|
||||||
|
const docId = this.docName;
|
||||||
|
const access = getDocSessionAccess(docSession);
|
||||||
|
// If we happen to be using a "readOnly" connection, max out at "readOnly"
|
||||||
|
// even if user could do more.
|
||||||
|
if (roles.getStrongestRole('viewers', access) === 'viewers') {
|
||||||
|
options.readOnly = true;
|
||||||
|
}
|
||||||
|
// Return a token that can be used to authorize as the given user.
|
||||||
|
if (!userId) { throw new Error('creating access token requires a user'); }
|
||||||
|
const token = await tokens.sign({
|
||||||
|
readOnly: options.readOnly,
|
||||||
|
userId, // definitely do not want userId overridable by options.
|
||||||
|
docId, // likewise for docId.
|
||||||
|
});
|
||||||
|
const ttlMsecs = tokens.getNominalTTLInMsec();
|
||||||
|
const baseUrl = this._options?.docApiUrl;
|
||||||
|
if (!baseUrl) { throw new Error('cannot create token without URLs'); }
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
baseUrl,
|
||||||
|
ttlMsecs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an ACL formula is valid. If not, will throw an error with an explanation.
|
* Check if an ACL formula is valid. If not, will throw an error with an explanation.
|
||||||
*/
|
*/
|
||||||
|
@ -11,11 +11,13 @@ import {DocAuthKey, DocAuthResult, HomeDBManager} from 'app/gen-server/lib/HomeD
|
|||||||
import {forceSessionChange, getSessionProfiles, getSessionUser, getSignInStatus, linkOrgWithEmail, SessionObj,
|
import {forceSessionChange, getSessionProfiles, getSessionUser, getSignInStatus, linkOrgWithEmail, SessionObj,
|
||||||
SessionUserObj, SignInStatus} from 'app/server/lib/BrowserSession';
|
SessionUserObj, SignInStatus} from 'app/server/lib/BrowserSession';
|
||||||
import {RequestWithOrg} from 'app/server/lib/extractOrg';
|
import {RequestWithOrg} from 'app/server/lib/extractOrg';
|
||||||
|
import {GristServer} from 'app/server/lib/GristServer';
|
||||||
import {COOKIE_MAX_AGE, getAllowedOrgForSessionID, getCookieDomain,
|
import {COOKIE_MAX_AGE, getAllowedOrgForSessionID, getCookieDomain,
|
||||||
cookieName as sessionCookieName} from 'app/server/lib/gristSessions';
|
cookieName as sessionCookieName} from 'app/server/lib/gristSessions';
|
||||||
import {makeId} from 'app/server/lib/idUtils';
|
import {makeId} from 'app/server/lib/idUtils';
|
||||||
import log from 'app/server/lib/log';
|
import log from 'app/server/lib/log';
|
||||||
import {IPermitStore, Permit} from 'app/server/lib/Permit';
|
import {IPermitStore, Permit} from 'app/server/lib/Permit';
|
||||||
|
import {AccessTokenInfo} from 'app/server/lib/AccessTokens';
|
||||||
import {allowHost, getOriginUrl, optStringParam} from 'app/server/lib/requestUtils';
|
import {allowHost, getOriginUrl, optStringParam} from 'app/server/lib/requestUtils';
|
||||||
import * as cookie from 'cookie';
|
import * as cookie from 'cookie';
|
||||||
import {NextFunction, Request, RequestHandler, Response} from 'express';
|
import {NextFunction, Request, RequestHandler, Response} from 'express';
|
||||||
@ -34,6 +36,7 @@ export interface RequestWithLogin extends Request {
|
|||||||
userIsAuthorized?: boolean; // If userId is for "anonymous", this will be false.
|
userIsAuthorized?: boolean; // If userId is for "anonymous", this will be false.
|
||||||
docAuth?: DocAuthResult; // For doc requests, the docId and the user's access level.
|
docAuth?: DocAuthResult; // For doc requests, the docId and the user's access level.
|
||||||
specialPermit?: Permit;
|
specialPermit?: Permit;
|
||||||
|
accessToken?: AccessTokenInfo;
|
||||||
altSessionId?: string; // a session id for use in trigger formulas and granular access rules
|
altSessionId?: string; // a session id for use in trigger formulas and granular access rules
|
||||||
activation?: ActivationState;
|
activation?: ActivationState;
|
||||||
}
|
}
|
||||||
@ -143,6 +146,7 @@ export function getRequestProfile(req: Request|IncomingMessage,
|
|||||||
*/
|
*/
|
||||||
export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPermitStore,
|
export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPermitStore,
|
||||||
options: {
|
options: {
|
||||||
|
gristServer: GristServer,
|
||||||
skipSession?: boolean,
|
skipSession?: boolean,
|
||||||
getProfile?(req: Request|IncomingMessage): Promise<UserProfile|null|undefined>,
|
getProfile?(req: Request|IncomingMessage): Promise<UserProfile|null|undefined>,
|
||||||
},
|
},
|
||||||
@ -150,8 +154,27 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
|
|||||||
const mreq = req as RequestWithLogin;
|
const mreq = req as RequestWithLogin;
|
||||||
let profile: UserProfile|undefined;
|
let profile: UserProfile|undefined;
|
||||||
|
|
||||||
// First, check for an apiKey
|
// We support multiple method of authentication. This flag gets set once
|
||||||
if (mreq.headers && mreq.headers.authorization) {
|
// we need not try any more. Specifically, it is used to avoid processing
|
||||||
|
// anything else after setting an access token, for simplicity in reasoning
|
||||||
|
// about this case.
|
||||||
|
let authDone: boolean = false;
|
||||||
|
|
||||||
|
// Support providing an access token via an `auth` query parameter.
|
||||||
|
// This is useful for letting the browser load assets like image
|
||||||
|
// attachments.
|
||||||
|
const auth = optStringParam(mreq.query.auth);
|
||||||
|
if (auth) {
|
||||||
|
const tokens = options.gristServer.getAccessTokens();
|
||||||
|
const token = await tokens.verify(auth);
|
||||||
|
mreq.accessToken = token;
|
||||||
|
// Once an accessToken is supplied, we don't consider anything else.
|
||||||
|
// User is treated as anonymous apart from having an accessToken.
|
||||||
|
authDone = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now, check for an apiKey
|
||||||
|
if (!authDone && mreq.headers && mreq.headers.authorization) {
|
||||||
// header needs to be of form "Bearer XXXXXXXXX" to apply
|
// header needs to be of form "Bearer XXXXXXXXX" to apply
|
||||||
const parts = String(mreq.headers.authorization).split(' ');
|
const parts = String(mreq.headers.authorization).split(' ');
|
||||||
if (parts[0] === "Bearer") {
|
if (parts[0] === "Bearer") {
|
||||||
@ -172,7 +195,7 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Special permission header for internal housekeeping tasks
|
// Special permission header for internal housekeeping tasks
|
||||||
if (mreq.headers && mreq.headers.permit) {
|
if (!authDone && mreq.headers && mreq.headers.permit) {
|
||||||
const permitKey = String(mreq.headers.permit);
|
const permitKey = String(mreq.headers.permit);
|
||||||
try {
|
try {
|
||||||
const permit = await permitStore.getPermit(permitKey);
|
const permit = await permitStore.getPermit(permitKey);
|
||||||
@ -193,13 +216,13 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
|
|||||||
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#use-of-custom-request-headers
|
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#use-of-custom-request-headers
|
||||||
// https://markitzeroday.com/x-requested-with/cors/2017/06/29/csrf-mitigation-for-ajax-requests.html
|
// https://markitzeroday.com/x-requested-with/cors/2017/06/29/csrf-mitigation-for-ajax-requests.html
|
||||||
if (!mreq.userId && !mreq.xhr && !['GET', 'HEAD', 'OPTIONS'].includes(mreq.method)) {
|
if (!mreq.userId && !mreq.xhr && !['GET', 'HEAD', 'OPTIONS'].includes(mreq.method)) {
|
||||||
return res.status(401).send('Bad request (missing header)');
|
return res.status(401).json({error: 'Bad request (missing header)'});
|
||||||
}
|
}
|
||||||
|
|
||||||
// For some configurations, the user profile can be determined from the request.
|
// For some configurations, the user profile can be determined from the request.
|
||||||
// If this is the case, we won't use session information.
|
// If this is the case, we won't use session information.
|
||||||
let skipSession: boolean = options.skipSession || false;
|
let skipSession: boolean = options.skipSession || authDone;
|
||||||
if (!mreq.userId) {
|
if (!authDone && !mreq.userId) {
|
||||||
let candidate = await options.getProfile?.(mreq);
|
let candidate = await options.getProfile?.(mreq);
|
||||||
if (candidate === undefined) {
|
if (candidate === undefined) {
|
||||||
candidate = getRequestProfile(mreq);
|
candidate = getRequestProfile(mreq);
|
||||||
@ -223,7 +246,7 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
|
|||||||
// for custom-host-specific sessionID.
|
// for custom-host-specific sessionID.
|
||||||
let customHostSession = '';
|
let customHostSession = '';
|
||||||
|
|
||||||
if (!skipSession) {
|
if (!authDone && !skipSession) {
|
||||||
// If we haven't selected a user by other means, and have profiles available in the
|
// If we haven't selected a user by other means, and have profiles available in the
|
||||||
// session, then select a user based on those profiles.
|
// session, then select a user based on those profiles.
|
||||||
const session = mreq.session;
|
const session = mreq.session;
|
||||||
@ -429,14 +452,38 @@ export function redirectToLogin(
|
|||||||
* Sets mreq.docAuth if not yet set, and returns it.
|
* Sets mreq.docAuth if not yet set, and returns it.
|
||||||
*/
|
*/
|
||||||
export async function getOrSetDocAuth(
|
export async function getOrSetDocAuth(
|
||||||
mreq: RequestWithLogin, dbManager: HomeDBManager, urlId: string
|
mreq: RequestWithLogin, dbManager: HomeDBManager,
|
||||||
|
gristServer: GristServer,
|
||||||
|
urlId: string
|
||||||
): Promise<DocAuthResult> {
|
): Promise<DocAuthResult> {
|
||||||
if (!mreq.docAuth) {
|
if (!mreq.docAuth) {
|
||||||
let effectiveUserId = getUserId(mreq);
|
let effectiveUserId = getUserId(mreq);
|
||||||
if (mreq.specialPermit && mreq.userId === dbManager.getAnonymousUserId()) {
|
if (mreq.specialPermit && mreq.userId === dbManager.getAnonymousUserId()) {
|
||||||
effectiveUserId = dbManager.getPreviewerUserId();
|
effectiveUserId = dbManager.getPreviewerUserId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A permit with a token gives us the userId associated with that token.
|
||||||
|
const tokenObj = mreq.accessToken;
|
||||||
|
if (tokenObj) {
|
||||||
|
effectiveUserId = tokenObj.userId;
|
||||||
|
}
|
||||||
|
|
||||||
mreq.docAuth = await dbManager.getDocAuthCached({urlId, userId: effectiveUserId, org: mreq.org});
|
mreq.docAuth = await dbManager.getDocAuthCached({urlId, userId: effectiveUserId, org: mreq.org});
|
||||||
|
|
||||||
|
if (tokenObj) {
|
||||||
|
// Sanity check: does the current document match the document the token is
|
||||||
|
// for? If not, fail.
|
||||||
|
if (!mreq.docAuth.docId || mreq.docAuth.docId !== tokenObj.docId) {
|
||||||
|
throw new ApiError('token misuse', 401);
|
||||||
|
}
|
||||||
|
// Limit access to read-only if specified.
|
||||||
|
if (tokenObj.readOnly) {
|
||||||
|
mreq.docAuth = {...mreq.docAuth, access: getWeakestRole('viewers', mreq.docAuth.access)};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A permit with a user set to the anonymous user and linked to this document
|
||||||
|
// gets updated to full access.
|
||||||
if (mreq.specialPermit && mreq.userId === dbManager.getAnonymousUserId() &&
|
if (mreq.specialPermit && mreq.userId === dbManager.getAnonymousUserId() &&
|
||||||
mreq.specialPermit.docId === mreq.docAuth.docId) {
|
mreq.specialPermit.docId === mreq.docAuth.docId) {
|
||||||
mreq.docAuth = {...mreq.docAuth, access: 'owners'};
|
mreq.docAuth = {...mreq.docAuth, access: 'owners'};
|
||||||
|
@ -890,7 +890,9 @@ export class DocWorkerApi {
|
|||||||
|
|
||||||
// Note the increased API usage on redis and in our local cache.
|
// Note the increased API usage on redis and in our local cache.
|
||||||
// Update redis in the background so that the rest of the request can continue without waiting for redis.
|
// Update redis in the background so that the rest of the request can continue without waiting for redis.
|
||||||
const multi = this._docWorkerMap.getRedisClient().multi();
|
const cli = this._docWorkerMap.getRedisClient();
|
||||||
|
if (!cli) { throw new Error('redis unexpectedly not available'); }
|
||||||
|
const multi = cli.multi();
|
||||||
for (let i = 0; i < keys.length; i++) {
|
for (let i = 0; i < keys.length; i++) {
|
||||||
const key = keys[i];
|
const key = keys[i];
|
||||||
// Incrementing the local count immediately prevents many requests from being squeezed through every minute
|
// Incrementing the local count immediately prevents many requests from being squeezed through every minute
|
||||||
@ -922,7 +924,7 @@ export class DocWorkerApi {
|
|||||||
req: Request, res: Response, next: NextFunction) {
|
req: Request, res: Response, next: NextFunction) {
|
||||||
const scope = getDocScope(req);
|
const scope = getDocScope(req);
|
||||||
allowRemoved = scope.showAll || scope.showRemoved || allowRemoved;
|
allowRemoved = scope.showAll || scope.showRemoved || allowRemoved;
|
||||||
const docAuth = await getOrSetDocAuth(req as RequestWithLogin, this._dbManager, scope.urlId);
|
const docAuth = await getOrSetDocAuth(req as RequestWithLogin, this._dbManager, this._grist, scope.urlId);
|
||||||
if (role) { assertAccess(role, docAuth, {allowRemoved}); }
|
if (role) { assertAccess(role, docAuth, {allowRemoved}); }
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
@ -932,7 +934,7 @@ export class DocWorkerApi {
|
|||||||
*/
|
*/
|
||||||
private async _isOwner(req: Request) {
|
private async _isOwner(req: Request) {
|
||||||
const scope = getDocScope(req);
|
const scope = getDocScope(req);
|
||||||
const docAuth = await getOrSetDocAuth(req as RequestWithLogin, this._dbManager, scope.urlId);
|
const docAuth = await getOrSetDocAuth(req as RequestWithLogin, this._dbManager, this._grist, scope.urlId);
|
||||||
return docAuth.access === 'owners';
|
return docAuth.access === 'owners';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -511,9 +511,12 @@ export class DocManager extends EventEmitter {
|
|||||||
return await db.getRawDocById(docName);
|
return await db.getRawDocById(docName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _getDocUrl(doc: Document) {
|
private async _getDocUrls(doc: Document) {
|
||||||
try {
|
try {
|
||||||
return await this.gristServer.getResourceUrl(doc);
|
return {
|
||||||
|
docUrl: await this.gristServer.getResourceUrl(doc),
|
||||||
|
docApiUrl: await this.gristServer.getResourceUrl(doc, 'api'),
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If there is no home url, we cannot construct links. Accept this, for the benefit
|
// If there is no home url, we cannot construct links. Accept this, for the benefit
|
||||||
// of legacy tests.
|
// of legacy tests.
|
||||||
@ -526,8 +529,8 @@ export class DocManager extends EventEmitter {
|
|||||||
private async _createActiveDoc(docSession: OptDocSession, docName: string, safeMode?: boolean) {
|
private async _createActiveDoc(docSession: OptDocSession, docName: string, safeMode?: boolean) {
|
||||||
const doc = await this._getDoc(docSession, docName);
|
const doc = await this._getDoc(docSession, docName);
|
||||||
// Get URL for document for use with SELF_HYPERLINK().
|
// Get URL for document for use with SELF_HYPERLINK().
|
||||||
const docUrl = doc && await this._getDocUrl(doc);
|
const docUrls = doc && await this._getDocUrls(doc);
|
||||||
return new ActiveDoc(this, docName, {docUrl, safeMode, doc});
|
return new ActiveDoc(this, docName, {...docUrls, safeMode, doc});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -6,7 +6,7 @@ import { createRpcLogger, PluginInstance } from 'app/common/PluginInstance';
|
|||||||
import { Promisified } from 'app/common/tpromisified';
|
import { Promisified } from 'app/common/tpromisified';
|
||||||
import { ParseFileResult, ParseOptions } from 'app/plugin/FileParserAPI';
|
import { ParseFileResult, ParseOptions } from 'app/plugin/FileParserAPI';
|
||||||
import { checkers, GristTable } from "app/plugin/grist-plugin-api";
|
import { checkers, GristTable } from "app/plugin/grist-plugin-api";
|
||||||
import { GristDocAPI } from "app/plugin/GristAPI";
|
import { AccessTokenResult, GristDocAPI } from "app/plugin/GristAPI";
|
||||||
import { Storage } from 'app/plugin/StorageAPI';
|
import { Storage } from 'app/plugin/StorageAPI';
|
||||||
import { ActiveDoc } from 'app/server/lib/ActiveDoc';
|
import { ActiveDoc } from 'app/server/lib/ActiveDoc';
|
||||||
import { DocPluginData } from 'app/server/lib/DocPluginData';
|
import { DocPluginData } from 'app/server/lib/DocPluginData';
|
||||||
@ -47,6 +47,13 @@ class GristDocAPIImpl implements GristDocAPI {
|
|||||||
public applyUserActions(actions: any[][]): Promise<ApplyUAResult> {
|
public applyUserActions(actions: any[][]): Promise<ApplyUAResult> {
|
||||||
return this._activeDoc.applyUserActions(makeExceptionalDocSession('plugin'), actions);
|
return this._activeDoc.applyUserActions(makeExceptionalDocSession('plugin'), actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// These implementations of GristDocAPI are from an early implementation of
|
||||||
|
// plugins that is incompatible with access control. No need to add new
|
||||||
|
// methods here.
|
||||||
|
public async getAccessToken(): Promise<AccessTokenResult> {
|
||||||
|
throw new Error('getAccessToken not implemented');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -9,6 +9,7 @@ import {Client} from 'app/server/lib/Client';
|
|||||||
import {Comm} from 'app/server/lib/Comm';
|
import {Comm} from 'app/server/lib/Comm';
|
||||||
import {DocSession, docSessionFromRequest} from 'app/server/lib/DocSession';
|
import {DocSession, docSessionFromRequest} from 'app/server/lib/DocSession';
|
||||||
import {filterDocumentInPlace} from 'app/server/lib/filterUtils';
|
import {filterDocumentInPlace} from 'app/server/lib/filterUtils';
|
||||||
|
import {GristServer} from 'app/server/lib/GristServer';
|
||||||
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
|
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
|
||||||
import log from 'app/server/lib/log';
|
import log from 'app/server/lib/log';
|
||||||
import {getDocId, integerParam, optStringParam, stringParam} from 'app/server/lib/requestUtils';
|
import {getDocId, integerParam, optStringParam, stringParam} from 'app/server/lib/requestUtils';
|
||||||
@ -21,12 +22,15 @@ import * as path from 'path';
|
|||||||
|
|
||||||
export interface AttachOptions {
|
export interface AttachOptions {
|
||||||
comm: Comm; // Comm object for methods called via websocket
|
comm: Comm; // Comm object for methods called via websocket
|
||||||
|
gristServer: GristServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DocWorker {
|
export class DocWorker {
|
||||||
private _comm: Comm;
|
private _comm: Comm;
|
||||||
constructor(private _dbManager: HomeDBManager, {comm}: AttachOptions) {
|
private _gristServer: GristServer;
|
||||||
this._comm = comm;
|
constructor(private _dbManager: HomeDBManager, options: AttachOptions) {
|
||||||
|
this._comm = options.comm;
|
||||||
|
this._gristServer = options.gristServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAttachment(req: express.Request, res: express.Response): Promise<void> {
|
public async getAttachment(req: express.Request, res: express.Response): Promise<void> {
|
||||||
@ -121,6 +125,7 @@ export class DocWorker {
|
|||||||
getAclResources: activeDocMethod.bind(null, 'viewers', 'getAclResources'),
|
getAclResources: activeDocMethod.bind(null, 'viewers', 'getAclResources'),
|
||||||
waitForInitialization: activeDocMethod.bind(null, 'viewers', 'waitForInitialization'),
|
waitForInitialization: activeDocMethod.bind(null, 'viewers', 'waitForInitialization'),
|
||||||
getUsersForViewAs: activeDocMethod.bind(null, 'viewers', 'getUsersForViewAs'),
|
getUsersForViewAs: activeDocMethod.bind(null, 'viewers', 'getUsersForViewAs'),
|
||||||
|
getAccessToken: activeDocMethod.bind(null, 'viewers', 'getAccessToken'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,7 +165,7 @@ export class DocWorker {
|
|||||||
}
|
}
|
||||||
if (!urlId) { return res.status(403).send({error: 'missing document id'}); }
|
if (!urlId) { return res.status(403).send({error: 'missing document id'}); }
|
||||||
|
|
||||||
const docAuth = await getOrSetDocAuth(mreq, this._dbManager, urlId);
|
const docAuth = await getOrSetDocAuth(mreq, this._dbManager, this._gristServer, urlId);
|
||||||
assertAccess('viewers', docAuth);
|
assertAccess('viewers', docAuth);
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -68,5 +68,5 @@ export interface IDocWorkerMap extends IPermitStores, IElectionStore, IChecksumS
|
|||||||
getWorkerGroup(workerId: string): Promise<string|null>;
|
getWorkerGroup(workerId: string): Promise<string|null>;
|
||||||
getDocGroup(docId: string): Promise<string|null>;
|
getDocGroup(docId: string): Promise<string|null>;
|
||||||
|
|
||||||
getRedisClient(): RedisClient;
|
getRedisClient(): RedisClient|null;
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,7 @@ import {getDocWorkerMap} from 'app/gen-server/lib/DocWorkerMap';
|
|||||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||||
import {Housekeeper} from 'app/gen-server/lib/Housekeeper';
|
import {Housekeeper} from 'app/gen-server/lib/Housekeeper';
|
||||||
import {Usage} from 'app/gen-server/lib/Usage';
|
import {Usage} from 'app/gen-server/lib/Usage';
|
||||||
|
import {AccessTokens, IAccessTokens} from 'app/server/lib/AccessTokens';
|
||||||
import {attachAppEndpoint} from 'app/server/lib/AppEndpoint';
|
import {attachAppEndpoint} from 'app/server/lib/AppEndpoint';
|
||||||
import {appSettings} from 'app/server/lib/AppSettings';
|
import {appSettings} from 'app/server/lib/AppSettings';
|
||||||
import {addRequestUser, getUser, getUserId, isSingleUserMode,
|
import {addRequestUser, getUser, getUserId, isSingleUserMode,
|
||||||
@ -125,6 +126,7 @@ export class FlexServer implements GristServer {
|
|||||||
private _docWorkerMap: IDocWorkerMap;
|
private _docWorkerMap: IDocWorkerMap;
|
||||||
private _widgetRepository: IWidgetRepository;
|
private _widgetRepository: IWidgetRepository;
|
||||||
private _notifier: INotifier;
|
private _notifier: INotifier;
|
||||||
|
private _accessTokens: IAccessTokens;
|
||||||
private _internalPermitStore: IPermitStore; // store for permits that stay within our servers
|
private _internalPermitStore: IPermitStore; // store for permits that stay within our servers
|
||||||
private _externalPermitStore: IPermitStore; // store for permits that pass through outside servers
|
private _externalPermitStore: IPermitStore; // store for permits that pass through outside servers
|
||||||
private _disabled: boolean = false;
|
private _disabled: boolean = false;
|
||||||
@ -303,6 +305,14 @@ export class FlexServer implements GristServer {
|
|||||||
return this._notifier;
|
return this._notifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getAccessTokens() {
|
||||||
|
if (this._accessTokens) { return this._accessTokens; }
|
||||||
|
this.addDocWorkerMap();
|
||||||
|
const cli = this._docWorkerMap.getRedisClient();
|
||||||
|
this._accessTokens = new AccessTokens(cli);
|
||||||
|
return this._accessTokens;
|
||||||
|
}
|
||||||
|
|
||||||
public sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise<void> {
|
public sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise<void> {
|
||||||
if (!this._sendAppPage) { throw new Error('no _sendAppPage method available'); }
|
if (!this._sendAppPage) { throw new Error('no _sendAppPage method available'); }
|
||||||
return this._sendAppPage(req, resp, options);
|
return this._sendAppPage(req, resp, options);
|
||||||
@ -509,6 +519,7 @@ export class FlexServer implements GristServer {
|
|||||||
getProfile: this._loginMiddleware.getProfile?.bind(this._loginMiddleware),
|
getProfile: this._loginMiddleware.getProfile?.bind(this._loginMiddleware),
|
||||||
// Set this to false to stop Grist using a cookie for authentication purposes.
|
// Set this to false to stop Grist using a cookie for authentication purposes.
|
||||||
skipSession,
|
skipSession,
|
||||||
|
gristServer: this,
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
this._trustOriginsMiddleware = expressWrap(trustOriginHandler);
|
this._trustOriginsMiddleware = expressWrap(trustOriginHandler);
|
||||||
@ -625,6 +636,7 @@ export class FlexServer implements GristServer {
|
|||||||
if (this.httpsServer) { this.httpsServer.close(); }
|
if (this.httpsServer) { this.httpsServer.close(); }
|
||||||
if (this.housekeeper) { await this.housekeeper.stop(); }
|
if (this.housekeeper) { await this.housekeeper.stop(); }
|
||||||
await this._shutdown();
|
await this._shutdown();
|
||||||
|
if (this._accessTokens) { await this._accessTokens.close(); }
|
||||||
// Do this after _shutdown, since DocWorkerMap is used during shutdown.
|
// Do this after _shutdown, since DocWorkerMap is used during shutdown.
|
||||||
if (this._docWorkerMap) { await this._docWorkerMap.close(); }
|
if (this._docWorkerMap) { await this._docWorkerMap.close(); }
|
||||||
if (this._sessionStore) { await this._sessionStore.close(); }
|
if (this._sessionStore) { await this._sessionStore.close(); }
|
||||||
@ -632,7 +644,7 @@ export class FlexServer implements GristServer {
|
|||||||
|
|
||||||
public addDocApiForwarder() {
|
public addDocApiForwarder() {
|
||||||
if (this._check('doc_api_forwarder', '!json', 'homedb', 'api-mw', 'map')) { return; }
|
if (this._check('doc_api_forwarder', '!json', 'homedb', 'api-mw', 'map')) { return; }
|
||||||
const docApiForwarder = new DocApiForwarder(this._docWorkerMap, this._dbManager);
|
const docApiForwarder = new DocApiForwarder(this._docWorkerMap, this._dbManager, this);
|
||||||
docApiForwarder.addEndpoints(this.app);
|
docApiForwarder.addEndpoints(this.app);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1064,7 +1076,7 @@ export class FlexServer implements GristServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Attach docWorker endpoints and Comm methods.
|
// Attach docWorker endpoints and Comm methods.
|
||||||
const docWorker = new DocWorker(this._dbManager, {comm: this._comm});
|
const docWorker = new DocWorker(this._dbManager, {comm: this._comm, gristServer: this});
|
||||||
this._docWorker = docWorker;
|
this._docWorker = docWorker;
|
||||||
|
|
||||||
// Register the websocket comm functions associated with the docworker.
|
// Register the websocket comm functions associated with the docworker.
|
||||||
@ -1299,7 +1311,8 @@ export class FlexServer implements GristServer {
|
|||||||
/**
|
/**
|
||||||
* Get a url for an organization, workspace, or document.
|
* Get a url for an organization, workspace, or document.
|
||||||
*/
|
*/
|
||||||
public async getResourceUrl(resource: Organization|Workspace|Document): Promise<string> {
|
public async getResourceUrl(resource: Organization|Workspace|Document,
|
||||||
|
purpose?: 'api'|'html'): Promise<string> {
|
||||||
if (!this._dbManager) { throw new Error('database missing'); }
|
if (!this._dbManager) { throw new Error('database missing'); }
|
||||||
const gristConfig = this.getGristConfig();
|
const gristConfig = this.getGristConfig();
|
||||||
const state: IGristUrlState = {};
|
const state: IGristUrlState = {};
|
||||||
@ -1316,7 +1329,8 @@ export class FlexServer implements GristServer {
|
|||||||
}
|
}
|
||||||
state.org = this._dbManager.normalizeOrgDomain(org.id, org.domain, org.ownerId);
|
state.org = this._dbManager.normalizeOrgDomain(org.id, org.domain, org.ownerId);
|
||||||
if (!gristConfig.homeUrl) { throw new Error('Computing a resource URL requires a home URL'); }
|
if (!gristConfig.homeUrl) { throw new Error('Computing a resource URL requires a home URL'); }
|
||||||
return encodeUrl(gristConfig, state, new URL(gristConfig.homeUrl));
|
return encodeUrl(gristConfig, state, new URL(gristConfig.homeUrl),
|
||||||
|
{ api: purpose === 'api' });
|
||||||
}
|
}
|
||||||
|
|
||||||
public addUsage() {
|
public addUsage() {
|
||||||
|
@ -4,6 +4,7 @@ import { Document } from 'app/gen-server/entity/Document';
|
|||||||
import { Organization } from 'app/gen-server/entity/Organization';
|
import { Organization } from 'app/gen-server/entity/Organization';
|
||||||
import { Workspace } from 'app/gen-server/entity/Workspace';
|
import { Workspace } from 'app/gen-server/entity/Workspace';
|
||||||
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
|
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
|
||||||
|
import { IAccessTokens } from 'app/server/lib/AccessTokens';
|
||||||
import { RequestWithLogin } from 'app/server/lib/Authorizer';
|
import { RequestWithLogin } from 'app/server/lib/Authorizer';
|
||||||
import { Comm } from 'app/server/lib/Comm';
|
import { Comm } from 'app/server/lib/Comm';
|
||||||
import { create } from 'app/server/lib/create';
|
import { create } from 'app/server/lib/create';
|
||||||
@ -31,7 +32,8 @@ export interface GristServer {
|
|||||||
getOwnUrl(): string;
|
getOwnUrl(): string;
|
||||||
getOrgUrl(orgKey: string|number): Promise<string>;
|
getOrgUrl(orgKey: string|number): Promise<string>;
|
||||||
getMergedOrgUrl(req: RequestWithLogin, pathname?: string): string;
|
getMergedOrgUrl(req: RequestWithLogin, pathname?: string): string;
|
||||||
getResourceUrl(resource: Organization|Workspace|Document): Promise<string>;
|
getResourceUrl(resource: Organization|Workspace|Document,
|
||||||
|
purpose?: 'api'|'html'): Promise<string>;
|
||||||
getGristConfig(): GristLoadConfig;
|
getGristConfig(): GristLoadConfig;
|
||||||
getPermitStore(): IPermitStore;
|
getPermitStore(): IPermitStore;
|
||||||
getExternalPermitStore(): IPermitStore;
|
getExternalPermitStore(): IPermitStore;
|
||||||
@ -44,6 +46,7 @@ export interface GristServer {
|
|||||||
getDocTemplate(): Promise<DocTemplate>;
|
getDocTemplate(): Promise<DocTemplate>;
|
||||||
getTag(): string;
|
getTag(): string;
|
||||||
sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise<void>;
|
sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise<void>;
|
||||||
|
getAccessTokens(): IAccessTokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GristLoginSystem {
|
export interface GristLoginSystem {
|
||||||
@ -117,5 +120,6 @@ export function createDummyGristServer(): GristServer {
|
|||||||
getDocTemplate() { throw new Error('no doc template'); },
|
getDocTemplate() { throw new Error('no doc template'); },
|
||||||
getTag() { return 'tag'; },
|
getTag() { return 'tag'; },
|
||||||
sendAppPage() { return Promise.resolve(); },
|
sendAppPage() { return Promise.resolve(); },
|
||||||
|
getAccessTokens() { throw new Error('no access tokens'); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -35,6 +35,7 @@ export interface ICreate {
|
|||||||
export interface ICreateActiveDocOptions {
|
export interface ICreateActiveDocOptions {
|
||||||
safeMode?: boolean;
|
safeMode?: boolean;
|
||||||
docUrl?: string;
|
docUrl?: string;
|
||||||
|
docApiUrl?: string;
|
||||||
doc?: Document;
|
doc?: Document;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,6 +41,7 @@
|
|||||||
"@types/fs-extra": "5.0.4",
|
"@types/fs-extra": "5.0.4",
|
||||||
"@types/image-size": "0.0.29",
|
"@types/image-size": "0.0.29",
|
||||||
"@types/js-yaml": "3.11.2",
|
"@types/js-yaml": "3.11.2",
|
||||||
|
"@types/jsonwebtoken": "7.2.8",
|
||||||
"@types/lodash": "4.14.117",
|
"@types/lodash": "4.14.117",
|
||||||
"@types/lru-cache": "5.1.1",
|
"@types/lru-cache": "5.1.1",
|
||||||
"@types/mime-types": "2.1.0",
|
"@types/mime-types": "2.1.0",
|
||||||
@ -119,6 +120,7 @@
|
|||||||
"image-size": "0.6.3",
|
"image-size": "0.6.3",
|
||||||
"jquery": "2.2.1",
|
"jquery": "2.2.1",
|
||||||
"js-yaml": "3.12.0",
|
"js-yaml": "3.12.0",
|
||||||
|
"jsonwebtoken": "8.3.0",
|
||||||
"knockout": "3.5.0",
|
"knockout": "3.5.0",
|
||||||
"locale-currency": "0.0.2",
|
"locale-currency": "0.0.2",
|
||||||
"lodash": "4.17.15",
|
"lodash": "4.17.15",
|
||||||
|
64
yarn.lock
64
yarn.lock
@ -391,6 +391,13 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
|
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
|
||||||
integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
|
integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
|
||||||
|
|
||||||
|
"@types/jsonwebtoken@7.2.8":
|
||||||
|
version "7.2.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-7.2.8.tgz#8d199dab4ddb5bba3234f8311b804d2027af2b3a"
|
||||||
|
integrity sha512-XENN3YzEB8D6TiUww0O8SRznzy1v+77lH7UmuN54xq/IHIsyWjWOzZuFFTtoiRuaE782uAoRwBe/wwow+vQXZw==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "*"
|
||||||
|
|
||||||
"@types/lodash@4.14.117":
|
"@types/lodash@4.14.117":
|
||||||
version "4.14.117"
|
version "4.14.117"
|
||||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.117.tgz#695a7f514182771a1e0f4345d189052ee33c8778"
|
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.117.tgz#695a7f514182771a1e0f4345d189052ee33c8778"
|
||||||
@ -3901,6 +3908,21 @@ jsonparse@^1.2.0:
|
|||||||
resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
|
resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
|
||||||
integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=
|
integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=
|
||||||
|
|
||||||
|
jsonwebtoken@8.3.0:
|
||||||
|
version "8.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.3.0.tgz#056c90eee9a65ed6e6c72ddb0a1d325109aaf643"
|
||||||
|
integrity sha512-oge/hvlmeJCH+iIz1DwcO7vKPkNGJHhgkspk8OH3VKlw+mbi42WtD4ig1+VXRln765vxptAv+xT26Fd3cteqag==
|
||||||
|
dependencies:
|
||||||
|
jws "^3.1.5"
|
||||||
|
lodash.includes "^4.3.0"
|
||||||
|
lodash.isboolean "^3.0.3"
|
||||||
|
lodash.isinteger "^4.0.4"
|
||||||
|
lodash.isnumber "^3.0.3"
|
||||||
|
lodash.isplainobject "^4.0.6"
|
||||||
|
lodash.isstring "^4.0.1"
|
||||||
|
lodash.once "^4.0.0"
|
||||||
|
ms "^2.1.1"
|
||||||
|
|
||||||
jsprim@^1.2.2:
|
jsprim@^1.2.2:
|
||||||
version "1.4.1"
|
version "1.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
|
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
|
||||||
@ -3926,6 +3948,15 @@ just-extend@^4.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744"
|
resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744"
|
||||||
integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==
|
integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==
|
||||||
|
|
||||||
|
jwa@^1.4.1:
|
||||||
|
version "1.4.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
|
||||||
|
integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==
|
||||||
|
dependencies:
|
||||||
|
buffer-equal-constant-time "1.0.1"
|
||||||
|
ecdsa-sig-formatter "1.0.11"
|
||||||
|
safe-buffer "^5.0.1"
|
||||||
|
|
||||||
jwa@^2.0.0:
|
jwa@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc"
|
resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc"
|
||||||
@ -3935,6 +3966,14 @@ jwa@^2.0.0:
|
|||||||
ecdsa-sig-formatter "1.0.11"
|
ecdsa-sig-formatter "1.0.11"
|
||||||
safe-buffer "^5.0.1"
|
safe-buffer "^5.0.1"
|
||||||
|
|
||||||
|
jws@^3.1.5:
|
||||||
|
version "3.2.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
|
||||||
|
integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==
|
||||||
|
dependencies:
|
||||||
|
jwa "^1.4.1"
|
||||||
|
safe-buffer "^5.0.1"
|
||||||
|
|
||||||
jws@^4.0.0:
|
jws@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4"
|
resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4"
|
||||||
@ -4072,6 +4111,11 @@ lodash.groupby@^4.6.0:
|
|||||||
resolved "https://registry.yarnpkg.com/lodash.groupby/-/lodash.groupby-4.6.0.tgz#0b08a1dcf68397c397855c3239783832df7403d1"
|
resolved "https://registry.yarnpkg.com/lodash.groupby/-/lodash.groupby-4.6.0.tgz#0b08a1dcf68397c397855c3239783832df7403d1"
|
||||||
integrity sha1-Cwih3PaDl8OXhVwyOXg4Mt90A9E=
|
integrity sha1-Cwih3PaDl8OXhVwyOXg4Mt90A9E=
|
||||||
|
|
||||||
|
lodash.includes@^4.3.0:
|
||||||
|
version "4.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
|
||||||
|
integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==
|
||||||
|
|
||||||
lodash.isboolean@^3.0.3:
|
lodash.isboolean@^3.0.3:
|
||||||
version "3.0.3"
|
version "3.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
|
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
|
||||||
@ -4087,16 +4131,31 @@ lodash.isfunction@^3.0.9:
|
|||||||
resolved "https://registry.yarnpkg.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz#06de25df4db327ac931981d1bdb067e5af68d051"
|
resolved "https://registry.yarnpkg.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz#06de25df4db327ac931981d1bdb067e5af68d051"
|
||||||
integrity sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==
|
integrity sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==
|
||||||
|
|
||||||
|
lodash.isinteger@^4.0.4:
|
||||||
|
version "4.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
|
||||||
|
integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==
|
||||||
|
|
||||||
lodash.isnil@^4.0.0:
|
lodash.isnil@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.isnil/-/lodash.isnil-4.0.0.tgz#49e28cd559013458c814c5479d3c663a21bfaa6c"
|
resolved "https://registry.yarnpkg.com/lodash.isnil/-/lodash.isnil-4.0.0.tgz#49e28cd559013458c814c5479d3c663a21bfaa6c"
|
||||||
integrity sha1-SeKM1VkBNFjIFMVHnTxmOiG/qmw=
|
integrity sha1-SeKM1VkBNFjIFMVHnTxmOiG/qmw=
|
||||||
|
|
||||||
|
lodash.isnumber@^3.0.3:
|
||||||
|
version "3.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc"
|
||||||
|
integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==
|
||||||
|
|
||||||
lodash.isplainobject@^4.0.6:
|
lodash.isplainobject@^4.0.6:
|
||||||
version "4.0.6"
|
version "4.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
|
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
|
||||||
integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
|
integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
|
||||||
|
|
||||||
|
lodash.isstring@^4.0.1:
|
||||||
|
version "4.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
|
||||||
|
integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==
|
||||||
|
|
||||||
lodash.isundefined@^3.0.1:
|
lodash.isundefined@^3.0.1:
|
||||||
version "3.0.1"
|
version "3.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz#23ef3d9535565203a66cefd5b830f848911afb48"
|
resolved "https://registry.yarnpkg.com/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz#23ef3d9535565203a66cefd5b830f848911afb48"
|
||||||
@ -4107,6 +4166,11 @@ lodash.memoize@~3.0.3:
|
|||||||
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-3.0.4.tgz#2dcbd2c287cbc0a55cc42328bd0c736150d53e3f"
|
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-3.0.4.tgz#2dcbd2c287cbc0a55cc42328bd0c736150d53e3f"
|
||||||
integrity sha1-LcvSwofLwKVcxCMovQxzYVDVPj8=
|
integrity sha1-LcvSwofLwKVcxCMovQxzYVDVPj8=
|
||||||
|
|
||||||
|
lodash.once@^4.0.0:
|
||||||
|
version "4.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
|
||||||
|
integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==
|
||||||
|
|
||||||
lodash.union@^4.6.0:
|
lodash.union@^4.6.0:
|
||||||
version "4.6.0"
|
version "4.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88"
|
resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88"
|
||||||
|
Loading…
Reference in New Issue
Block a user