Summary: - Update cookie module, to support modern sameSite settings - Add a new cookie, grist_sid_status with less-sensitive value, to let less-trusted subdomains know if user is signed in - The new cookie is kept in-sync with the session cookie. - For a user signed in once, allow auto-signin is appropriate. - For a user signed in with multiple accounts, show a page to select which account to use. - Move css stylings for rendering users to a separate module. Test Plan: Added a test case with a simulated Discourse page to test redirects and account-selection page. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3047pull/115/head
parent
b3b7410ede
commit
1517dca644
@ -0,0 +1,120 @@
|
|||||||
|
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||||
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
|
import {input, styled} from 'grainjs';
|
||||||
|
import {cssMenuItem} from 'popweasel';
|
||||||
|
|
||||||
|
// Styled elements used for rendering a user, e.g. in the UserManager, Billing, etc.
|
||||||
|
// There is a general structure, but enough small variation that there is no helper at this point.
|
||||||
|
//
|
||||||
|
// cssMemberListItem(
|
||||||
|
// cssMemberImage(
|
||||||
|
// createUserImage(getFullUser(member), 'large')
|
||||||
|
// ),
|
||||||
|
// cssMemberText(
|
||||||
|
// cssMemberPrimary(NAME),
|
||||||
|
// cssMemberSecondary(EMAIL),
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
|
||||||
|
export const cssMemberListItem = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
width: 460px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 12px 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssMemberImage = styled('div', `
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin: 0 4px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background-color: ${colors.lightGreen};
|
||||||
|
background-size: cover;
|
||||||
|
|
||||||
|
.${cssMemberListItem.className}-removed & {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssMemberText = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 2px 12px;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0px;
|
||||||
|
font-size: ${vars.mediumFontSize};
|
||||||
|
|
||||||
|
.${cssMemberListItem.className}-removed & {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssMemberPrimary = styled('span', `
|
||||||
|
font-weight: bold;
|
||||||
|
color: ${colors.dark};
|
||||||
|
padding: 2px 0;
|
||||||
|
|
||||||
|
.${cssMenuItem.className}-sel & {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssMemberSecondary = styled('span', `
|
||||||
|
color: ${colors.slate};
|
||||||
|
/* the following just undo annoying bootstrap styles that apply to all labels */
|
||||||
|
margin: 0px;
|
||||||
|
font-weight: normal;
|
||||||
|
padding: 2px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.${cssMenuItem.className}-sel & {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssMemberBtn = styled('div', `
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&-disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssRemoveIcon = styled(icon, `
|
||||||
|
margin: 12px 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssEmailInputContainer = styled('div', `
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 3px;
|
||||||
|
margin: 16px 63px;
|
||||||
|
border: 1px solid ${colors.darkGrey};
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: ${vars.mediumFontSize};
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&-green {
|
||||||
|
border: 1px solid ${colors.lightGreen};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssEmailInput = styled(input, `
|
||||||
|
flex: 1 1 0;
|
||||||
|
line-height: 42px;
|
||||||
|
font-size: ${vars.mediumFontSize};
|
||||||
|
font-family: ${vars.fontFamily};
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssMailIcon = styled(icon, `
|
||||||
|
margin: 12px 8px 12px 13px;
|
||||||
|
background-color: ${colors.slate};
|
||||||
|
`);
|
@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Endpoint to support DiscourseConnect, to allow users to use their Grist logins for the
|
||||||
|
* Grist Community Forum.
|
||||||
|
*
|
||||||
|
* Adds one endpoint:
|
||||||
|
* - /discourse-connect: sends signed user info in a redirect to DISCOURSE_SITE.
|
||||||
|
*
|
||||||
|
* Expects environment variables:
|
||||||
|
* - DISCOURSE_SITE: URL of the Discourse site to which to redirect back.
|
||||||
|
* - DISCOURSE_CONNECT_SECRET: Secret for checking and adding signatures.
|
||||||
|
*
|
||||||
|
* This follows documentation at
|
||||||
|
* https://meta.discourse.org/t/discourseconnect-official-single-sign-on-for-discourse-sso/13045
|
||||||
|
* The recommended Discourse configuration includes:
|
||||||
|
* - enable discourse connect: true
|
||||||
|
* - discourse connect url: GRIST_SITE/discourse-connect
|
||||||
|
* - discourse connect secret: DISCOURSE_CONNECT_SECRET
|
||||||
|
* - logout redirect (in Users): GRIST_SITE/logout?next=DISCOURSE_SITE
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {Express, NextFunction, Request, RequestHandler, Response} from 'express';
|
||||||
|
import type {RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||||
|
import {expressWrap} from 'app/server/lib/expressWrap';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
const DISCOURSE_CONNECT_SECRET = process.env.DISCOURSE_CONNECT_SECRET;
|
||||||
|
const DISCOURSE_SITE = process.env.DISCOURSE_SITE;
|
||||||
|
|
||||||
|
// A hook for dependency injection. Allows tests to override these variables on the fly.
|
||||||
|
export const Deps = {DISCOURSE_CONNECT_SECRET, DISCOURSE_SITE};
|
||||||
|
|
||||||
|
// Calculate payload signature using the given secret.
|
||||||
|
function calcSignature(payload: string, secret: string) {
|
||||||
|
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check configuration and signature of the Discourse nonce in the request.
|
||||||
|
function checkParams(req: Request, resp: Response, next: NextFunction) {
|
||||||
|
if (!Deps.DISCOURSE_SITE || !Deps.DISCOURSE_CONNECT_SECRET) {
|
||||||
|
throw new Error('DiscourseConnect not configured');
|
||||||
|
}
|
||||||
|
const payload = String(req.query.sso || '');
|
||||||
|
const signature = String(req.query.sig || '');
|
||||||
|
if (calcSignature(payload, Deps.DISCOURSE_CONNECT_SECRET) !== signature) {
|
||||||
|
throw new Error('Invalid signature for Discourse SSO request');
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams(Buffer.from(payload, 'base64').toString('utf8'));
|
||||||
|
const nonce = params.get('nonce');
|
||||||
|
if (!nonce) {
|
||||||
|
throw new Error('Invalid request for Discourse SSO');
|
||||||
|
}
|
||||||
|
(req as any).discourseConnectNonce = nonce;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond to the DiscourseConnect request by redirecting back to discourse, including the user
|
||||||
|
// info and a signature into the URL parameters.
|
||||||
|
function discourseConnect(req: Request, resp: Response) {
|
||||||
|
const mreq = req as RequestWithLogin;
|
||||||
|
const nonce: string|undefined = (req as any).discourseConnectNonce;
|
||||||
|
if (!nonce) {
|
||||||
|
throw new Error('Invalid request for Discourse SSO');
|
||||||
|
}
|
||||||
|
if (!mreq.userIsAuthorized || !mreq.user?.loginEmail) {
|
||||||
|
throw new Error('User is not authenticated');
|
||||||
|
}
|
||||||
|
if (!req.query.user && mreq.users && mreq.users.length > 1) {
|
||||||
|
const origUrl = new URL(req.originalUrl, `${req.protocol}://${req.get('host')}`);
|
||||||
|
const redirectUrl = new URL('/welcome/select-account', `${req.protocol}://${req.get('host')}`);
|
||||||
|
redirectUrl.searchParams.set('next', origUrl.toString());
|
||||||
|
return resp.redirect(redirectUrl.toString());
|
||||||
|
}
|
||||||
|
const responseObj: {[key: string]: string} = {
|
||||||
|
nonce,
|
||||||
|
email: mreq.user.loginEmail,
|
||||||
|
// We don't treat user IDs as secret, so use the same ID with Discourse directly.
|
||||||
|
external_id: String(mreq.user.id),
|
||||||
|
// We could specify the username (used for @ mentions), but let Discourse create one for us
|
||||||
|
// (it bases it on name or email). The user can change it within Discourse.
|
||||||
|
// username,
|
||||||
|
name: mreq.user.name,
|
||||||
|
...(mreq.user.picture ? {avatar_url: mreq.user.picture} : {}),
|
||||||
|
suppress_welcome_message: 'true',
|
||||||
|
};
|
||||||
|
const responseString = new URLSearchParams(responseObj).toString();
|
||||||
|
const responsePayload = Buffer.from(responseString, 'utf8').toString('base64');
|
||||||
|
const responseSignature = calcSignature(responsePayload, Deps.DISCOURSE_CONNECT_SECRET!);
|
||||||
|
const redirectUrl = new URL('/session/sso_login', Deps.DISCOURSE_SITE);
|
||||||
|
redirectUrl.search = new URLSearchParams({sso: responsePayload, sig: responseSignature}).toString();
|
||||||
|
return resp.redirect(redirectUrl.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the endpoint for /discourse-connect, as documented at
|
||||||
|
* https://meta.discourse.org/t/discourseconnect-official-single-sign-on-for-discourse-sso/13045
|
||||||
|
*/
|
||||||
|
export function addDiscourseConnectEndpoints(app: Express, options: {
|
||||||
|
userIdMiddleware: RequestHandler,
|
||||||
|
redirectToLogin: RequestHandler,
|
||||||
|
}) {
|
||||||
|
app.get('/discourse-connect',
|
||||||
|
expressWrap(checkParams), // Check early, to fail early if Discourse is misconfigured.
|
||||||
|
options.userIdMiddleware,
|
||||||
|
options.redirectToLogin,
|
||||||
|
expressWrap(discourseConnect)
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in new issue