@continuoussecuritytooling/keycloak-reporter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bin/start-server.mjs +87 -0
- package/.bin/wait-for-server.sh +13 -0
- package/.docs/webhook-slack-sample.png +0 -0
- package/.docs/webhook-teams-sample.png +0 -0
- package/.editorconfig +23 -0
- package/.eslintignore +1 -0
- package/.eslintrc.cjs +14 -0
- package/.github/FUNDING.yml +2 -0
- package/.github/workflows/pipeline.yml +101 -0
- package/Dockerfile +9 -0
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/cli.ts +125 -0
- package/dist/cli.js +83 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.js +83 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/client.js +41 -0
- package/dist/lib/client.js.map +1 -0
- package/dist/lib/convert.js +9 -0
- package/dist/lib/convert.js.map +1 -0
- package/dist/lib/output.js +98 -0
- package/dist/lib/output.js.map +1 -0
- package/dist/lib/user.js +75 -0
- package/dist/lib/user.js.map +1 -0
- package/dist/package.json +53 -0
- package/dist/src/cli.js +19 -0
- package/dist/src/cli.js.map +1 -0
- package/docker_entrypoint.sh +3 -0
- package/e2e/fixtures/auth-utils/test-realm.json +5095 -0
- package/e2e/run-tests.sh +45 -0
- package/e2e/spec/clients.js +29 -0
- package/e2e/spec/users.js +29 -0
- package/e2e/spec/webhooks.js +60 -0
- package/index.ts +125 -0
- package/jest.config.js +25 -0
- package/lib/client.ts +55 -0
- package/lib/convert.ts +9 -0
- package/lib/output.ts +115 -0
- package/lib/user.ts +99 -0
- package/package.json +53 -0
- package/renovate.json +14 -0
- package/src/cli.ts +26 -0
- package/test/convert.spec.ts +15 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
import { Octokit } from '@octokit/rest'
|
|
4
|
+
import gunzip from 'gunzip-maybe'
|
|
5
|
+
import fetch from 'node-fetch'
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { pipeline } from 'node:stream'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { promisify } from 'node:util'
|
|
12
|
+
import tar from 'tar-fs'
|
|
13
|
+
|
|
14
|
+
const DIR_NAME = path.dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const SERVER_DIR = path.resolve(DIR_NAME, '../tmp/server')
|
|
16
|
+
const SCRIPT_EXTENSION = process.platform === 'win32' ? '.bat' : '.sh'
|
|
17
|
+
|
|
18
|
+
// TODO: Once support for Node.js 14 has been dropped this can be replaced with an import from 'node:stream/promises'.
|
|
19
|
+
// More information: https://nodejs.org/api/stream.html#streams-promises-api
|
|
20
|
+
const pipelineAsync = promisify(pipeline)
|
|
21
|
+
|
|
22
|
+
await startServer()
|
|
23
|
+
|
|
24
|
+
async function startServer () {
|
|
25
|
+
await downloadServer()
|
|
26
|
+
|
|
27
|
+
console.info('Starting server…')
|
|
28
|
+
|
|
29
|
+
const args = process.argv.slice(2)
|
|
30
|
+
const child = spawn(
|
|
31
|
+
path.join(SERVER_DIR, `bin/kc${SCRIPT_EXTENSION}`),
|
|
32
|
+
['start-dev', ...args],
|
|
33
|
+
{
|
|
34
|
+
env: {
|
|
35
|
+
KEYCLOAK_ADMIN: 'admin',
|
|
36
|
+
KEYCLOAK_ADMIN_PASSWORD: 'admin',
|
|
37
|
+
...process.env
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
child.stdout.pipe(process.stdout)
|
|
43
|
+
child.stderr.pipe(process.stderr)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function downloadServer () {
|
|
47
|
+
const directoryExists = fs.existsSync(SERVER_DIR)
|
|
48
|
+
|
|
49
|
+
if (directoryExists) {
|
|
50
|
+
console.info('Server installation found, skipping download.')
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.info('Downloading and extracting server…')
|
|
55
|
+
|
|
56
|
+
const nightlyAsset = await getNightlyAsset()
|
|
57
|
+
const assetStream = await getAssetAsStream(nightlyAsset)
|
|
58
|
+
|
|
59
|
+
await extractTarball(assetStream, SERVER_DIR, { strip: 1 })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function getNightlyAsset () {
|
|
63
|
+
const api = new Octokit()
|
|
64
|
+
const release = await api.repos.getReleaseByTag({
|
|
65
|
+
owner: 'keycloak',
|
|
66
|
+
repo: 'keycloak',
|
|
67
|
+
tag: 'nightly'
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
return release.data.assets.find(
|
|
71
|
+
({ name }) => name === 'keycloak-999.0.0-SNAPSHOT.tar.gz'
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function getAssetAsStream (asset) {
|
|
76
|
+
const response = await fetch(asset.browser_download_url)
|
|
77
|
+
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new Error('Something went wrong requesting the nightly release.')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return response.body
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function extractTarball (stream, path, options) {
|
|
86
|
+
return pipelineAsync(stream, gunzip(), tar.extract(path, options))
|
|
87
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# For debugging purposes to make sure the image was started
|
|
3
|
+
counter=0
|
|
4
|
+
printf 'Waiting for Keycloak server to start'
|
|
5
|
+
until $(curl --output /dev/null --silent --head --fail http://localhost:8080/realms/master/.well-known/openid-configuration); do
|
|
6
|
+
printf '.'
|
|
7
|
+
sleep 5
|
|
8
|
+
if [[ "$counter" -gt 24 ]]; then
|
|
9
|
+
printf "Keycloak server failed to start. Timeout!"
|
|
10
|
+
exit 1
|
|
11
|
+
fi
|
|
12
|
+
counter=$((counter + 1))
|
|
13
|
+
done
|
|
Binary file
|
|
Binary file
|
package/.editorconfig
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# EditorConfig helps developers define and maintain consistent
|
|
2
|
+
# coding styles between different editors and IDEs
|
|
3
|
+
# editorconfig.org
|
|
4
|
+
|
|
5
|
+
root = true
|
|
6
|
+
|
|
7
|
+
[*]
|
|
8
|
+
|
|
9
|
+
# Change these settings to your own preference
|
|
10
|
+
indent_style = space
|
|
11
|
+
indent_size = 2
|
|
12
|
+
|
|
13
|
+
# We recommend you to keep these unchanged
|
|
14
|
+
end_of_line = lf
|
|
15
|
+
charset = utf-8
|
|
16
|
+
trim_trailing_whitespace = true
|
|
17
|
+
insert_final_newline = true
|
|
18
|
+
|
|
19
|
+
[*.md]
|
|
20
|
+
trim_trailing_whitespace = false
|
|
21
|
+
|
|
22
|
+
[*.ts,*.js]
|
|
23
|
+
quote_type = single
|
package/.eslintignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dist/**
|
package/.eslintrc.cjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* eslint-env node */
|
|
2
|
+
module.exports = {
|
|
3
|
+
env: {
|
|
4
|
+
node: true,
|
|
5
|
+
commonjs: true,
|
|
6
|
+
},
|
|
7
|
+
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
|
8
|
+
parser: '@typescript-eslint/parser',
|
|
9
|
+
plugins: ['@typescript-eslint'],
|
|
10
|
+
root: true,
|
|
11
|
+
rules: {
|
|
12
|
+
quotes: [2, 'single', { avoidEscape: true }],
|
|
13
|
+
},
|
|
14
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
name: Build
|
|
2
|
+
"on":
|
|
3
|
+
- push
|
|
4
|
+
jobs:
|
|
5
|
+
build:
|
|
6
|
+
name: "Build and Test on Node ${{ matrix.node_version }} and ${{ matrix.os }}"
|
|
7
|
+
runs-on: "${{ matrix.os }}"
|
|
8
|
+
strategy:
|
|
9
|
+
matrix:
|
|
10
|
+
node_version:
|
|
11
|
+
- 16
|
|
12
|
+
- 18
|
|
13
|
+
- 20
|
|
14
|
+
os:
|
|
15
|
+
- ubuntu-latest
|
|
16
|
+
- macOS-latest
|
|
17
|
+
- windows-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v3
|
|
20
|
+
- name: "Use Node.js ${{ matrix.node_version }}"
|
|
21
|
+
uses: actions/setup-node@v3
|
|
22
|
+
with:
|
|
23
|
+
node-version: "${{ matrix.node_version }}"
|
|
24
|
+
- name: npm build and test
|
|
25
|
+
run: |
|
|
26
|
+
npm run clean
|
|
27
|
+
npm run build
|
|
28
|
+
npm run test
|
|
29
|
+
|
|
30
|
+
- uses: actions/upload-artifact@v3
|
|
31
|
+
with:
|
|
32
|
+
name: dist-folder
|
|
33
|
+
path: dist
|
|
34
|
+
|
|
35
|
+
end2end:
|
|
36
|
+
name: "End2End Test on Node ${{ matrix.node_version }} and ${{ matrix.os }}"
|
|
37
|
+
runs-on: "${{ matrix.os }}"
|
|
38
|
+
needs:
|
|
39
|
+
- build
|
|
40
|
+
strategy:
|
|
41
|
+
matrix:
|
|
42
|
+
node_version:
|
|
43
|
+
- 16
|
|
44
|
+
- 18
|
|
45
|
+
- 20
|
|
46
|
+
os:
|
|
47
|
+
- ubuntu-latest
|
|
48
|
+
steps:
|
|
49
|
+
- uses: actions/checkout@v3
|
|
50
|
+
- name: "Use Node.js ${{ matrix.node_version }}"
|
|
51
|
+
uses: actions/setup-node@v3
|
|
52
|
+
with:
|
|
53
|
+
node-version: "${{ matrix.node_version }}"
|
|
54
|
+
- name: npm build and test
|
|
55
|
+
run: |
|
|
56
|
+
npm run clean
|
|
57
|
+
npm run build
|
|
58
|
+
|
|
59
|
+
- name: Start Keycloak server
|
|
60
|
+
run: npm run end2end:start-server &
|
|
61
|
+
|
|
62
|
+
- name: Wait for Keycloak server
|
|
63
|
+
run: .bin/wait-for-server.sh
|
|
64
|
+
|
|
65
|
+
- name: Run end2end tests
|
|
66
|
+
run: npm run end2end:test
|
|
67
|
+
env:
|
|
68
|
+
WEBHOOK_TESTING_TEAMS: ${{ secrets.WEBHOOK_TESTING_TEAMS }}
|
|
69
|
+
WEBHOOK_TESTING_SLACK: ${{ secrets.WEBHOOK_TESTING_SLACK }}
|
|
70
|
+
|
|
71
|
+
package:
|
|
72
|
+
name: Build Container Image
|
|
73
|
+
runs-on: ubuntu-latest
|
|
74
|
+
needs:
|
|
75
|
+
- build
|
|
76
|
+
- end2end
|
|
77
|
+
steps:
|
|
78
|
+
- uses: actions/checkout@v3
|
|
79
|
+
- uses: actions/setup-node@v3
|
|
80
|
+
- name: "Build Package"
|
|
81
|
+
run: |
|
|
82
|
+
npm run clean
|
|
83
|
+
npm run build
|
|
84
|
+
- name: Buildah Action
|
|
85
|
+
id: build-image
|
|
86
|
+
uses: redhat-actions/buildah-build@v2
|
|
87
|
+
with:
|
|
88
|
+
image: continuoussecuritytooling/keycloak-reporting-cli
|
|
89
|
+
tags: "v1 ${{ github.sha }}"
|
|
90
|
+
containerfiles: |
|
|
91
|
+
./Dockerfile
|
|
92
|
+
- name: Push To Docker Hub
|
|
93
|
+
id: push-to-dockerhub
|
|
94
|
+
uses: redhat-actions/push-to-registry@v2
|
|
95
|
+
with:
|
|
96
|
+
image: ${{ steps.build-image.outputs.image }}
|
|
97
|
+
tags: ${{ steps.build-image.outputs.tags }}
|
|
98
|
+
registry: registry.hub.docker.com
|
|
99
|
+
username: continuoussecuritytooling
|
|
100
|
+
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
|
101
|
+
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
|
package/Dockerfile
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Continuous Security Tools
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Keycloak Reporter
|
|
2
|
+
|
|
3
|
+
[](https://hub.docker.com/r/continuoussecuritytooling/keycloak-reporting-cli/)
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
docker run continuoussecuritytooling/keycloak-reporting-cli listClients <Keycloak_Root_URL> <Client_ID> <Client_Secret> --format=csv
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The output looks for CSV, like that:
|
|
12
|
+
```
|
|
13
|
+
"client","description","realm","enabled","public","allowedOrigins"
|
|
14
|
+
"account",,"bunge",true,true,"[]"
|
|
15
|
+
"account-console",,"bunge",true,true,"[]"
|
|
16
|
+
"admin-cli",,"bunge",true,true,"[]"
|
|
17
|
+
"broker",,"bunge",true,false,"[]"
|
|
18
|
+
"portal",,"bunge",true,false,"[]"
|
|
19
|
+
"realm-management",,"bunge",true,false,"[]"
|
|
20
|
+
"security-admin-console",,"bunge",true,true,"[""+""]"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Valid commands are:
|
|
24
|
+
- `listClients`
|
|
25
|
+
- `listUsers`
|
|
26
|
+
|
|
27
|
+
## Advanced
|
|
28
|
+
|
|
29
|
+
### Post to Slack or Teams
|
|
30
|
+
|
|
31
|
+
When using this command:
|
|
32
|
+
```
|
|
33
|
+
node dist/index.js listUsers <Keycloak_Root_URL> <Client_ID> <Client_Secret> --format=json --output=webhook --webhookType=slack --webhookUrl=$WEBHOOK_TESTING_SLACK
|
|
34
|
+
```
|
|
35
|
+
the following entry in slack will be created:
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
And for Teams:
|
|
39
|
+
```
|
|
40
|
+
node dist/index.js listUsers <Keycloak_Root_URL> <Client_ID> <Client_Secret> --format=json --output=webhook --webhookType=teams --webhookUrl=$WEBHOOK_TESTING_TEAMS
|
|
41
|
+
```
|
|
42
|
+
the following entry in slack will be created:
|
|
43
|
+

|
package/cli.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import yargs from 'yargs/yargs';
|
|
4
|
+
import { hideBin } from 'yargs/helpers';
|
|
5
|
+
import { listUsers, listClients } from './src/cli.js';
|
|
6
|
+
import { Options } from './lib/client.js';
|
|
7
|
+
import { convertJSON2CSV } from './lib/convert.js';
|
|
8
|
+
import { post2Webhook } from './lib/output.js';
|
|
9
|
+
|
|
10
|
+
class WebhookConfig {
|
|
11
|
+
type: string;
|
|
12
|
+
url: string;
|
|
13
|
+
title: string;
|
|
14
|
+
constructor(type: string, url: string, title: string) {
|
|
15
|
+
this.type = type;
|
|
16
|
+
this.url = url;
|
|
17
|
+
this.title = title;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function convert(
|
|
22
|
+
format: string,
|
|
23
|
+
output: string,
|
|
24
|
+
config: WebhookConfig,
|
|
25
|
+
json: object
|
|
26
|
+
) {
|
|
27
|
+
let outputContent: string;
|
|
28
|
+
switch (format) {
|
|
29
|
+
case 'csv':
|
|
30
|
+
outputContent = (await convertJSON2CSV(json)).toString();
|
|
31
|
+
break;
|
|
32
|
+
// defaulting to JSON
|
|
33
|
+
default:
|
|
34
|
+
outputContent = JSON.stringify(json);
|
|
35
|
+
}
|
|
36
|
+
switch (output) {
|
|
37
|
+
case 'webhook':
|
|
38
|
+
try {
|
|
39
|
+
await post2Webhook(
|
|
40
|
+
config.type,
|
|
41
|
+
config.url,
|
|
42
|
+
config.title,
|
|
43
|
+
outputContent
|
|
44
|
+
);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.error('Error during sending webhook: ', e);
|
|
47
|
+
}
|
|
48
|
+
break;
|
|
49
|
+
// defaulting to standard out
|
|
50
|
+
default:
|
|
51
|
+
console.log(outputContent);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
yargs(hideBin(process.argv))
|
|
56
|
+
.command(
|
|
57
|
+
'listUsers [url] [clientId] [clientSecret]',
|
|
58
|
+
'fetches all users in the realms.',
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
60
|
+
() => {},
|
|
61
|
+
async (argv) => {
|
|
62
|
+
const users = await listUsers(<Options>{
|
|
63
|
+
clientId: argv.clientId as string,
|
|
64
|
+
clientSecret: argv.clientSecret as string,
|
|
65
|
+
rootUrl: argv.url as string,
|
|
66
|
+
});
|
|
67
|
+
await convert(
|
|
68
|
+
argv.format as string,
|
|
69
|
+
argv.output as string,
|
|
70
|
+
new WebhookConfig(
|
|
71
|
+
argv.webhookType as string,
|
|
72
|
+
argv.webhookUrl as string,
|
|
73
|
+
'User Listing'
|
|
74
|
+
),
|
|
75
|
+
users
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
.command(
|
|
80
|
+
'listClients [url] [clientId] [clientSecret]',
|
|
81
|
+
'fetches all clients in the realms.',
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
83
|
+
() => {},
|
|
84
|
+
async (argv) => {
|
|
85
|
+
const clients = await listClients(<Options>{
|
|
86
|
+
clientId: argv.clientId as string,
|
|
87
|
+
clientSecret: argv.clientSecret as string,
|
|
88
|
+
rootUrl: argv.url as string,
|
|
89
|
+
});
|
|
90
|
+
await convert(
|
|
91
|
+
argv.format as string,
|
|
92
|
+
argv.output as string,
|
|
93
|
+
new WebhookConfig(
|
|
94
|
+
argv.webhookType as string,
|
|
95
|
+
argv.webhookUrl as string,
|
|
96
|
+
'Client Listing'
|
|
97
|
+
),
|
|
98
|
+
clients
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
.option('format', {
|
|
103
|
+
alias: 'f',
|
|
104
|
+
type: 'string',
|
|
105
|
+
default: 'json',
|
|
106
|
+
description: 'output format, e.g. JSON|CSV',
|
|
107
|
+
})
|
|
108
|
+
.option('output', {
|
|
109
|
+
alias: 'o',
|
|
110
|
+
type: 'string',
|
|
111
|
+
default: 'stdout',
|
|
112
|
+
description: 'output channel',
|
|
113
|
+
})
|
|
114
|
+
.option('webhookType', {
|
|
115
|
+
alias: 'w',
|
|
116
|
+
type: 'string',
|
|
117
|
+
default: 'slack',
|
|
118
|
+
description: 'Webhook Type',
|
|
119
|
+
})
|
|
120
|
+
.option('webhookUrl', {
|
|
121
|
+
alias: 't',
|
|
122
|
+
type: 'string',
|
|
123
|
+
description: 'Webhook URL',
|
|
124
|
+
})
|
|
125
|
+
.parse();
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import yargs from 'yargs/yargs';
|
|
3
|
+
import { hideBin } from 'yargs/helpers';
|
|
4
|
+
import { listUsers, listClients } from './src/cli.js';
|
|
5
|
+
import { convertJSON2CSV } from './lib/convert.js';
|
|
6
|
+
import { post2Webhook } from './lib/output.js';
|
|
7
|
+
class WebhookConfig {
|
|
8
|
+
constructor(type, url, title) {
|
|
9
|
+
this.type = type;
|
|
10
|
+
this.url = url;
|
|
11
|
+
this.title = title;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
async function convert(format, output, config, json) {
|
|
15
|
+
let outputContent;
|
|
16
|
+
switch (format) {
|
|
17
|
+
case 'csv':
|
|
18
|
+
outputContent = (await convertJSON2CSV(json)).toString();
|
|
19
|
+
break;
|
|
20
|
+
// defaulting to JSON
|
|
21
|
+
default:
|
|
22
|
+
outputContent = JSON.stringify(json);
|
|
23
|
+
}
|
|
24
|
+
switch (output) {
|
|
25
|
+
case 'webhook':
|
|
26
|
+
try {
|
|
27
|
+
await post2Webhook(config.type, config.url, config.title, outputContent);
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
console.error('Error during sending webhook: ', e);
|
|
31
|
+
}
|
|
32
|
+
break;
|
|
33
|
+
// defaulting to standard out
|
|
34
|
+
default:
|
|
35
|
+
console.log(outputContent);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
yargs(hideBin(process.argv))
|
|
39
|
+
.command('listUsers [url] [clientId] [clientSecret]', 'fetches all users in the realms.',
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
41
|
+
() => { }, async (argv) => {
|
|
42
|
+
const users = await listUsers({
|
|
43
|
+
clientId: argv.clientId,
|
|
44
|
+
clientSecret: argv.clientSecret,
|
|
45
|
+
rootUrl: argv.url,
|
|
46
|
+
});
|
|
47
|
+
await convert(argv.format, argv.output, new WebhookConfig(argv.webhookType, argv.webhookUrl, 'User Listing'), users);
|
|
48
|
+
})
|
|
49
|
+
.command('listClients [url] [clientId] [clientSecret]', 'fetches all clients in the realms.',
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
51
|
+
() => { }, async (argv) => {
|
|
52
|
+
const clients = await listClients({
|
|
53
|
+
clientId: argv.clientId,
|
|
54
|
+
clientSecret: argv.clientSecret,
|
|
55
|
+
rootUrl: argv.url,
|
|
56
|
+
});
|
|
57
|
+
await convert(argv.format, argv.output, new WebhookConfig(argv.webhookType, argv.webhookUrl, 'Client Listing'), clients);
|
|
58
|
+
})
|
|
59
|
+
.option('format', {
|
|
60
|
+
alias: 'f',
|
|
61
|
+
type: 'string',
|
|
62
|
+
default: 'json',
|
|
63
|
+
description: 'output format, e.g. JSON|CSV',
|
|
64
|
+
})
|
|
65
|
+
.option('output', {
|
|
66
|
+
alias: 'o',
|
|
67
|
+
type: 'string',
|
|
68
|
+
default: 'stdout',
|
|
69
|
+
description: 'output channel',
|
|
70
|
+
})
|
|
71
|
+
.option('webhookType', {
|
|
72
|
+
alias: 'w',
|
|
73
|
+
type: 'string',
|
|
74
|
+
default: 'slack',
|
|
75
|
+
description: 'Webhook Type',
|
|
76
|
+
})
|
|
77
|
+
.option('webhookUrl', {
|
|
78
|
+
alias: 't',
|
|
79
|
+
type: 'string',
|
|
80
|
+
description: 'Webhook URL',
|
|
81
|
+
})
|
|
82
|
+
.parse();
|
|
83
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../cli.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import yargs from 'yargs/yargs';
|
|
3
|
+
import { hideBin } from 'yargs/helpers';
|
|
4
|
+
import { listUsers, listClients } from './src/cli.js';
|
|
5
|
+
import { convertJSON2CSV } from './lib/convert.js';
|
|
6
|
+
import { post2Webhook } from './lib/output.js';
|
|
7
|
+
class WebhookConfig {
|
|
8
|
+
constructor(type, url, title) {
|
|
9
|
+
this.type = type;
|
|
10
|
+
this.url = url;
|
|
11
|
+
this.title = title;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
async function convert(format, output, config, json) {
|
|
15
|
+
let outputContent;
|
|
16
|
+
switch (format) {
|
|
17
|
+
case 'csv':
|
|
18
|
+
outputContent = (await convertJSON2CSV(json)).toString();
|
|
19
|
+
break;
|
|
20
|
+
// defaulting to JSON
|
|
21
|
+
default:
|
|
22
|
+
outputContent = JSON.stringify(json);
|
|
23
|
+
}
|
|
24
|
+
switch (output) {
|
|
25
|
+
case 'webhook':
|
|
26
|
+
try {
|
|
27
|
+
await post2Webhook(config.type, config.url, config.title, outputContent);
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
console.error('Error during sending webhook: ', e);
|
|
31
|
+
}
|
|
32
|
+
break;
|
|
33
|
+
// defaulting to standard out
|
|
34
|
+
default:
|
|
35
|
+
console.log(outputContent);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
yargs(hideBin(process.argv))
|
|
39
|
+
.command('listUsers [url] [clientId] [clientSecret]', 'fetches all users in the realms.',
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
41
|
+
() => { }, async (argv) => {
|
|
42
|
+
const users = await listUsers({
|
|
43
|
+
clientId: argv.clientId,
|
|
44
|
+
clientSecret: argv.clientSecret,
|
|
45
|
+
rootUrl: argv.url,
|
|
46
|
+
});
|
|
47
|
+
await convert(argv.format, argv.output, new WebhookConfig(argv.webhookType, argv.webhookUrl, 'User Listing'), users);
|
|
48
|
+
})
|
|
49
|
+
.command('listClients [url] [clientId] [clientSecret]', 'fetches all clients in the realms.',
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
51
|
+
() => { }, async (argv) => {
|
|
52
|
+
const clients = await listClients({
|
|
53
|
+
clientId: argv.clientId,
|
|
54
|
+
clientSecret: argv.clientSecret,
|
|
55
|
+
rootUrl: argv.url,
|
|
56
|
+
});
|
|
57
|
+
await convert(argv.format, argv.output, new WebhookConfig(argv.webhookType, argv.webhookUrl, 'Client Listing'), clients);
|
|
58
|
+
})
|
|
59
|
+
.option('format', {
|
|
60
|
+
alias: 'f',
|
|
61
|
+
type: 'string',
|
|
62
|
+
default: 'json',
|
|
63
|
+
description: 'output format, e.g. JSON|CSV',
|
|
64
|
+
})
|
|
65
|
+
.option('output', {
|
|
66
|
+
alias: 'o',
|
|
67
|
+
type: 'string',
|
|
68
|
+
default: 'stdout',
|
|
69
|
+
description: 'output channel',
|
|
70
|
+
})
|
|
71
|
+
.option('webhookType', {
|
|
72
|
+
alias: 'w',
|
|
73
|
+
type: 'string',
|
|
74
|
+
default: 'slack',
|
|
75
|
+
description: 'Webhook Type',
|
|
76
|
+
})
|
|
77
|
+
.option('webhookUrl', {
|
|
78
|
+
alias: 't',
|
|
79
|
+
type: 'string',
|
|
80
|
+
description: 'Webhook URL',
|
|
81
|
+
})
|
|
82
|
+
.parse();
|
|
83
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Issuer } from 'openid-client';
|
|
2
|
+
import KcAdminClient from '@keycloak/keycloak-admin-client';
|
|
3
|
+
// Token refresh interval 60 seconds
|
|
4
|
+
const TOKEN_REFRESH = 60;
|
|
5
|
+
export async function createClient(options) {
|
|
6
|
+
const kcAdminClient = new KcAdminClient({
|
|
7
|
+
baseUrl: options.rootUrl,
|
|
8
|
+
realmName: 'master',
|
|
9
|
+
});
|
|
10
|
+
try {
|
|
11
|
+
// client login
|
|
12
|
+
await kcAdminClient.auth({
|
|
13
|
+
clientId: options.clientId,
|
|
14
|
+
clientSecret: options.clientSecret,
|
|
15
|
+
grantType: 'client_credentials',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
console.error('Check Client Config:', e.response.data.error_description);
|
|
20
|
+
return Promise.reject();
|
|
21
|
+
}
|
|
22
|
+
const keycloakIssuer = await Issuer.discover(`${options.rootUrl}/realms/master`);
|
|
23
|
+
const client = new keycloakIssuer.Client({
|
|
24
|
+
client_id: options.clientId,
|
|
25
|
+
token_endpoint_auth_method: 'none', // to send only client_id in the header
|
|
26
|
+
});
|
|
27
|
+
// Use the grant type 'password'
|
|
28
|
+
const tokenSet = await client.grant({
|
|
29
|
+
client_id: options.clientId,
|
|
30
|
+
client_secret: options.clientSecret,
|
|
31
|
+
grant_type: 'client_credentials',
|
|
32
|
+
});
|
|
33
|
+
/*
|
|
34
|
+
// TODO: FIXME - Periodically using refresh_token grant flow to get new access token here
|
|
35
|
+
setInterval(async () => {
|
|
36
|
+
const refreshToken = tokenSet.refresh_token;
|
|
37
|
+
kcAdminClient.setAccessToken((await client.refresh(refreshToken)).access_token);
|
|
38
|
+
}, TOKEN_REFRESH * 1000); */
|
|
39
|
+
return new Promise((resolve) => resolve(kcAdminClient));
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../../lib/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AACvC,OAAO,aAAa,MAAM,iCAAiC,CAAC;AAE5D,oCAAoC;AACpC,MAAM,aAAa,GAAG,EAAE,CAAC;AAQzB,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,OAAgB;IACjD,MAAM,aAAa,GAAG,IAAI,aAAa,CAAC;QACtC,OAAO,EAAE,OAAO,CAAC,OAAO;QACxB,SAAS,EAAE,QAAQ;KACpB,CAAC,CAAC;IAEH,IAAI;QACF,eAAe;QACf,MAAM,aAAa,CAAC,IAAI,CAAC;YACvB,QAAQ,EAAE,OAAO,CAAC,QAAQ;YAC1B,YAAY,EAAE,OAAO,CAAC,YAAY;YAClC,SAAS,EAAE,oBAAoB;SAChC,CAAC,CAAC;KACJ;IAAC,OAAO,CAAC,EAAE;QACV,OAAO,CAAC,KAAK,CAAC,sBAAsB,EAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;QACxE,OAAO,OAAO,CAAC,MAAM,EAAE,CAAC;KACzB;IAED,MAAM,cAAc,GAAG,MAAM,MAAM,CAAC,QAAQ,CAC1C,GAAG,OAAO,CAAC,OAAO,gBAAgB,CACnC,CAAC;IAEF,MAAM,MAAM,GAAG,IAAI,cAAc,CAAC,MAAM,CAAC;QACvC,SAAS,EAAE,OAAO,CAAC,QAAQ;QAC3B,0BAA0B,EAAE,MAAM,EAAE,uCAAuC;KAC5E,CAAC,CAAC;IAEH,gCAAgC;IAChC,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC;QAClC,SAAS,EAAE,OAAO,CAAC,QAAQ;QAC3B,aAAa,EAAE,OAAO,CAAC,YAAY;QACnC,UAAU,EAAE,oBAAoB;KACjC,CAAC,CAAC;IAEH;;;;;gCAK4B;IAE5B,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC;AAC1D,CAAC"}
|