From c966e1292606665c02bcc57ae39199e407fd6a25 Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Mon, 25 Mar 2024 15:34:37 +0100 Subject: [PATCH] Added working oauth2. --- .gitignore | 1 + Dockerfile | 4 + admin/vite.config.ts | 3 +- settings.json.docker | 20 +++- settings.json.template | 21 +++- src/node/security/OAuth2Provider.ts | 129 +++++++++++++++----- src/node/security/OIDCAdapter.ts | 6 +- src/node/utils/Settings.ts | 5 + src/package.json | 2 + src/static/oidc/consent.html | 18 --- src/static/oidc/login.html | 50 -------- ui/consent.html | 17 ++- ui/login.html | 45 +++++-- ui/package.json | 3 +- ui/public/vite.svg | 1 - ui/src/consent.ts | 35 ++++++ ui/src/main.ts | 55 +++++++++ ui/src/style.css | 177 ++++++++++++++++------------ ui/vite.config.ts | 7 +- 19 files changed, 401 insertions(+), 198 deletions(-) delete mode 100644 src/static/oidc/consent.html delete mode 100644 src/static/oidc/login.html delete mode 100644 ui/public/vite.svg diff --git a/.gitignore b/.gitignore index f577330c9..71584e76b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ plugin_packages /src/test-results playwright-report state.json +/src/static/oidc diff --git a/Dockerfile b/Dockerfile index 06f182bf5..ebbba0003 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,8 +8,10 @@ FROM node:alpine as adminBuild WORKDIR /opt/etherpad-lite COPY ./admin ./admin +COPY ./ui ./ui COPY ./src/locales ./src/locales RUN cd ./admin && npm install -g pnpm && pnpm install && pnpm run build --outDir ./dist +RUN cd ./ui && pnpm install && pnpm run build --outDir ./dist FROM node:alpine as build @@ -116,6 +118,7 @@ FROM build as development COPY --chown=etherpad:etherpad ./src/package.json .npmrc ./src/ COPY --chown=etherpad:etherpad --from=adminBuild /opt/etherpad-lite/admin/dist ./src/templates/admin +COPY --chown=etherpad:etherpad --from=adminBuild /opt/etherpad-lite/ui/dist ./src/static/oidc RUN bin/installDeps.sh && \ if [ ! -z "${ETHERPAD_PLUGINS}" ] || [ ! -z "${ETHERPAD_LOCAL_PLUGINS}" ]; then \ @@ -130,6 +133,7 @@ ENV ETHERPAD_PRODUCTION=true COPY --chown=etherpad:etherpad ./src ./src COPY --chown=etherpad:etherpad --from=adminBuild /opt/etherpad-lite/admin/dist ./src/templates/admin +COPY --chown=etherpad:etherpad --from=adminBuild /opt/etherpad-lite/ui/dist ./src/static/oidc RUN bin/installDeps.sh && rm -rf ~/.npm && rm -rf ~/.local && rm -rf ~/.cache && \ if [ ! -z "${ETHERPAD_PLUGINS}" ] || [ ! -z "${ETHERPAD_LOCAL_PLUGINS}" ]; then \ diff --git a/admin/vite.config.ts b/admin/vite.config.ts index ff329032f..d90386ca8 100644 --- a/admin/vite.config.ts +++ b/admin/vite.config.ts @@ -15,7 +15,8 @@ export default defineConfig({ })], base: '/admin', build:{ - outDir: '../src/templates/admin' + outDir: '../src/templates/admin', + emptyOutDir: true, }, server:{ proxy: { diff --git a/settings.json.docker b/settings.json.docker index 7fbda1aef..b4a9ebbd0 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -650,5 +650,23 @@ /* * Enable/Disable case-insensitive pad names. */ - "lowerCasePadIds": "${LOWER_CASE_PAD_IDS:false}" + "lowerCasePadIds": "${LOWER_CASE_PAD_IDS:false}", + "sso": { + "clients": [ + { + "client_id": "admin_client", + "client_secret": "admin", + "grant_types": ["authorization_code"], + "response_types": ["code"], + "redirect_uris": ["http://localhost:9001/admin/*"] + }, + { + "client_id": "user_client", + "client_secret": "user", + "grant_types": ["authorization_code"], + "response_types": ["code"], + "redirect_uris": ["http://localhost:9001/*"] + } + ] + } } diff --git a/settings.json.template b/settings.json.template index 9c9150394..48fc7a17d 100644 --- a/settings.json.template +++ b/settings.json.template @@ -650,5 +650,24 @@ /* * Enable/Disable case-insensitive pad names. */ - "lowerCasePadIds": false + "lowerCasePadIds": false, + + "sso": { + "clients": [ + { + "client_id": "admin_client", + "client_secret": "admin", + "grant_types": ["authorization_code"], + "response_types": ["code"], + "redirect_uris": ["http://localhost:9001/admin/*"] + }, + { + "client_id": "user_client", + "client_secret": "user", + "grant_types": ["authorization_code"], + "response_types": ["code"], + "redirect_uris": ["http://localhost:9001/*"] + } + ] + } } diff --git a/src/node/security/OAuth2Provider.ts b/src/node/security/OAuth2Provider.ts index b5afbb5cb..520d64ae1 100644 --- a/src/node/security/OAuth2Provider.ts +++ b/src/node/security/OAuth2Provider.ts @@ -5,36 +5,37 @@ import MemoryAdapter from "./OIDCAdapter"; import path from "path"; const settings = require('../utils/Settings'); import {IncomingForm} from 'formidable' -import {Request, Response} from 'express'; +import express, {Request, Response} from 'express'; import {format} from 'url' import {ParsedUrlQuery} from "node:querystring"; +import cors from 'cors' +import {Http2ServerRequest, Http2ServerResponse} from "node:http2"; const configuration: Configuration = { - // refer to the documentation for other available configuration - clients: [ { - client_id: 'oidc_client', - client_secret: 'a_different_secret', - grant_types: ['authorization_code'], - response_types: ['code'], - redirect_uris: ['http://localhost:3001/cb', 'https://oauth.pstmn.io/v1/callback'] - }, - { - client_id: 'app', - client_secret: 'a_secret', - grant_types: ['client_credentials'], - redirect_uris: [], - response_types: [] - } - ], scopes: ['openid', 'profile', 'email'], findAccount: async (ctx, id) => { + const users = settings.users as { + [username: string]: { + password: string; + is_admin: boolean; + } + } + + const usersArray1 = Object.keys(users).map((username) => ({ + username, + ...users[username] + })); + + const account = usersArray1.find((user) => user.username === id); + return { accountId: id, claims: () => ({ sub: id, + test: "test", + admin: account?.is_admin }) } as Account }, - ttl:{ AccessToken: 1 * 60 * 60, // 1 hour in seconds AuthorizationCode: 10 * 60, // 10 minutes in seconds @@ -42,6 +43,12 @@ const configuration: Configuration = { IdToken: 1 * 60 * 60, // 1 hour in seconds RefreshToken: 1 * 24 * 60 * 60, // 1 day in seconds }, + claims: { + openid: ['sub'], + email: ['email'], + profile: ['name'], + admin: ['admin'] + }, cookies: { keys: ['oidc'], }, @@ -52,24 +59,89 @@ const configuration: Configuration = { }; +/* +This function is used to initialize the OAuth2 provider + */ export const expressCreateServer = async (hookName: string, args: ArgsExpressType, cb: Function) => { const {privateKey} = await generateKeyPair('RS256'); const privateKeyJWK = await exportJWK(privateKey); + // Use cors middleware + args.app.use(cors({ + origin: ['http://localhost:3001', 'https://oauth.pstmn.io'], // replace with your allowed origins + })); + const oidc = new Provider('http://localhost:9001', { ...configuration, jwks: { keys: [ privateKeyJWK ], }, + conformIdTokenClaims: false, + claims: { + address: ['address'], + email: ['email', 'email_verified'], + phone: ['phone_number', 'phone_number_verified'], + profile: ['birthdate', 'family_name', 'gender', 'given_name', 'locale', 'middle_name', 'name', + 'nickname', 'picture', 'preferred_username', 'profile', 'updated_at', 'website', 'zoneinfo'], + }, + features:{ + userinfo: {enabled: true}, + + claimsParameter: {enabled: true}, + devInteractions: {enabled: false}, + resourceIndicators: {enabled: true, defaultResource(ctx) { + return ctx.origin; + }, + getResourceServerInfo(ctx, resourceIndicator, client) { + return { + scope: client.scope as string, + audience: 'account', + accessTokenFormat: 'jwt', + }; + }, + useGrantedResource(ctx, model) { + return true; + },}, + jwtResponseModes: {enabled: true}, + }, + clientBasedCORS: (ctx, origin, client) => { + return true + }, + extraTokenClaims: async (ctx, token) => { + + + if(token.kind === 'AccessToken') { + // Add your custom claims here. For example: + const users = settings.users as { + [username: string]: { + password: string; + is_admin: boolean; + } + } + + const usersArray1 = Object.keys(users).map((username) => ({ + username, + ...users[username] + })); + + const account = usersArray1.find((user) => user.username === token.accountId); + return { + admin: account?.is_admin + }; + } + }, + clients: settings.sso.clients }); - args.app.post('/interaction/:uid', async (req, res, next) => { + args.app.post('/interaction/:uid', async (req: Http2ServerRequest, res: Http2ServerResponse, next:Function) => { const formid = new IncomingForm(); try { + // @ts-ignore const {login, password} = (await formid.parse(req))[0] - const {prompt, jti, session, params, grantId} = await oidc.interactionDetails(req, res); + const {prompt, jti, session,cid, params, grantId} = await oidc.interactionDetails(req, res); + const client = await oidc.Client.find(params.client_id as string); switch (prompt.name) { case 'login': { @@ -110,6 +182,7 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp } if (prompt.details.missingOIDCScope) { + // @ts-ignore grant!.addOIDCScope(prompt.details.missingOIDCScope.join(' ')); } if (prompt.details.missingOIDCClaims) { @@ -128,13 +201,13 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp } } await next(); - } catch (err) { + } catch (err:any) { return res.writeHead(500).end(err.message); } }) - args.app.get('/interaction/:uid', async (req: Request, res: Response, next) => { + args.app.get('/interaction/:uid', async (req: Request, res: Response, next: Function) => { try { const { uid, prompt, params, session, @@ -145,14 +218,14 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp switch (prompt.name) { case 'login': { res.redirect(format({ - pathname: '/views/login', + pathname: '/views/login.html', query: params as ParsedUrlQuery })) break } case 'consent': { res.redirect(format({ - pathname: '/views/consent', + pathname: '/views/consent.html', query: params as ParsedUrlQuery })) break @@ -166,13 +239,7 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp }); - args.app.get('/views/login', async (req, res) => { - res.sendFile(path.join(settings.root,'src','static', 'oidc','login.html')); - }) - - args.app.get('/views/consent', async (req, res) => { - res.sendFile(path.join(settings.root,'src','static', 'oidc','consent.html')); - }) + args.app.use('/views/', express.static(path.join(settings.root,'src','static', 'oidc'), {maxAge: 1000 * 60 * 60 * 24})); /* oidc.on('authorization.error', (ctx, error) => { diff --git a/src/node/security/OIDCAdapter.ts b/src/node/security/OIDCAdapter.ts index 219f9edf6..7fb907776 100644 --- a/src/node/security/OIDCAdapter.ts +++ b/src/node/security/OIDCAdapter.ts @@ -4,7 +4,7 @@ import type {Adapter, AdapterPayload} from "oidc-provider"; const options = { max: 500, - sizeCalculation: (item, key) => { + sizeCalculation: (item:any, key:any) => { return 1 }, // for use with tracking overall storage size @@ -43,7 +43,6 @@ class MemoryAdapter implements Adapter{ } destroy(id:string) { - console.log("destroy", id); const key = this.key(id); const found = storage.get(key) as AdapterPayload; @@ -61,13 +60,11 @@ class MemoryAdapter implements Adapter{ } consume(id: string) { - console.log("consume", id); (storage.get(this.key(id)) as AdapterPayload)!.consumed = epochTime(); return Promise.resolve(); } find(id: string): Promise { - const foundSession = storage.get(this.key(id)) as AdapterPayload; if (storage.has(this.key(id))){ return Promise.resolve(storage.get(this.key(id)) as AdapterPayload); } @@ -88,7 +85,6 @@ class MemoryAdapter implements Adapter{ accountId: string; loginTs: number; }, expiresIn: number) { - console.log("upsert", payload); const key = this.key(id); storage.set(key, payload, {ttl: expiresIn * 1000}); diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 0fd964872..426ed555c 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -342,6 +342,11 @@ exports.requireAuthentication = false; exports.requireAuthorization = false; exports.users = {}; +/* + * This setting is used for configuring sso + */ +exports.sso = {} + /* * Show settings in admin page, by default it is true */ diff --git a/src/package.json b/src/package.json index 68825c1b3..011f07895 100644 --- a/src/package.json +++ b/src/package.json @@ -34,6 +34,7 @@ "axios": "^1.6.8", "clean-css": "^5.3.3", "cookie-parser": "^1.4.6", + "cors": "^2.8.5", "cross-spawn": "^7.0.3", "ejs": "^3.1.9", "etherpad-require-kernel": "^1.0.16", @@ -83,6 +84,7 @@ "devDependencies": { "@playwright/test": "^1.42.1", "@types/async": "^3.2.24", + "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/formidable": "^3.4.5", "@types/http-errors": "^2.0.4", diff --git a/src/static/oidc/consent.html b/src/static/oidc/consent.html deleted file mode 100644 index 086740e56..000000000 --- a/src/static/oidc/consent.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - Consent - - - -
- - -
- diff --git a/src/static/oidc/login.html b/src/static/oidc/login.html deleted file mode 100644 index ef9a7ddb2..000000000 --- a/src/static/oidc/login.html +++ /dev/null @@ -1,50 +0,0 @@ - - - - - Title - - - -
- - - - -
-
- - diff --git a/ui/consent.html b/ui/consent.html index d687f6b77..502b95a2e 100644 --- a/ui/consent.html +++ b/ui/consent.html @@ -2,12 +2,23 @@ - + - Vite + TS + Consent Etherpad -
+
+ +
diff --git a/ui/login.html b/ui/login.html index 44a933506..0ff588363 100644 --- a/ui/login.html +++ b/ui/login.html @@ -1,13 +1,38 @@ - - - - - Vite + TS - - -
- - + + + + + SSO Etherpad + + +
+ +
+ + diff --git a/ui/package.json b/ui/package.json index fb5cab322..2a46afa02 100644 --- a/ui/package.json +++ b/ui/package.json @@ -10,6 +10,7 @@ }, "devDependencies": { "typescript": "^5.2.2", - "vite": "^5.2.0" + "vite": "^5.2.0", + "vite-plugin-singlefile": "^2.0.1" } } diff --git a/ui/public/vite.svg b/ui/public/vite.svg deleted file mode 100644 index e7b8dfb1b..000000000 --- a/ui/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/ui/src/consent.ts b/ui/src/consent.ts index e69de29bb..79f6214fe 100644 --- a/ui/src/consent.ts +++ b/ui/src/consent.ts @@ -0,0 +1,35 @@ +import "./style.css" +//import {MapArrayType} from "ep_etherpad-lite/node/types/MapType"; + +const form = document.querySelector('form')!; +const sessionId = new URLSearchParams(window.location.search).get('state'); + +form.action = '/interaction/' + sessionId; + +/*form.addEventListener('submit', function (event) { + event.preventDefault(); + const formData = new FormData(form); + const data: MapArrayType = {}; + formData.forEach((value, key) => { + data[key] = value; + }); + const sessionId = new URLSearchParams(window.location.search).get('state'); + + fetch('/interaction/' + sessionId, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }).then(response => { + if (response.ok) { + if (response.redirected) { + window.location.href = response.url; + } + } else { + document.getElementById('error')!.innerText = "Error signing in"; + } + }).catch(error => { + document.getElementById('error')!.innerText = "Error signing in" + error; + }) +});*/ diff --git a/ui/src/main.ts b/ui/src/main.ts index 4d2f2a286..1ff174cdb 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -1,3 +1,58 @@ import './style.css' +import {MapArrayType} from "ep_etherpad-lite/node/types/MapType.ts"; + +const searchParams = new URLSearchParams(window.location.search); + + +document.getElementById('client')!.innerText = searchParams.get('client_id')!; + +const form = document.querySelector('form')!; +form.addEventListener('submit', function (event) { + event.preventDefault(); + const formData = new FormData(form); + const data: MapArrayType = {}; + formData.forEach((value, key) => { + data[key] = value; + }); + const sessionId = new URLSearchParams(window.location.search).get('state'); + + fetch('/interaction/' + sessionId, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + body: JSON.stringify(data), + }).then(response => { + if (response.ok) { + if (response.redirected) { + window.location.href = response.url; + } + } else { + document.getElementById('error')!.innerText = "Error signing in"; + } + }).catch(error => { + document.getElementById('error')!.innerText = "Error signing in" + error; + }) +}); + +const hidePassword = document.querySelector('.toggle-password-visibility')! as HTMLElement +const showPassword = document.getElementById('eye-hide')! as HTMLElement +const togglePasswordVisibility = () => { + const passwordInput = document.getElementsByName('password')[0] as HTMLInputElement; + if (passwordInput.type === 'password') { + showPassword.style.display = 'block'; + hidePassword.style.display = 'none'; + passwordInput.type = 'text'; + } else { + showPassword.style.display = 'none'; + hidePassword.style.display = 'block'; + passwordInput.type = 'password'; + } +} + + +hidePassword.addEventListener('click', togglePasswordVisibility); +showPassword.addEventListener('click', togglePasswordVisibility); diff --git a/ui/src/style.css b/ui/src/style.css index f9c735024..2e4621d15 100644 --- a/ui/src/style.css +++ b/ui/src/style.css @@ -1,96 +1,125 @@ :root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + --color-etherpad: #0f775b; } body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; + font-size: 16px; + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; } #app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; + max-width: 1280px; + margin: auto; + padding: 2rem; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.vanilla:hover { - filter: drop-shadow(0 0 2em #3178c6aa); -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; } + button:hover { - border-color: #646cff; + border-color: #646cff; } + button:focus, button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; + outline: 4px auto -webkit-focus-ring-color; } @media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } + :root { + color: #213547; + background-color: #ffffff; + } + + a:hover { + color: #747bff; + } + + button { + background-color: #f9f9f9; + } +} + +.login-box { + background-color: #f2f6f7; + padding: 40px; + border-radius: 20px; + color: #607278; +} + +body { + background: radial-gradient(100% 100% at 50% 0%, var(--color-etherpad) 0%, #003A47 100%) fixed +} + +input { + border-radius: 8px; + border: 1px solid #d1d1d1; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #f9f9f9; + transition: border-color 0.25s; +} + +.login-inner-box { + display: flex; + flex-direction: column; + gap: 10px; +} + +.login-inner-box input[type=submit] { + background-color: var(--color-etherpad); + color: white; + border: none; + cursor: pointer; + margin-top: 20px; +} + +.password-label { + position: relative; +} + +.password-label svg { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + cursor: pointer; + width: 16px; +} + +#eye-hide { + display: none; +} + +label { + display: flex; +} + +label input { + flex-grow: 1; } diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 4fafe1389..2ce13f8f7 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -3,12 +3,15 @@ import { resolve } from 'path' import { defineConfig } from 'vite' export default defineConfig({ + base: '/views/', build: { + outDir: resolve(__dirname, '../src/static/oidc'), rollupOptions: { input: { - main: resolve(__dirname, 'index.html'), - nested: resolve(__dirname, 'nested/index.html'), + main: resolve(__dirname, 'consent.html'), + nested: resolve(__dirname, 'login.html'), }, }, + emptyOutDir: true, }, })