|
1 | 1 | import { homedir } from 'node:os'; |
2 | | -import * as path from 'node:path'; |
| 2 | +import path from 'node:path'; |
3 | 3 | import { mkdirSync, existsSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; |
4 | | -import * as crypto from 'node:crypto'; |
| 4 | +import crypto from 'node:crypto'; |
5 | 5 | import { Buffer } from 'node:buffer'; |
6 | 6 | import { logger } from '@redocly/openapi-core'; |
7 | | -import { type AuthToken, RedoclyOAuthDeviceFlow } from './device-flow.js'; |
| 7 | +import { type Credentials, RedoclyOAuthDeviceFlow } from './device-flow.js'; |
8 | 8 |
|
9 | 9 | const SALT = '4618dbc9-8aed-4e27-aaf0-225f4603e5a4'; |
10 | 10 | const CRYPTO_ALGORITHM = 'aes-256-cbc'; |
11 | 11 |
|
12 | 12 | export class RedoclyOAuthClient { |
13 | | - private dir: string; |
14 | | - private cipher: crypto.Cipher; |
15 | | - private decipher: crypto.Decipher; |
16 | | - |
17 | | - constructor(private clientName: string, private version: string) { |
18 | | - this.dir = path.join(homedir(), '.redocly'); |
19 | | - if (!existsSync(this.dir)) { |
20 | | - mkdirSync(this.dir); |
21 | | - } |
| 13 | + public static readonly CREDENTIALS_FILE = 'credentials'; |
| 14 | + |
| 15 | + private readonly dir: string; |
| 16 | + private readonly key: Buffer; |
| 17 | + private readonly iv: Buffer; |
| 18 | + |
| 19 | + constructor() { |
| 20 | + const homeDirPath = homedir(); |
22 | 21 |
|
23 | | - const homeDirPath = process.env.HOME as string; |
24 | | - const hash = crypto.createHash('sha256'); |
25 | | - hash.update(`${homeDirPath}${SALT}`); |
26 | | - const hashHex = hash.digest('hex'); |
27 | | - |
28 | | - const key = Buffer.alloc( |
29 | | - 32, |
30 | | - Buffer.from(hashHex).toString('base64') |
31 | | - ).toString() as crypto.CipherKey; |
32 | | - const iv = Buffer.alloc( |
33 | | - 16, |
34 | | - Buffer.from(process.env.HOME as string).toString('base64') |
35 | | - ).toString() as crypto.BinaryLike; |
36 | | - this.cipher = crypto.createCipheriv(CRYPTO_ALGORITHM, key, iv); |
37 | | - this.decipher = crypto.createDecipheriv(CRYPTO_ALGORITHM, key, iv); |
| 22 | + this.dir = path.join(homeDirPath, '.redocly'); |
| 23 | + mkdirSync(this.dir, { recursive: true }); |
| 24 | + |
| 25 | + this.key = crypto.createHash('sha256').update(`${homeDirPath}${SALT}`).digest(); // 32-byte key |
| 26 | + this.iv = crypto.createHash('md5').update(homeDirPath).digest(); // 16-byte IV |
38 | 27 | } |
39 | 28 |
|
40 | | - async login(baseUrl: string) { |
41 | | - const deviceFlow = new RedoclyOAuthDeviceFlow(baseUrl, this.clientName, this.version); |
| 29 | + public async login(baseUrl: string) { |
| 30 | + const deviceFlow = new RedoclyOAuthDeviceFlow(baseUrl); |
42 | 31 |
|
43 | | - const token = await deviceFlow.run(); |
44 | | - if (!token) { |
| 32 | + const credentials = await deviceFlow.run(); |
| 33 | + if (!credentials) { |
45 | 34 | throw new Error('Failed to login'); |
46 | 35 | } |
47 | | - this.saveToken(token); |
| 36 | + this.saveCredentials(credentials); |
48 | 37 | } |
49 | 38 |
|
50 | | - async logout() { |
| 39 | + public async logout() { |
51 | 40 | try { |
52 | | - this.removeToken(); |
| 41 | + this.removeCredentials(); |
53 | 42 | } catch (err) { |
54 | 43 | // do nothing |
55 | 44 | } |
56 | 45 | } |
57 | 46 |
|
58 | | - async isAuthorized(baseUrl: string, apiKey?: string) { |
59 | | - const deviceFlow = new RedoclyOAuthDeviceFlow(baseUrl, this.clientName, this.version); |
60 | | - |
| 47 | + public async isAuthorized(reuniteUrl: string, apiKey?: string): Promise<boolean> { |
61 | 48 | if (apiKey) { |
62 | | - return await deviceFlow.verifyApiKey(apiKey); |
| 49 | + const deviceFlow = new RedoclyOAuthDeviceFlow(reuniteUrl); |
| 50 | + return deviceFlow.verifyApiKey(apiKey); |
63 | 51 | } |
64 | 52 |
|
65 | | - const token = await this.readToken(); |
66 | | - if (!token) { |
67 | | - return false; |
| 53 | + const accessToken = await this.getAccessToken(reuniteUrl); |
| 54 | + |
| 55 | + return Boolean(accessToken); |
| 56 | + } |
| 57 | + |
| 58 | + public getAccessToken = async (reuniteUrl: string): Promise<string | null> => { |
| 59 | + const deviceFlow = new RedoclyOAuthDeviceFlow(reuniteUrl); |
| 60 | + const credentials = await this.readCredentials(); |
| 61 | + |
| 62 | + if (!credentials) { |
| 63 | + return null; |
68 | 64 | } |
69 | 65 |
|
70 | | - const isValidAccessToken = await deviceFlow.verifyToken(token.access_token); |
| 66 | + const isValid = await deviceFlow.verifyToken(credentials.access_token); |
71 | 67 |
|
72 | | - if (isValidAccessToken) { |
73 | | - return true; |
| 68 | + if (isValid) { |
| 69 | + return credentials.access_token; |
74 | 70 | } |
75 | 71 |
|
76 | 72 | try { |
77 | | - const newToken = await deviceFlow.refreshToken(token.refresh_token); |
78 | | - await this.saveToken(newToken); |
| 73 | + const newCredentials = await deviceFlow.refreshToken(credentials.refresh_token); |
| 74 | + await this.saveCredentials(newCredentials); |
| 75 | + |
| 76 | + return newCredentials.access_token; |
79 | 77 | } catch { |
80 | | - return false; |
| 78 | + return null; |
81 | 79 | } |
| 80 | + }; |
82 | 81 |
|
83 | | - return true; |
| 82 | + private get credentialsPath() { |
| 83 | + return path.join(this.dir, RedoclyOAuthClient.CREDENTIALS_FILE); |
84 | 84 | } |
85 | 85 |
|
86 | | - private async saveToken(token: AuthToken) { |
| 86 | + private async saveCredentials(credentials: Credentials): Promise<void> { |
87 | 87 | try { |
88 | | - const encrypted = |
89 | | - this.cipher.update(JSON.stringify(token), 'utf8', 'hex') + this.cipher.final('hex'); |
90 | | - writeFileSync(path.join(this.dir, 'auth.json'), encrypted); |
| 88 | + const encryptedCredentials = this.encryptCredentials(credentials); |
| 89 | + writeFileSync(this.credentialsPath, encryptedCredentials, 'utf8'); |
91 | 90 | } catch (error) { |
92 | | - logger.error(`Error saving tokens: ${error}`); |
| 91 | + logger.error(`Failed to save credentials: ${error.message}`); |
93 | 92 | } |
94 | 93 | } |
95 | 94 |
|
96 | | - private async readToken() { |
| 95 | + private async readCredentials(): Promise<Credentials | null> { |
| 96 | + if (!existsSync(this.credentialsPath)) { |
| 97 | + return null; |
| 98 | + } |
| 99 | + |
97 | 100 | try { |
98 | | - const token = readFileSync(path.join(this.dir, 'auth.json'), 'utf8'); |
99 | | - const decrypted = this.decipher.update(token, 'hex', 'utf8') + this.decipher.final('utf8'); |
100 | | - return decrypted ? JSON.parse(decrypted) : null; |
| 101 | + const encryptedCredentials = readFileSync(this.credentialsPath, 'utf8'); |
| 102 | + return this.decryptCredentials(encryptedCredentials); |
101 | 103 | } catch { |
102 | 104 | return null; |
103 | 105 | } |
104 | 106 | } |
105 | 107 |
|
106 | | - private async removeToken() { |
107 | | - const tokenPath = path.join(this.dir, 'auth.json'); |
108 | | - if (existsSync(tokenPath)) { |
109 | | - rmSync(tokenPath); |
| 108 | + private async removeCredentials(): Promise<void> { |
| 109 | + if (existsSync(this.credentialsPath)) { |
| 110 | + rmSync(this.credentialsPath); |
110 | 111 | } |
111 | 112 | } |
| 113 | + |
| 114 | + private encryptCredentials(credentials: Credentials): string { |
| 115 | + const cipher = crypto.createCipheriv(CRYPTO_ALGORITHM, this.key, this.iv); |
| 116 | + const encrypted = Buffer.concat([ |
| 117 | + cipher.update(JSON.stringify(credentials), 'utf8'), |
| 118 | + cipher.final(), |
| 119 | + ]); |
| 120 | + |
| 121 | + return encrypted.toString('hex'); |
| 122 | + } |
| 123 | + |
| 124 | + private decryptCredentials(encryptedCredentials: string): Credentials { |
| 125 | + const decipher = crypto.createDecipheriv(CRYPTO_ALGORITHM, this.key, this.iv); |
| 126 | + const decrypted = Buffer.concat([ |
| 127 | + decipher.update(Buffer.from(encryptedCredentials, 'hex')), |
| 128 | + decipher.final(), |
| 129 | + ]); |
| 130 | + |
| 131 | + return JSON.parse(decrypted.toString('utf8')); |
| 132 | + } |
112 | 133 | } |
0 commit comments