From 8a66b04b68f66d47cca6a977a9c030be40c6856a Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+SamTV12345@users.noreply.github.com> Date: Sat, 13 Apr 2024 10:32:23 +0200 Subject: [PATCH] chore: Added client credentials grant for API calling from services. (#6325) * chore: Added client credentials grant for API calling from services. * chore: Added authentication documentation --- doc/api/http_api.md | 58 ++++++++++++++++++++++++----- src/node/security/OAuth2Provider.ts | 28 ++++++++++---- src/static/js/pluginfw/installer.ts | 1 + 3 files changed, 70 insertions(+), 17 deletions(-) diff --git a/doc/api/http_api.md b/doc/api/http_api.md index b11f90b1310..ffaa3af371e 100644 --- a/doc/api/http_api.md +++ b/doc/api/http_api.md @@ -28,7 +28,7 @@ Portal maps the internal userid to an etherpad author. #### Request ```http -GET /api/1/createAuthorIfNotExistsFor?apikey=secret&name=Michael&authorMapper=7 +GET /api/1/createAuthorIfNotExistsFor?name=Michael&authorMapper=7 ``` @@ -42,7 +42,7 @@ GET /api/1/createAuthorIfNotExistsFor?apikey=secret&name=Michael&authorMapper=7 > Portal maps the internal userid to an etherpad group: ```http -GET http://pad.domain/api/1/createGroupIfNotExistsFor?apikey=secret&groupMapper=7 +GET http://pad.domain/api/1/createGroupIfNotExistsFor?groupMapper=7 ``` ### Response @@ -56,7 +56,7 @@ GET http://pad.domain/api/1/createGroupIfNotExistsFor?apikey=secret&groupMapper= #### Request ```http -GET http://pad.domain/api/1/createGroupPad?apikey=secret&groupID=g.s8oes9dhwrvt0zif&padName=samplePad&text=This is the first sentence in the pad +GET http://pad.domain/api/1/createGroupPad?groupID=g.s8oes9dhwrvt0zif&padName=samplePad&text=This is the first sentence in the pad ``` #### Response @@ -70,7 +70,7 @@ GET http://pad.domain/api/1/createGroupPad?apikey=secret&groupID=g.s8oes9dhwrvt0 #### Request ```http -GET http://pad.domain/api/1/createSession?apikey=secret&groupID=g.s8oes9dhwrvt0zif&authorID=a.s8oes9dhwrvt0zif&validUntil=1312201246 +GET http://pad.domain/api/1/createSession?groupID=g.s8oes9dhwrvt0zif&authorID=a.s8oes9dhwrvt0zif&validUntil=1312201246 ``` ### Response @@ -87,7 +87,7 @@ A portal (such as WordPress) wants to transform the contents of a pad that multi Portal retrieves the contents of the pad for entry into the db as a blog post: -> Request: `http://pad.domain/api/1/getText?apikey=secret&padID=g.s8oes9dhwrvt0zif$123` +> Request: `http://pad.domain/api/1/getText?&padID=g.s8oes9dhwrvt0zif$123` > > Response: `{code: 0, message:"ok", data: {text:"Welcome Text"}}` @@ -108,23 +108,23 @@ The API is accessible via HTTP. Starting from **1.8**, API endpoints can be invo The URL of the HTTP request is of the form: `/api/$APIVERSION/$FUNCTIONNAME`. $APIVERSION depends on the endpoint you want to use. Depending on the verb you use (GET or POST) **parameters** can be passed differently. -When invoking via GET (mandatory until **1.7.5** included), parameters must be included in the query string (example: `/api/$APIVERSION/$FUNCTIONNAME?apikey=¶m1=value1`). Please note that starting with nodejs 8.14+ the total size of HTTP request headers has been capped to 8192 bytes. This limits the quantity of data that can be sent in an API request. +When invoking via GET (mandatory until **1.7.5** included), parameters must be included in the query string (example: `/api/$APIVERSION/$FUNCTIONNAME?param1=value1`). Please note that starting with nodejs 8.14+ the total size of HTTP request headers has been capped to 8192 bytes. This limits the quantity of data that can be sent in an API request. Starting from Etherpad **1.8** it is also possible to invoke the HTTP API via POST. In this case, querystring parameters will still be accepted, but **any parameter with the same name sent via POST will take precedence**. If you need to send large chunks of text (for example, for `setText()`) it is advisable to invoke via POST. Example with cURL using GET (toy example, no encoding): ``` -curl "http://pad.domain/api/1/setText?apikey=secret&padID=padname&text=this_text_will_NOT_be_encoded_by_curl_use_next_example" +curl "http://pad.domain/api/1/setText?padID=padname&text=this_text_will_NOT_be_encoded_by_curl_use_next_example" ``` Example with cURL using GET (better example, encodes text): ``` -curl "http://pad.domain/api/1/setText?apikey=secret&padID=padname" --get --data-urlencode "text=Text sent via GET with proper encoding. For big documents, please use POST" +curl "http://pad.domain/api/1/setText?padID=padname" --get --data-urlencode "text=Text sent via GET with proper encoding. For big documents, please use POST" ``` Example with cURL using POST: ``` -curl "http://pad.domain/api/1/setText?apikey=secret&padID=padname" --data-urlencode "text=Text sent via POST with proper encoding. For big texts (>8 KB), use this method" +curl "http://pad.domain/api/1/setText?padID=padname" --data-urlencode "text=Text sent via POST with proper encoding. For big texts (>8 KB), use this method" ``` ### Response Format @@ -161,7 +161,45 @@ Responses are valid JSON in the following format: ### Authentication -Authentication works via a token that is sent with each request as a post parameter. There is a single token per Etherpad deployment. This token will be random string, generated by Etherpad at the first start. It will be saved in APIKEY.txt in the root folder of Etherpad. Only Etherpad and the requesting application knows this key. Token management will not be exposed through this API. +Authentication works via an OAuth token that is sent with each request as a post parameter. You can add new clients that can sign in via the API by adding new entries to the sso section in the settings.json. + + +#### Example for browser login clients + +This example illustrates how to add a new client that can sign in via the API using the browser login method. This method is used for users trying to sign in to the API via the browser. You can log in with the users in the settings.json file. The redirect URI is the URL where the user is redirected after the login. This is normally your etherpad instance url. + +```json + { + "client_id": "admin_client", + "client_secret": "admin", + "grant_types": ["authorization_code"], + "response_types": ["code"], + "redirect_uris": ["http://my-etherpad-instance.com"], + } +``` + + +#### Example for services + +This example illustrates how to add a new client that can sign in via the API using the client credentials method. This method is used for services trying to sign in to the API where there is no browser. +E.g. a service that creates a pad for a user or a service that inserts a text into a pad. Just make sure that the secret is complex enough as anybody who knows the secret can access the API. + +```json + { + "client_id": "client_credentials", + "redirect_uris": [], + "response_types": [], + "grant_types": ["client_credentials"], + "client_secret": "client_credentials", + "extraParams": [ + { + "name": "admin", + "value": "true" + } + ] +} +``` + ### Node Interoperability diff --git a/src/node/security/OAuth2Provider.ts b/src/node/security/OAuth2Provider.ts index 3c6583e6222..e34926d5b1d 100644 --- a/src/node/security/OAuth2Provider.ts +++ b/src/node/security/OAuth2Provider.ts @@ -9,6 +9,7 @@ import express, {Request, Response} from 'express'; import {format} from 'url' import {ParsedUrlQuery} from "node:querystring"; import {Http2ServerRequest, Http2ServerResponse} from "node:http2"; +import {MapArrayType} from "../types/MapType"; const configuration: Configuration = { scopes: ['openid', 'profile', 'email'], @@ -19,7 +20,6 @@ const configuration: Configuration = { is_admin: boolean; } } - const usersArray1 = Object.keys(users).map((username) => ({ username, ...users[username] @@ -99,28 +99,29 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp features:{ userinfo: {enabled: true}, claimsParameter: {enabled: true}, + clientCredentials: {enabled: true}, devInteractions: {enabled: false}, resourceIndicators: {enabled: true, defaultResource(ctx) { return ctx.origin; }, getResourceServerInfo(ctx, resourceIndicator, client) { return { - scope: client.scope as string, + scope: "openid", audience: 'account', accessTokenFormat: 'jwt', }; }, useGrantedResource(ctx, model) { return true; - },}, + }, + }, jwtResponseModes: {enabled: true}, }, clientBasedCORS: (ctx, origin, client) => { return true }, + extraParams: [], extraTokenClaims: async (ctx, token) => { - - if(token.kind === 'AccessToken') { // Add your custom claims here. For example: const users = settings.users as { @@ -139,6 +140,19 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp return { admin: account?.is_admin }; + } else if (token.kind === "ClientCredentials") { + let extraParams: MapArrayType = {} + + settings.sso.clients + .filter((client:any) => client.client_id === token.clientId) + .forEach((client:any) => { + if(client.extraParams !== undefined) { + client.extraParams.forEach((param:any) => { + extraParams[param.name] = param.value + }) + } + }) + return extraParams } }, clients: settings.sso.clients @@ -252,7 +266,7 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp args.app.use('/views/', express.static(path.join(settings.root,'src','static', 'oidc'), {maxAge: 1000 * 60 * 60 * 24})); - /* + oidc.on('authorization.error', (ctx, error) => { console.log('authorization.error', error); }) @@ -268,7 +282,7 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp }) oidc.on('revocation.error', (ctx, error) => { console.log('revocation.error', error); - })*/ + }) args.app.use("/oidc", oidc.callback()); //cb(); } diff --git a/src/static/js/pluginfw/installer.ts b/src/static/js/pluginfw/installer.ts index 973bdd56f56..4d2ceaf4511 100644 --- a/src/static/js/pluginfw/installer.ts +++ b/src/static/js/pluginfw/installer.ts @@ -60,6 +60,7 @@ const migratePluginsFromNodeModules = async () => { const cmd = ['pnpm', 'ls', '--long', '--json', '--depth=0', '--no-production']; const [{dependencies = {}}] = JSON.parse(await runCmd(cmd, {stdio: [null, 'string']})); + await Promise.all(Object.entries(dependencies) .filter(([pkg, info]) => pkg.startsWith(plugins.prefix) && pkg !== 'ep_etherpad-lite') .map(async ([pkg, info]) => {