Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: Added client credentials grant for API calling from services. #6325

Merged
merged 2 commits into from
Apr 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
58 changes: 48 additions & 10 deletions doc/api/http_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```


Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"}}`

Expand All @@ -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=<APIKEY>&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.
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
Expand Down Expand Up @@ -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

Expand Down
28 changes: 21 additions & 7 deletions src/node/security/OAuth2Provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -19,7 +20,6 @@ const configuration: Configuration = {
is_admin: boolean;
}
}

const usersArray1 = Object.keys(users).map((username) => ({
username,
...users[username]
Expand Down Expand Up @@ -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 {
Expand All @@ -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<string> = {}

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
Expand Down Expand Up @@ -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);
})
Expand All @@ -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();
}
1 change: 1 addition & 0 deletions src/static/js/pluginfw/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) => {
Expand Down