diff --git a/backend/src/api-tests/sso.test.ts b/backend/src/api-tests/sso.test.ts index 69bfae13..b64a3ce1 100644 --- a/backend/src/api-tests/sso.test.ts +++ b/backend/src/api-tests/sso.test.ts @@ -53,3 +53,46 @@ describe('/api/sso/service', () => { .expect(200); }); }); + +describe('/api/sso/public', () => { + const APP = testServerRunner(async () => ({ + run: await appFactory( + testConfig({ + insecure: { + sharedAccount: { enabled: true, authUrl: '/insecure-login' }, + }, + }), + ), + })); + + it('returns a signed JWT token with the user ID', async (props) => { + const { server } = props.getTyped(APP); + + const response1 = await request(server) + .get('/insecure-login?redirect_uri=http://example.com/&state={}') + .expect(303); + + const url = new URL(response1.headers['location']!); + expect(url.host).toEqual('example.com'); + const urlParams = new URLSearchParams(url.hash.substring(1)); + const externalToken = urlParams.get('token')!; + expect(externalToken.length).toBeGreaterThan(10); + + const response2 = await request(server) + .post('/api/sso/public') + .send({ externalToken }) + .expect(200); + + const { userToken } = response2.body; + const data = jwt.decode(userToken, '', true); + + expect(data.aud).toEqual('user'); + expect(data.sub).toEqual('everybody'); + expect(data.iss).toEqual('public'); + + await request(server) + .get('/api/retros') + .set('Authorization', `Bearer ${userToken}`) + .expect(200); + }); +}); diff --git a/backend/src/api-tests/testConfig.ts b/backend/src/api-tests/testConfig.ts index 3482dbc4..462eaf66 100644 --- a/backend/src/api-tests/testConfig.ts +++ b/backend/src/api-tests/testConfig.ts @@ -20,6 +20,12 @@ const baseTestConfig: ConfigT = { token: { secretPassphrase: '' }, encryption: { secretKey: '' }, db: { url: 'memory://' }, + insecure: { + sharedAccount: { + enabled: false, + authUrl: '/insecure-login', + }, + }, sso: { google: { clientId: '', authUrl: '', tokenInfoUrl: '' }, github: { diff --git a/backend/src/app.ts b/backend/src/app.ts index 265341d2..391c3502 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -2,7 +2,6 @@ import { join } from 'node:path'; import { WebSocketExpress } from 'websocket-express'; import { connectDB } from './import-wrappers/collection-storage-wrap'; import { Hasher } from 'pwd-hasher'; -import ab from 'authentication-backend'; import { ApiConfigRouter } from './routers/ApiConfigRouter'; import { ApiAuthRouter } from './routers/ApiAuthRouter'; import { ApiSlugsRouter } from './routers/ApiSlugsRouter'; @@ -18,8 +17,14 @@ import { RetroService } from './services/RetroService'; import { RetroArchiveService } from './services/RetroArchiveService'; import { RetroAuthService } from './services/RetroAuthService'; import { UserAuthService } from './services/UserAuthService'; +import { getAuthBackend } from './auth'; import { type ConfigT } from './config'; import { basedir } from './basedir'; +import { + addNoCacheHeaders, + addSecurityHeaders, + removeHtmlSecurityHeaders, +} from './headers'; export interface TestHooks { retroService: RetroService; @@ -37,53 +42,6 @@ export class App { ) {} } -const devMode = process.env['NODE_ENV'] === 'development'; - -const CSP_DOMAIN_PLACEHOLDER = /\(domain\)/g; -const CSP = [ - "base-uri 'self'", - "default-src 'self'", - "object-src 'none'", - `script-src 'self'${devMode ? " 'unsafe-eval'" : ''}`, - `style-src 'self'${ - devMode - ? " 'unsafe-inline'" - : " 'sha256-dhQFgDyZCSW+FVxPjFWZQkEnh+5DHADvj1I8rpzmaGU='" - }`, - 'trusted-types dynamic-import', - "require-trusted-types-for 'script'", - // https://github.com/w3c/webappsec-csp/issues/7 (2023: still required for Mobile Safari) - `connect-src 'self' wss://(domain)${devMode ? ' ws://(domain)' : ''}`, - "img-src 'self' data: https://*.giphy.com", - "form-action 'none'", - "frame-ancestors 'none'", -].join('; '); - -const PERMISSIONS_POLICY = [ - 'accelerometer=()', - 'autoplay=()', - 'camera=()', - 'geolocation=()', - 'gyroscope=()', - 'interest-cohort=()', - 'magnetometer=()', - 'microphone=()', - 'payment=()', - 'sync-xhr=()', - 'usb=()', -].join(', '); - -function getHost(req: { hostname: string }): string { - const raw: string = req.hostname; - if (raw.includes(':')) { - return raw; - } - // Bug in express 4.x: hostname does not include port - // fixed in 5, but not released yet - // https://expressjs.com/en/guide/migrating-5.html#req.host - return `${raw}:*`; -} - function readKey(value: string, length: number): Buffer { if (!value) { return Buffer.alloc(length); @@ -111,10 +69,7 @@ export const appFactory = async (config: ConfigT): Promise => { const userAuthService = new UserAuthService(tokenManager); await userAuthService.initialise(db); - const sso = ab.buildAuthenticationBackend( - config.sso, - userAuthService.grantLoginToken, - ); + const auth = getAuthBackend(config, userAuthService); const app = new WebSocketExpress(); @@ -126,33 +81,13 @@ export const appFactory = async (config: ConfigT): Promise => { app.set('shutdown timeout', 5000); app.useHTTP((req, res, next) => { - res.header('x-frame-options', 'DENY'); - res.header('x-xss-protection', '1; mode=block'); - res.header('x-content-type-options', 'nosniff'); - res.header( - 'content-security-policy', - CSP.replace(CSP_DOMAIN_PLACEHOLDER, getHost(req)), - ); - res.header('permissions-policy', PERMISSIONS_POLICY); - res.header('referrer-policy', 'no-referrer'); - res.header('cross-origin-opener-policy', 'same-origin'); - // Note: CORP causes manifest icons to fail to load in Chrome Devtools, - // but does not break actual functionality - // See: https://issues.chromium.org/issues/41451129 - res.header('cross-origin-resource-policy', 'same-origin'); - res.header('cross-origin-embedder-policy', 'require-corp'); + addSecurityHeaders(req, res); next(); }); app.useHTTP('/api', (_, res, next) => { - res.header('cache-control', 'no-cache, no-store'); - res.header('expires', '0'); - res.header('pragma', 'no-cache'); - res.removeHeader('content-security-policy'); - res.removeHeader('permissions-policy'); - res.removeHeader('referrer-policy'); - res.removeHeader('cross-origin-opener-policy'); - res.removeHeader('cross-origin-embedder-policy'); + removeHtmlSecurityHeaders(res); + addNoCacheHeaders(res); next(); }); @@ -161,8 +96,8 @@ export const appFactory = async (config: ConfigT): Promise => { new ApiAuthRouter(userAuthService, retroAuthService, retroService), ); app.use('/api/slugs', new ApiSlugsRouter(retroService)); - app.use('/api/config', new ApiConfigRouter(config, sso.service.clientConfig)); - app.useHTTP('/api/sso', sso.router); + app.use('/api/config', new ApiConfigRouter(config, auth.clientConfig)); + auth.addRoutes(app); const apiRetrosRouter = new ApiRetrosRouter( userAuthService, retroAuthService, diff --git a/backend/src/config/default.json b/backend/src/config/default.json index 2a0c6295..9f14ccbb 100644 --- a/backend/src/config/default.json +++ b/backend/src/config/default.json @@ -20,6 +20,12 @@ "db": { "url": "memory://refacto" }, + "insecure": { + "sharedAccount": { + "enabled": false, + "authUrl": "/api/open-login" + } + }, "sso": { "google": { "clientId": "", diff --git a/backend/src/routers/ApiConfigRouter.ts b/backend/src/routers/ApiConfigRouter.ts index 58d4ed41..e8edcdb5 100644 --- a/backend/src/routers/ApiConfigRouter.ts +++ b/backend/src/routers/ApiConfigRouter.ts @@ -1,22 +1,18 @@ import { Router } from 'websocket-express'; -import { type ClientConfig } from '../shared/api-entities'; -import { type AuthenticationClientConfiguration } from 'authentication-backend'; +import type { ClientConfig } from '../shared/api-entities'; interface ServerConfig { - sso: ClientConfig['sso']; - giphy: { - apiKey: string; - }; + giphy: { apiKey: string }; } export class ApiConfigRouter extends Router { public constructor( serverConfig: ServerConfig, - ssoClientConfig: AuthenticationClientConfiguration, + ssoClientConfig: ClientConfig['sso'], ) { super(); - const clientConfig = { + const clientConfig: ClientConfig = { sso: ssoClientConfig, giphy: serverConfig.giphy.apiKey !== '', }; diff --git a/docs/SERVICES.md b/docs/SERVICES.md index 45072a3d..ac95fc32 100644 --- a/docs/SERVICES.md +++ b/docs/SERVICES.md @@ -142,6 +142,45 @@ SSO_GITLAB_CLIENT_ID="idhere" \ ./index.js ``` +### Public access + +If you are running Refacto in a private network where all users are +trusted, you can set up Refacto to allow all users access to a single +account. This is simpler than setting up an authentication provider, +but will allow everybody access to the same account. + +```sh +INSECURE_SHARED_ACCOUNT_ENABLED=true ./index.js +``` + +By default this will use `/api/open-login` as the login URL. If you +want to use a different URL, you can configure it: + +```sh +INSECURE_SHARED_ACCOUNT_ENABLED=true \ +INSECURE_SHARED_ACCOUNT_AUTH_URL="/custom-path" \ +./index.js +``` + +You may want to provide some additional security by protecting this +URL in your proxy. For example, to enable Basic auth using NGINX: + +``` +location /api/open-login { + auth_basic "Admin"; + auth_basic_user_file /etc/apache2/.htpasswd; +} +``` + +Or to enable access only from a specific IP: + +``` +location /api/open-login { + allow 1.2.3.4/32; + deny all; +} +``` + ## Other Integrations ### Giphy diff --git a/frontend/src/components/login/LoginForm.tsx b/frontend/src/components/login/LoginForm.tsx index 48930c00..72d38db5 100644 --- a/frontend/src/components/login/LoginForm.tsx +++ b/frontend/src/components/login/LoginForm.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react'; +import { memo, useLayoutEffect } from 'react'; import { useConfig } from '../../hooks/data/useConfig'; import { toHex, randomBytes } from '../../helpers/crypto'; import { storage } from './storage'; @@ -23,6 +23,8 @@ interface PropsT { export const LoginForm = memo(({ message, redirect }: PropsT) => { const config = useConfig(); const sso = config?.sso ?? {}; + + const publicConfig = sso['public']; const googleConfig = sso['google']; const githubConfig = sso['github']; const gitlabConfig = sso['gitlab']; @@ -30,6 +32,16 @@ export const LoginForm = memo(({ message, redirect }: PropsT) => { const resolvedRedirect = redirect || document.location.pathname; const domain = document.location.origin; + useLayoutEffect(() => { + if (publicConfig) { + const targetUrl = new URL('/sso/public', domain); + const url = new URL(publicConfig.authUrl, document.location.href); + url.searchParams.set('redirect_uri', targetUrl.toString()); + url.searchParams.set('state', makeState(resolvedRedirect)); + document.location.href = url.toString(); + } + }, [publicConfig]); + return (
{message ?

{message}

: null} diff --git a/frontend/src/components/login/handleLogin.ts b/frontend/src/components/login/handleLogin.ts index 91c708c3..3321d8db 100644 --- a/frontend/src/components/login/handleLogin.ts +++ b/frontend/src/components/login/handleLogin.ts @@ -17,7 +17,10 @@ export async function handleLogin( const hashParams = new URLSearchParams(hash.substring(1)); const searchParams = new URLSearchParams(search.substring(1)); - if (service === 'google') { + if (service === 'public') { + externalToken = hashParams.get('token'); + state = hashParams.get('state'); + } else if (service === 'google') { externalToken = hashParams.get('id_token'); state = hashParams.get('state'); } else if (service === 'github') {