Skip to content

Commit

Permalink
Add option to trust all visitors
Browse files Browse the repository at this point in the history
  • Loading branch information
davidje13 committed Nov 16, 2024
1 parent e58181b commit 926871d
Show file tree
Hide file tree
Showing 8 changed files with 127 additions and 87 deletions.
43 changes: 43 additions & 0 deletions backend/src/api-tests/sso.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
6 changes: 6 additions & 0 deletions backend/src/api-tests/testConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
89 changes: 12 additions & 77 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -111,10 +69,7 @@ export const appFactory = async (config: ConfigT): Promise<App> => {
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();

Expand All @@ -126,33 +81,13 @@ export const appFactory = async (config: ConfigT): Promise<App> => {
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();
});

Expand All @@ -161,8 +96,8 @@ export const appFactory = async (config: ConfigT): Promise<App> => {
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,
Expand Down
6 changes: 6 additions & 0 deletions backend/src/config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
"db": {
"url": "memory://refacto"
},
"insecure": {
"sharedAccount": {
"enabled": false,
"authUrl": "/api/open-login"
}
},
"sso": {
"google": {
"clientId": "",
Expand Down
12 changes: 4 additions & 8 deletions backend/src/routers/ApiConfigRouter.ts
Original file line number Diff line number Diff line change
@@ -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 !== '',
};
Expand Down
39 changes: 39 additions & 0 deletions docs/SERVICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/components/login/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -23,13 +23,25 @@ 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'];

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 (
<div className="login-form">
{message ? <p>{message}</p> : null}
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/login/handleLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down

0 comments on commit 926871d

Please sign in to comment.