Skip to content

Commit ad6b119

Browse files
author
Simon Hirscher
committed
Add support for auth tokens
- Introduce new config option `authToken` - Expect either username/password *or* auth token - Carry out necessary modifications of types and function signatures to account for either/or logic - (Still) Prompt for username/password if neither user/pass *nor* auth token is given - Adapt tests - Update README
1 parent f41f7a7 commit ad6b119

File tree

10 files changed

+156
-75
lines changed

10 files changed

+156
-75
lines changed

README.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,10 @@ which produces:
4949

5050
```json
5151
{
52-
"baseUrl": "YOUR_BASE_URL",
53-
"user": "YOUR_USERNAME",
54-
"pass": "YOUR_PASSWORD",
52+
"baseUrl": "<your base URL>",
53+
"user": "<your username>",
54+
"pass": "<your password>",
55+
"authToken": "<your auth token (can be set instead of username/password)>",
5556
"cachePath": "build",
5657
"prefix": "This document is automatically generated. Please don't edit it directly!",
5758
"pages": [
@@ -69,14 +70,22 @@ which produces:
6970
In most scenarios it is not recommended to store your credentials in the configuration file, because you will probably add it to your VCS. Instead it is recommended to provide the following environment variables in your build pipeline (GitLab CI, GitHub Actions, Jenkins, ...):
7071

7172
```ini
72-
CONFLUENCE_USERNAME=YOUR_USERNAME
73-
CONFLUENCE_PASSWORD=YOUR_PASSWORD
73+
CONFLUENCE_USERNAME="<your username>"
74+
CONFLUENCE_PASSWORD="<your password>"
75+
```
76+
77+
or
78+
79+
```ini
80+
CONFLUENCE_AUTH_TOKEN="<your auth token>"
7481
```
7582

7683
or add it in front of the command when executing locally (add a space in front of the command when using bash in order to not write the credentials to the bash history):
7784

7885
```bash
79-
CONFLUENCE_USER=YOUR_USERNAME CONFLUENCE_PASSWORD=YOUR_PASSWORD cosmere
86+
CONFLUENCE_USER="<your username>" CONFLUENCE_PASSWORD="<your password>" cosmere
87+
# or
88+
CONFLUENCE_AUTH_TOKEN="<your auth token>" cosmere
8089
```
8190

8291
### Run

src/ConfigLoader.ts

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,80 @@
11
import * as path from "path";
22
import * as fs from "fs";
3-
import { Config } from "./types/Config";
3+
import { Config, ConfigKey, isBasicAuth, isTokenAuth } from "./types/Config";
44
import * as inquirer from "inquirer";
55
import signale from "signale";
66

7+
8+
function overwriteConfigKeyWithEnvVarIfPresent<T>(config: Partial<Config>, configKey: ConfigKey, envKey: string): Partial<Config> {
9+
if (configKey === "pages") {
10+
throw Error("Cannot override pages using environment variable");
11+
}
12+
13+
if (process.env[envKey] !== undefined) {
14+
return { ...config, [configKey]: process.env[envKey] };
15+
} else {
16+
return config;
17+
}
18+
}
19+
20+
721
export class ConfigLoader {
8-
static async load(configPath: string | null): Promise<Config> {
9-
return await ConfigLoader.promptUserAndPassIfNotSet(
10-
ConfigLoader.overwriteAuthFromConfigWithEnvIfPresent(ConfigLoader.readConfigFromFile(configPath)),
11-
);
22+
static async load(configPath: string | null): Promise<Partial<Config>> {
23+
let config = ConfigLoader.readConfigFromFile(configPath);
24+
config = ConfigLoader.overwriteAuthFromConfigWithEnvIfPresent(config),
25+
config = await ConfigLoader.promptUserAndPassIfNoCredentialsSet(config);
26+
return config;
1227
}
1328

14-
private static readConfigFromFile(configPath: string | null): Config {
29+
private static readConfigFromFile(configPath: string | null): Partial<Config> {
1530
configPath = path.resolve(configPath || path.join("cosmere.json"));
1631
if (!fs.existsSync(configPath!)) {
1732
signale.fatal(`File "${configPath}" not found!`);
1833
process.exit(1);
1934
}
2035

21-
let config = JSON.parse(fs.readFileSync(configPath!, "utf8")) as Config;
22-
for (const i in config.pages) {
23-
config.pages[i].file = path.isAbsolute(config.pages[i].file)
24-
? config.pages[i].file
25-
: path.resolve(path.dirname(configPath) + "/" + config.pages[i].file);
36+
let config: Partial<Config> = JSON.parse(fs.readFileSync(configPath!, "utf8"));
37+
if (config.pages !== undefined) {
38+
for (const i in config.pages) {
39+
config.pages[i].file = path.isAbsolute(config.pages[i].file)
40+
? config.pages[i].file
41+
: path.resolve(path.dirname(configPath) + "/" + config.pages[i].file);
42+
}
2643
}
2744
config.configPath = configPath;
2845
return config;
2946
}
3047

31-
private static overwriteAuthFromConfigWithEnvIfPresent(config: Config): Config {
32-
config.user = process.env.CONFLUENCE_USERNAME || config.user;
33-
config.pass = process.env.CONFLUENCE_PASSWORD || config.pass;
48+
private static overwriteAuthFromConfigWithEnvIfPresent(config: Partial<Config>): Partial<Config> {
49+
config = overwriteConfigKeyWithEnvVarIfPresent(config, "user", "CONFLUENCE_USERNAME");
50+
config = overwriteConfigKeyWithEnvVarIfPresent(config, "pass", "CONFLUENCE_PASSWORD");
51+
config = overwriteConfigKeyWithEnvVarIfPresent(config, "authToken", "CONFLUENCE_AUTH_TOKEN");
3452
return config;
3553
}
3654

37-
private static async promptUserAndPassIfNotSet(config: Config): Promise<Config> {
38-
const prompts = [];
39-
if (!config.user) {
40-
prompts.push({
55+
private static async promptUserAndPassIfNoCredentialsSet(config: Partial<Config>): Promise<Partial<Config>> {
56+
if (isTokenAuth(config)) {
57+
return config;
58+
}
59+
60+
if (!("user" in config)) {
61+
const answers = await inquirer.prompt([{
4162
type: "input",
4263
name: "user",
4364
message: "Your Confluence username:",
44-
});
65+
}]);
66+
(config as any).user = answers.user;
4567
}
4668

47-
if (!config.pass) {
48-
prompts.push({
69+
if (!("pass" in config)) {
70+
const answers = await inquirer.prompt([{
4971
type: "password",
5072
name: "pass",
5173
message: "Your Confluence password:",
52-
});
74+
}]);
75+
(config as any).pass = answers.pass;
5376
}
5477

55-
const answers = await inquirer.prompt(prompts);
56-
config.user = config.user || (answers.user as string);
57-
config.pass = config.pass || (answers.pass as string);
58-
5978
return config;
6079
}
6180
}

src/api/ConfluenceAPI.ts

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,22 @@
1-
import { AuthHeaders } from "../types/AuthHeaders";
2-
import axios from "axios";
1+
import { AuthConfig, isBasicAuth, isTokenAuth } from "../types/Config";
2+
import axios, { AxiosRequestConfig } from "axios";
33
import * as fs from "fs";
44
import signale from "signale";
55

6-
export class ConfluenceAPI {
7-
private readonly authHeaders: AuthHeaders;
8-
private readonly baseUrl: string;
96

10-
constructor(baseUrl: string, username: string, password: string) {
11-
this.baseUrl = baseUrl;
12-
this.authHeaders = {
13-
auth: {
14-
username: username,
15-
password: password,
16-
},
17-
};
18-
}
7+
export class ConfluenceAPI {
8+
constructor(
9+
private readonly baseUrl: string,
10+
private readonly authConfig: AuthConfig
11+
) {}
1912

2013
async updateConfluencePage(pageId: string, newPage: any) {
21-
const config = {
14+
const config = this.appendAuthHeaders({
2215
headers: {
2316
"Content-Type": "application/json",
2417
},
25-
...this.authHeaders,
26-
};
18+
});
19+
2720
try {
2821
await axios.put(`${this.baseUrl}/content/${pageId}`, newPage, config);
2922
} catch (e) {
@@ -33,11 +26,12 @@ export class ConfluenceAPI {
3326
}
3427

3528
async deleteAttachments(pageId: string) {
36-
const attachments = await axios.get(`${this.baseUrl}/content/${pageId}/child/attachment`, this.authHeaders);
29+
const config = this.appendAuthHeaders({});
30+
const attachments = await axios.get(`${this.baseUrl}/content/${pageId}/child/attachment`, config);
3731
for (const attachment of attachments.data.results) {
3832
try {
3933
signale.await(`Deleting attachment "${attachment.title}" ...`);
40-
await axios.delete(`${this.baseUrl}/content/${attachment.id}`, this.authHeaders);
34+
await axios.delete(`${this.baseUrl}/content/${attachment.id}`, config);
4135
} catch (e) {
4236
signale.error(`Deleting attachment "${attachment.title}" failed ...`);
4337
}
@@ -46,7 +40,7 @@ export class ConfluenceAPI {
4640

4741
async uploadAttachment(filename: string, pageId: string) {
4842
try {
49-
await require("axios-file")({
43+
const config = this.appendAuthHeaders({
5044
url: `${this.baseUrl}/content/${pageId}/child/attachment`,
5145
method: "post",
5246
headers: {
@@ -55,14 +49,40 @@ export class ConfluenceAPI {
5549
data: {
5650
file: fs.createReadStream(filename),
5751
},
58-
...this.authHeaders,
5952
});
53+
54+
await require("axios-file")(config);
6055
} catch (e) {
6156
signale.error(`Uploading attachment "${filename}" failed ...`);
6257
}
6358
}
6459

6560
async currentPage(pageId: string) {
66-
return axios.get(`${this.baseUrl}/content/${pageId}?expand=body.storage,version`, this.authHeaders);
61+
const config = this.appendAuthHeaders({});
62+
return axios.get(`${this.baseUrl}/content/${pageId}?expand=body.storage,version`, config);
63+
}
64+
65+
private appendAuthHeaders(config: AxiosRequestConfig): AxiosRequestConfig {
66+
if (isBasicAuth(this.authConfig)) {
67+
return {
68+
...config,
69+
auth: {
70+
username: this.authConfig.user,
71+
password: this.authConfig.pass,
72+
},
73+
};
74+
}
75+
else if (isTokenAuth(this.authConfig)) {
76+
return {
77+
...config,
78+
headers: {
79+
...(config.headers ?? {}),
80+
"Authorization": `Bearer ${this.authConfig.authToken}`,
81+
}
82+
}
83+
}
84+
else {
85+
throw Error("No credentials found in config");
86+
}
6787
}
6888
}

src/cli/GenerateCommand.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ export default function(configPath: string | null) {
55
fs.writeFileSync(
66
configPath || path.join("cosmere.json")!,
77
`{
8-
"baseUrl": "YOUR_BASE_URL",
9-
"user": "YOUR_USERNAME",
10-
"pass": "YOUR_PASSWORD",
8+
"baseUrl": "<your base URL>",
9+
"user": "<your username>",
10+
"pass": "<your password>",
11+
"authToken": "<your auth token (can be set instead of username/password)>",
1112
"cachePath": "build",
1213
"prefix": "This document is automatically generated. Please don't edit it directly!",
1314
"pages": [

src/cli/MainCommand.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { ConfluenceAPI } from "../api/ConfluenceAPI";
44
import { updatePage } from "../UpdatePage";
55

66
export default async function(configPath: string | null, force: boolean = false) {
7-
const config: Config = await ConfigLoader.load(configPath);
8-
const confluenceAPI = new ConfluenceAPI(config.baseUrl, config.user!, config.pass!);
7+
const config: Config = await ConfigLoader.load(configPath) as Config;
8+
const confluenceAPI = new ConfluenceAPI(config.baseUrl, config);
99

1010
for (const pageData of config.pages) {
1111
await updatePage(confluenceAPI, pageData, config, force);

src/types/AuthHeaders.ts

Lines changed: 0 additions & 6 deletions
This file was deleted.

src/types/Config.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,30 @@
11
import { Page } from "./Page";
22

3-
export type Config = {
3+
export type BasicAuthConfig = {
4+
user: string;
5+
pass: string;
6+
};
7+
8+
export type TokenAuthConfig = {
9+
authToken: string;
10+
};
11+
12+
export type AuthConfig = BasicAuthConfig | TokenAuthConfig;
13+
14+
export type Config = AuthConfig & {
415
baseUrl: string;
516
cachePath: string;
6-
user?: string;
7-
pass?: string;
8-
prefix: string;
17+
prefix?: string;
918
pages: Page[];
1019
configPath: string | null;
1120
};
21+
22+
export type ConfigKey = keyof BasicAuthConfig | keyof TokenAuthConfig | keyof Config;
23+
24+
export function isBasicAuth(authConfig: Object): authConfig is BasicAuthConfig {
25+
return authConfig.hasOwnProperty("user");
26+
}
27+
28+
export function isTokenAuth(authConfig: Object): authConfig is TokenAuthConfig {
29+
return authConfig.hasOwnProperty("authToken");
30+
}

tests/UpdatePage.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ describe("UpdatePage", () => {
1010
file: "/dev/null"
1111
};
1212
const config: Config = {
13+
user: "",
14+
pass: "",
1315
baseUrl: "string",
1416
cachePath: "string",
1517
prefix: "string",
1618
pages: [],
1719
configPath: null,
1820
};
19-
expect(updatePage(new ConfluenceAPI("", "", ""), pageData, config, false)).toBeFalsy();
21+
expect(updatePage(new ConfluenceAPI("", config), pageData, config, false)).toBeFalsy();
2022
});
2123
});

tests/api/ConfluenceAPI.test.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ jest.mock('axios');
66
const axiosMock = mocked(axios, true);
77

88
describe("ConfluenceAPI", () => {
9-
it("fetches current version of confluence page", async () => {
9+
it("fetches current version of confluence page using basic auth", async () => {
1010
axiosMock.get.mockResolvedValue({ data: "Test"});
1111

12-
const confluenceAPI = new ConfluenceAPI("", "", "");
12+
const confluenceAPI = new ConfluenceAPI("", { user: "", pass: "" });
1313
await confluenceAPI.currentPage("2");
1414
expect(axiosMock.get).toHaveBeenCalledWith(
1515
"/content/2?expand=body.storage,version",
@@ -21,4 +21,20 @@ describe("ConfluenceAPI", () => {
2121
}
2222
);
2323
});
24+
25+
it("fetches current version of confluence page using auth token", async () => {
26+
axiosMock.get.mockResolvedValue({ data: "Test"});
27+
28+
const authToken = "foo";
29+
const confluenceAPI = new ConfluenceAPI("", { authToken });
30+
await confluenceAPI.currentPage("2");
31+
expect(axiosMock.get).toHaveBeenCalledWith(
32+
"/content/2?expand=body.storage,version",
33+
{
34+
headers: {
35+
Authorization: `Bearer ${authToken}`,
36+
}
37+
}
38+
);
39+
});
2440
});

tests/cli/GenerateCommand.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ describe("GenerateCommand", () => {
77
const path = os.tmpdir() + '/cosmere.json';
88
GenerateCommand(path);
99
expect(fs.readFileSync(path, "utf8")).toBe(`{
10-
"baseUrl": "YOUR_BASE_URL",
11-
"user": "YOUR_USERNAME",
12-
"pass": "YOUR_PASSWORD",
10+
"baseUrl": "<your base URL>",
11+
"user": "<your username>",
12+
"pass": "<your password>",
13+
"authToken": "<your auth token (can be set instead of username/password)>",
1314
"cachePath": "build",
1415
"prefix": "This document is automatically generated. Please don't edit it directly!",
1516
"pages": [

0 commit comments

Comments
 (0)