-
-
Notifications
You must be signed in to change notification settings - Fork 348
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
20 changed files
with
1,600 additions
and
25 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() {}, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.": "موقع الفريق هذا معلق. يمكن قراءة المستندات لكن لا يمكن تعديلها." | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.