Skip to content

Commit

Permalink
(core) updates from grist-core
Browse files Browse the repository at this point in the history
  • Loading branch information
paulfitz committed Nov 13, 2023
2 parents 3c219e0 + 9347827 commit 6880147
Show file tree
Hide file tree
Showing 20 changed files with 1,600 additions and 25 deletions.
5 changes: 5 additions & 0 deletions app/server/lib/BrowserSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ export interface SessionObj {
// anonymous editing (e.g. to allow the user to edit
// something they just added, without allowing the suer
// to edit other people's contributions).

oidc?: {
// codeVerifier is used during OIDC authentication, to protect against attacks like CSRF.
codeVerifier?: string;
}
}

// Make an artificial change to a session to encourage express-session to set a cookie.
Expand Down
2 changes: 2 additions & 0 deletions app/server/lib/DocWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ async function removeData(filename: string) {
for (const tableId of tableIds) {
await db.run(`DELETE FROM ${quoteIdent(tableId)}`);
}
await db.run(`DELETE FROM _grist_Attachments`);
await db.run(`DELETE FROM _gristsys_Files`);
await db.close();
}

Expand Down
187 changes: 187 additions & 0 deletions app/server/lib/OIDCConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* Configuration for OpenID Connect (OIDC), useful for enterprise single-sign-on logins.
* A good informative overview of OIDC is at https://developer.okta.com/blog/2019/10/21/illustrated-guide-to-oauth-and-oidc
* Note:
* SP is "Service Provider", in our case, the Grist application.
* IdP is the "Identity Provider", somewhere users log into, e.g. Okta or Google Apps.
*
* We also use optional attributes for the user's name, for which we accept any of:
* given_name
* family_name
*
* Expected environment variables:
* env GRIST_OIDC_SP_HOST=https://<your-domain>
* Host at which our /oauth2 endpoint will live. Optional, defaults to `APP_HOME_URL`.
* env GRIST_OIDC_IDP_ISSUER
* The issuer URL for the IdP, passed to node-openid-client, see: https://github.com/panva/node-openid-client/blob/a84d022f195f82ca1c97f8f6b2567ebcef8738c3/docs/README.md#issuerdiscoverissuer.
* This variable turns on the OIDC login system.
* env GRIST_OIDC_IDP_CLIENT_ID
* The client ID for the application, as registered with the IdP.
* env GRIST_OIDC_IDP_CLIENT_SECRET
* The client secret for the application, as registered with the IdP.
* env GRIST_OIDC_IDP_SCOPES
* The scopes to request from the IdP, as a space-separated list. Defaults to "openid email profile".
*
* This version of OIDCConfig has been tested with Keycloak OIDC IdP following the instructions
* at:
* https://www.keycloak.org/getting-started/getting-started-docker
*
* /!\ CAUTION: For production, be sure to use https for all URLs. /!\
*
* For development of this module on localhost, these settings should work:
* - GRIST_OIDC_SP_HOST=http://localhost:8484 (or whatever port you use for Grist)
* - GRIST_OIDC_IDP_ISSUER=http://localhost:8080/realms/myrealm (replace 8080 by the port you use for keycloak)
* - GRIST_OIDC_IDP_CLIENT_ID=my_grist_instance
* - GRIST_OIDC_IDP_CLIENT_SECRET=YOUR_SECRET (as set in keycloak)
* - GRIST_OIDC_IDP_SCOPES="openid email profile"
*/

import * as express from 'express';
import { GristLoginSystem, GristServer } from './GristServer';
import { Client, generators, Issuer, UserinfoResponse } from 'openid-client';
import { Sessions } from './Sessions';
import log from 'app/server/lib/log';
import { appSettings } from './AppSettings';
import { RequestWithLogin } from './Authorizer';

const CALLBACK_URL = '/oauth2/callback';

export class OIDCConfig {
private _client: Client;
private _redirectUrl: string;

public constructor() {
}

public async initOIDC(): Promise<void> {
const section = appSettings.section('login').section('system').section('oidc');
const spHost = section.flag('spHost').requireString({
envVar: 'GRIST_OIDC_SP_HOST',
defaultValue: process.env.APP_HOME_URL,
});
const issuerUrl = section.flag('issuer').requireString({
envVar: 'GRIST_OIDC_IDP_ISSUER',
});
const clientId = section.flag('clientId').requireString({
envVar: 'GRIST_OIDC_IDP_CLIENT_ID',
});
const clientSecret = section.flag('clientSecret').requireString({
envVar: 'GRIST_OIDC_IDP_CLIENT_SECRET',
censor: true,
});

const issuer = await Issuer.discover(issuerUrl);
this._redirectUrl = new URL(CALLBACK_URL, spHost).href;
this._client = new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [ this._redirectUrl ],
response_types: [ 'code' ],
});
}

public addEndpoints(app: express.Application, sessions: Sessions): void {
app.get(CALLBACK_URL, this.handleCallback.bind(this, sessions));
}

public async handleCallback(sessions: Sessions, req: express.Request, res: express.Response): Promise<void> {
try {
const params = this._client.callbackParams(req);
const { state } = params;
if (!state) {
throw new Error('Login or logout failed to complete');
}

const codeVerifier = await this._retrieveCodeVerifierFromSession(req);

const tokenSet = await this._client.callback(
this._redirectUrl,
params,
{ state, code_verifier: codeVerifier }
);

const userInfo = await this._client.userinfo(tokenSet);
const profile = this._makeUserProfileFromUserInfo(userInfo);

const scopedSession = sessions.getOrCreateSessionFromRequest(req);
await scopedSession.operateOnScopedSession(req, async (user) => Object.assign(user, {
profile,
}));

res.redirect('/');
} catch (err) {
log.error(`OIDC callback failed: ${err.message}`);
res.status(500).send(`OIDC callback failed: ${err.message}`);
}
}

public async getLoginRedirectUrl(req: express.Request): Promise<string> {
const codeVerifier = await this._generateAndStoreCodeVerifier(req);
const codeChallenge = generators.codeChallenge(codeVerifier);
const state = generators.state();

const authUrl = this._client.authorizationUrl({
scope: process.env.GRIST_OIDC_IDP_SCOPES || 'openid email profile',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
});
return authUrl;
}

public async getLogoutRedirectUrl(req: express.Request, redirectUrl: URL): Promise<string> {
return this._client.endSessionUrl({
post_logout_redirect_uri: redirectUrl.href
});
}

private async _generateAndStoreCodeVerifier(req: express.Request) {
const mreq = req as RequestWithLogin;
if (!mreq.session) { throw new Error('no session available'); }
const codeVerifier = generators.codeVerifier();
mreq.session.oidc = {
codeVerifier,
};

return codeVerifier;
}

private async _retrieveCodeVerifierFromSession(req: express.Request) {
const mreq = req as RequestWithLogin;
if (!mreq.session) { throw new Error('no session available'); }
const codeVerifier = mreq.session.oidc?.codeVerifier;
if (!codeVerifier) { throw new Error('Login is stale'); }
delete mreq.session.oidc?.codeVerifier;
return codeVerifier;
}

private _makeUserProfileFromUserInfo(userInfo: UserinfoResponse) {
const email = userInfo.email;
const fname = userInfo.given_name ?? '';
const lname = userInfo.family_name ?? '';
return {
email,
name: `${fname} ${lname}`.trim(),
};
}
}

export async function getOIDCLoginSystem(): Promise<GristLoginSystem|undefined> {
if (!process.env.GRIST_OIDC_IDP_ISSUER) { return undefined; }
return {
async getMiddleware(gristServer: GristServer) {
const config = new OIDCConfig();
await config.initOIDC();
return {
getLoginRedirectUrl: config.getLoginRedirectUrl.bind(config),
getSignUpRedirectUrl: config.getLoginRedirectUrl.bind(config),
getLogoutRedirectUrl: config.getLogoutRedirectUrl.bind(config),
async addEndpoints(app: express.Express) {
config.addEndpoints(app, gristServer.getSessions());
return 'oidc';
},
};
},
async deleteUser() {},
};
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "grist-core",
"version": "1.1.6",
"version": "1.1.7",
"license": "Apache-2.0",
"description": "Grist is the evolution of spreadsheets",
"homepage": "https://github.com/gristlabs/grist-core",
Expand Down Expand Up @@ -168,6 +168,7 @@
"multiparty": "4.2.2",
"node-abort-controller": "3.0.1",
"node-fetch": "2.6.7",
"openid-client": "^5.6.1",
"pg": "8.6.0",
"piscina": "3.2.0",
"plotly.js-basic-dist": "2.13.2",
Expand Down
4 changes: 2 additions & 2 deletions sandbox/gvisor/update_engine_checkpoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
export NODE_PATH=_build:_build/core:_build/stubs:_build/ext
source $SCRIPT_DIR/get_checkpoint_path.sh

if [[ -z "GRIST_CHECKPOINT" ]]; then
if [[ -z "$GRIST_CHECKPOINT" ]]; then
echo "Skipping checkpoint generation"
return
exit 0
fi

export GRIST_CHECKPOINT_MAKE=1
Expand Down
2 changes: 1 addition & 1 deletion sandbox/pyodide/Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# This number should be bumped up if making a non-additive change
# to python packages.
GRIST_PYODIDE_VERSION = 2
GRIST_PYODIDE_VERSION = 3

default:
echo "Welcome to the pyodide sandbox"
Expand Down
2 changes: 1 addition & 1 deletion sandbox/pyodide/package_filenames.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[
"astroid-2.14.2-cp311-none-any.whl",
"asttokens-2.2.1-cp311-none-any.whl",
"asttokens-2.4.0-cp311-none-any.whl",
"chardet-5.1.0-cp311-none-any.whl",
"et_xmlfile-1.0.1-cp311-none-any.whl",
"executing-1.1.1-cp311-none-any.whl",
Expand Down
86 changes: 86 additions & 0 deletions static/locales/ar.client.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{
"AccessRules": {
"Permission to access the document in full when needed": "الإذن بالنفاذ إلى المستند بالكامل عند الحاجة",
"Permissions": "الأذون",
"Type a message...": "اكتب رسالة…",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "السماح للمحررين بتحرير البنية (أي التعديل والحذف في الجداول والأعمدة والترتيبات)، وكتابة الصيغ الرياضية، وهذا يجعلهم نافذين إلى كل البيانات بغض النظر عن قيود القراءة.",
"Save": "حفظ"
},
"AccountPage": {
"Theme": "السمة",
"Change Password": "تغيير كلمة السر",
"Password & Security": "كلمة السر والأمان",
"Account settings": "إعدادات الحساب",
"Two-factor authentication": "المصادقة ذات العاملين",
"Two-factor authentication is an extra layer of security for your Grist account designed to ensure that you're the only person who can access your account, even if someone knows your password.": "المصادقة ذات العاملين Two-factor authentication طبقة إضافية من الحماية لحسابك في Grista مصممة للتأكد من أنك الشخص الوحيد القادر على النفاذ إلى حسابك، حتى لو كان شخص آخر يعرف كلمة السر خاصتك.",
"Language": "اللغة",
"Edit": "تحرير",
"Login Method": "طريقة الدخول",
"Allow signing in to this account with Google": "السماح بالدخول إلى هذا الحساب باستخدام Google",
"Save": "حفظ"
},
"AccountWidget": {
"Add Account": "إضافة حساب",
"Switch Accounts": "تبديل الحساب",
"Activation": "التفعيل",
"Support Grist": "ادعم Grist",
"Upgrade Plan": "ترقية الخطة",
"Sign Out": "خروج",
"Profile Settings": "إعدادات الملف الشخصي",
"Sign in": "دخول",
"Pricing": "التسعير",
"Use This Template": "استعمل هذا القالب",
"Billing Account": "حساب الفوترة",
"Sign In": "دخول",
"Accounts": "الحسابات",
"Sign Up": "خروج"
},
"ACUserManager": {
"Invite new member": "دعوة عضو جديد",
"We'll email an invite to {{email}}": "سنرسل دعوة إلى البريد الإلكتروني {{email}}",
"Enter email address": "أدخل عنوان البريد الإلكتروني"
},
"ApiKey": {
"Click to show": "انقر لتعرض"
},
"ChartView": {
"Pick a column": "اختر عمودا"
},
"CellContextMenu": {
"Insert row below": "إدراج صف تحته",
"Copy": "نسخ",
"Delete {{count}} columns_other": "حذف {{count}} من الأعمدة",
"Insert row above": "إدراج صف فوقه",
"Delete {{count}} rows_other": "حذف {{count}} من الصفوف",
"Comment": "تعليق",
"Insert column to the right": "إدراج عمود إلى اليمين",
"Cut": "قص",
"Insert column to the left": "إدراج عمود إلى اليسار",
"Paste": "لصق"
},
"ColumnFilterMenu": {
"No matching values": "لا قيم مطابقة"
},
"ColorSelect": {
"Apply": "تطبيق",
"Cancel": "إلغاء"
},
"AppHeader": {
"Personal Site": "الموقع الشخصي",
"Home Page": "الصفحة الرئيسية",
"Team Site": "موقع الفريق",
"Grist Templates": "قوالب Grist"
},
"CustomSectionConfig": {
" (optional)": " (اختياري)"
},
"DataTables": {
"Click to copy": "انقر لتنسخ"
},
"DocHistory": {
"Activity": "النشاط"
},
"AppModel": {
"This team site is suspended. Documents can be read, but not modified.": "موقع الفريق هذا معلق. يمكن قراءة المستندات لكن لا يمكن تعديلها."
}
}
27 changes: 27 additions & 0 deletions static/locales/cs.client.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"AccessRules": {
"Add Column Rule": "Přidej Sloupcové Pravidlo",
"Lookup Column": "Vyhledávací Sloupec",
"Enter Condition": "Napiš Podmínku",
"Everyone Else": "Všichni Ostatní",
"Allow everyone to view Access Rules.": "Umožni všem zobrazit Přístupové Práva.",
"Lookup Table": "Vyhledávací Tabulka",
"Add Table Rules": "Přidej Tabulkové Pravidlo",
"Invalid": "Neplatné",
"Condition": "Podmínka",
"Delete Table Rules": "Vymaž Tabulkové Pravidla",
"Default Rules": "Základní Práva",
"Attribute name": "Jméno Atributu",
"Add User Attributes": "Přidej Uživatelské Atributy",
"Attribute to Look Up": "Atribut k Vyhledání",
"Everyone": "Všichni",
"Allow everyone to copy the entire document, or view it in full in fiddle mode.\nUseful for examples and templates, but not for sensitive data.": "Umožni všem kopírovat celý dokument, nebo zobrazit plně v \"fiddle\" režimu.\nUžiitečné pro ukázky a šablony, ale ne pro citlivá data.",
"Add Default Rule": "Přidej Základní Pravidlo",
"Checking...": "Kontroluji…"
},
"ACUserManager": {
"Invite new member": "Pozvi nového uživatele",
"We'll email an invite to {{email}}": "Zašleme pozvánku emailem na {{email}}",
"Enter email address": "Napiš e-mailovou adresu"
}
}
3 changes: 2 additions & 1 deletion static/locales/en.client.json
Original file line number Diff line number Diff line change
Expand Up @@ -1055,7 +1055,8 @@
},
"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.": "A UUID is a randomly-generated string that is useful for unique identifiers and link keys.",
"Lookups return data from related tables.": "Lookups return data from related tables.",
"Use reference columns to relate data in different tables.": "Use reference columns to relate data in different tables."
"Use reference columns to relate data in different tables.": "Use reference columns to relate data in different tables.",
"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL."
},
"DescriptionConfig": {
"DESCRIPTION": "DESCRIPTION"
Expand Down
3 changes: 2 additions & 1 deletion static/locales/es.client.json
Original file line number Diff line number Diff line change
Expand Up @@ -1109,7 +1109,8 @@
"Can't find the right columns? Click 'Change Widget' to select the table with events data.": "¿No encuentras las columnas adecuadas? Haz clic en \"Cambiar widget\" para seleccionar la tabla con los datos de los eventos.",
"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.": "Un UUID es una cadena generada aleatoriamente que resulta útil para identificadores únicos y claves de los enlaces.",
"Lookups return data from related tables.": "Las búsquedas devuelven datos de tablas relacionadas.",
"Use reference columns to relate data in different tables.": "Utilizar las columnas de referencia para relacionar los datos de distintas tablas."
"Use reference columns to relate data in different tables.": "Utilizar las columnas de referencia para relacionar los datos de distintas tablas.",
"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Puedes elegir entre los widgets disponibles en el menú desplegable, o incrustar el suyo propio proporcionando su dirección URL completa."
},
"DescriptionConfig": {
"DESCRIPTION": "DESCRIPCIÓN"
Expand Down
Loading

0 comments on commit 6880147

Please sign in to comment.