-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathTokenStore.ts
More file actions
142 lines (126 loc) · 5.48 KB
/
TokenStore.ts
File metadata and controls
142 lines (126 loc) · 5.48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import type { TokenResponseJson } from "@openid/appauth";
import OperatingSystemUserName from "username";
import { TokenResponse } from "@openid/appauth";
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
import Conf from "conf";
type CacheEntry = TokenResponseJson & {scopesForCacheValidation?: string};
interface ConfEntry {
encryptedCache: string;
iv: string;
}
/**
* Utility to store OIDC AppAuth in secure storage
* @internal
*/
export class TokenStore {
private readonly _appStorageKey: string;
private readonly _scopes: string;
private readonly _store: Conf;
public constructor(namedArgs: {clientId: string, issuerUrl: string, scopes: string}, dir?: string) {
// A stored credential is only valid for a combination of the clientId, the issuing authority and the requested scopes.
// We make the storage key a combination of clientId and issuing authority so that keys can stay cached when switching
// between PROD and QA environments.
// We store the scopes in our password blob so we know if a new token is required due to updated scopes.
const configFileName = `iTwinJs_${namedArgs.clientId}`;
this._appStorageKey = `${configFileName}_${namedArgs.issuerUrl}`
.replace(/[.]/g, "%2E") // Replace all '.' with URL Percent-encoding representation
.replace(/[\/]/g, "%2F"); // Replace all '/' with URL Percent-encoding representation;
this._scopes = namedArgs.scopes;
let confConfig: object = {
configName: configFileName, // specifies storage file name.
encryptionKey: "iTwin", // obfuscates the storage file's content, in case a user finds the file and wants to modify it.
};
if (dir) {
confConfig = {
...confConfig,
cwd: dir, // specifies the directory that contains the storage file.
};
} else {
confConfig = {
...confConfig,
projectName: configFileName, // specifies the directory that contains the storage file.
};
}
this._store = new Conf(confConfig);
}
private _userName?: string;
private async getUserName(): Promise<string | undefined> {
if (!this._userName)
this._userName = await OperatingSystemUserName();
return this._userName;
}
private async getKey(): Promise<string> {
const userName = await this.getUserName();
return `${this._appStorageKey}${userName}`;
}
/**
* Generate a viable cipher key from a password based derivation function (scrypt).
* @returns
*/
private generateCipherKey(): Buffer {
return scryptSync(this._appStorageKey, "iTwin", 32); // aes-256-cbc requires a key length of 32 bytes.
}
/**
* Uses node's native `crypto` module to encrypt the given cache entry.
* @returns an object containing a hexadecimal encoded token, returned as a string, as well as the initialization vector.
*/
private encryptCache(cacheEntry: CacheEntry): ConfEntry {
const iv = randomBytes(16);
const cipher = createCipheriv("aes-256-cbc", this.generateCipherKey(), iv);
const encryptedCache = cipher.update(JSON.stringify(cacheEntry), "utf8", "hex") + cipher.final("hex");
return {
encryptedCache,
iv: iv.toString("hex"),
};
}
private decryptCache(encryptedCache: string, iv: Buffer): string {
const decipher = createDecipheriv("aes-256-cbc", this.generateCipherKey(), iv);
const decryptedCache = decipher.update(encryptedCache, "hex", "utf8") + decipher.final("utf8");
return decryptedCache;
}
public async load(): Promise<TokenResponse | undefined> {
if (process.platform === "linux")
return undefined;
const userName = await this.getUserName();
if (!userName)
return undefined;
const key = await this.getKey();
if (!this._store.has(key)) {
return undefined;
}
const storedObj = this._store.get(key) as ConfEntry;
const encryptedCache = storedObj.encryptedCache;
const iv = storedObj.iv;
const cacheEntry = this.decryptCache(encryptedCache, Buffer.from(iv, "hex"));
// Only reuse token if matching scopes. Don't include cache data for TokenResponse object.
const tokenResponseObj = JSON.parse(cacheEntry) as CacheEntry;
if (tokenResponseObj?.scopesForCacheValidation !== this._scopes) {
this._store.delete(key);
return undefined;
}
delete tokenResponseObj.scopesForCacheValidation;
return new TokenResponse(tokenResponseObj);
}
public async save(tokenResponse: TokenResponse): Promise<void> {
if (process.platform === "linux")
return undefined;
const userName = await this.getUserName();
if (!userName)
return;
const tokenResponseObj = new TokenResponse(tokenResponse.toJson()); // Workaround for 'stub received bad data' error on windows - see https://github.com/atom/node-keytar/issues/112
tokenResponseObj.accessToken = "";
tokenResponseObj.idToken = "";
// TokenResponse.scope is always empty in my testing, so manually add to object instead
const cacheEntry = {
scopesForCacheValidation: this._scopes,
...tokenResponseObj.toJson(),
};
const objToStore = this.encryptCache(cacheEntry);
const key = await this.getKey();
this._store.set(key, objToStore);
}
}