codekingpro/portable-devtools
114k
1const { URL } = require('node:url')
2const timers = require('node:timers/promises')
3const fetch = require('npm-registry-fetch')
4const { HttpErrorBase } = require('npm-registry-fetch/lib/errors')
5const { log } = require('proc-log')
6
7// try loginWeb, catch the "not supported" message and fall back to couch
8const login = async (opener, prompter, opts = {}) => {
9 try {
10 return await loginWeb(opener, opts)
11 } catch (er) {
12 if (er instanceof WebLoginNotSupported) {
13 log.verbose('web login', 'not supported, trying couch')
14 const { username, password } = await prompter(opts.creds)
15 return loginCouch(username, password, opts)
16 }
17 throw er
18 }
19}
20
21const adduser = async (opener, prompter, opts = {}) => {
22 try {
23 return await adduserWeb(opener, opts)
24 } catch (er) {
25 if (er instanceof WebLoginNotSupported) {
26 log.verbose('web adduser', 'not supported, trying couch')
27 const { username, email, password } = await prompter(opts.creds)
28 return adduserCouch(username, email, password, opts)
29 }
30 throw er
31 }
32}
33
34const adduserWeb = (opener, opts = {}) => {
35 log.verbose('web adduser', 'before first POST')
36 return webAuth(opener, opts, { create: true })
37}
38
39const loginWeb = (opener, opts = {}) => {
40 log.verbose('web login', 'before first POST')
41 return webAuth(opener, opts, {})
42}
43
44const isValidUrl = u => {
45 try {
46 return /^https?:$/.test(new URL(u).protocol)
47 } catch {
48 return false
49 }
50}
51
52const webAuth = async (opener, opts, body) => {
53 try {
54 const res = await fetch('/-/v1/login', {
55 ...opts,
56 method: 'POST',
57 body,
58 })
59
60 const content = await res.json()
61 log.verbose('web auth', 'got response', content)
62
63 const { doneUrl, loginUrl } = content
64 if (!isValidUrl(doneUrl) || !isValidUrl(loginUrl)) {
65 throw new WebLoginInvalidResponse('POST', res, content)
66 }
67
68 return await webAuthOpener(opener, loginUrl, doneUrl, opts)
69 } catch (er) {
70 if ((er.statusCode >= 400 && er.statusCode <= 499) || er.statusCode === 500) {
71 throw new WebLoginNotSupported('POST', {
72 status: er.statusCode,
73 headers: er.headers,
74 }, er.body)
75 }
76 throw er
77 }
78}
79
80const webAuthOpener = async (opener, loginUrl, doneUrl, opts) => {
81 const abortController = new AbortController()
82 const { signal } = abortController
83 try {
84 log.verbose('web auth', 'opening url pair')
85 const [, authResult] = await Promise.all([
86 opener(loginUrl, { signal }).catch((err) => {
87 if (err.name === 'AbortError') {
88 abortController.abort()
89 return
90 }
91 throw err
92 }),
93 webAuthCheckLogin(doneUrl, { ...opts, cache: false }, { signal }).then((r) => {
94 log.verbose('web auth', 'done-check finished')
95 abortController.abort()
96 return r
97 }),
98 ])
99 return authResult
100 } catch (er) {
101 abortController.abort()
102 throw er
103 }
104}
105
106const webAuthCheckLogin = async (doneUrl, opts, { signal } = {}) => {
107 signal?.throwIfAborted()
108
109 const res = await fetch(doneUrl, opts)
110 const content = await res.json()
111
112 if (res.status === 200) {
113 if (!content.token) {
114 throw new WebLoginInvalidResponse('GET', res, content)
115 }
116 return content
117 }
118
119 if (res.status === 202) {
120 const retry = +res.headers.get('retry-after') * 1000
121 if (retry > 0) {
122 await timers.setTimeout(retry, null, { signal })
123 }
124 return webAuthCheckLogin(doneUrl, opts, { signal })
125 }
126
127 throw new WebLoginInvalidResponse('GET', res, content)
128}
129
130const couchEndpoint = (username) => `/-/user/org.couchdb.user:${encodeURIComponent(username)}`
131
132const putCouch = async (path, username, body, opts) => {
133 const result = await fetch.json(`${couchEndpoint(username)}${path}`, {
134 ...opts,
135 method: 'PUT',
136 body,
137 })
138 result.username = username
139 return result
140}
141
142const adduserCouch = async (username, email, password, opts = {}) => {
143 const body = {
144 _id: `org.couchdb.user:${username}`,
145 name: username,
146 password: password,
147 email: email,
148 type: 'user',
149 roles: [],
150 date: new Date().toISOString(),
151 }
152
153 log.verbose('adduser', 'before first PUT', {
154 ...body,
155 password: 'XXXXX',
156 })
157
158 return putCouch('', username, body, opts)
159}
160
161const loginCouch = async (username, password, opts = {}) => {
162 const body = {
163 _id: `org.couchdb.user:${username}`,
164 name: username,
165 password: password,
166 type: 'user',
167 roles: [],
168 date: new Date().toISOString(),
169 }
170
171 log.verbose('login', 'before first PUT', {
172 ...body,
173 password: 'XXXXX',
174 })
175
176 try {
177 return await putCouch('', username, body, opts)
178 } catch (err) {
179 if (err.code === 'E400') {
180 err.message = `There is no user with the username "${username}".`
181 throw err
182 }
183
184 if (err.code !== 'E409') {
185 throw err
186 }
187 }
188
189 const result = await fetch.json(couchEndpoint(username), {
190 ...opts,
191 query: { write: true },
192 })
193
194 for (const k of Object.keys(result)) {
195 if (!body[k] || k === 'roles') {
196 body[k] = result[k]
197 }
198 }
199
200 return putCouch(`/-rev/${body._rev}`, username, body, {
201 ...opts,
202 forceAuth: {
203 username,
204 password: Buffer.from(password, 'utf8').toString('base64'),
205 otp: opts.otp,
206 },
207 })
208}
209
210const get = (opts = {}) => fetch.json('/-/npm/v1/user', opts)
211
212const set = (profile, opts = {}) => fetch.json('/-/npm/v1/user', {
213 ...opts,
214 method: 'POST',
215 // profile keys can't be empty strings, but they CAN be null
216 body: Object.fromEntries(Object.entries(profile).map(([k, v]) => [k, v === '' ? null : v])),
217})
218
219const paginate = async (href, opts, items = []) => {
220 const result = await fetch.json(href, opts)
221 items = items.concat(result.objects)
222 if (result.urls.next) {
223 return paginate(result.urls.next, opts, items)
224 }
225 return items
226}
227
228const listTokens = (opts = {}) => paginate('/-/npm/v1/tokens', opts)
229
230const removeToken = async (tokenKey, opts = {}) => {
231 await fetch(`/-/npm/v1/tokens/token/${tokenKey}`, {
232 ...opts,
233 method: 'DELETE',
234 ignoreBody: true,
235 })
236 return null
237}
238
239const createToken = (password, readonly, cidrs, opts = {}) => fetch.json('/-/npm/v1/tokens', {
240 ...opts,
241 method: 'POST',
242 body: {
243 password: password,
244 readonly: readonly,
245 cidr_whitelist: cidrs,
246 },
247})
248
249class WebLoginInvalidResponse extends HttpErrorBase {
250 constructor (method, res, body) {
251 super(method, res, body)
252 this.message = 'Invalid response from web login endpoint'
253 }
254}
255
256class WebLoginNotSupported extends HttpErrorBase {
257 constructor (method, res, body) {
258 super(method, res, body)
259 this.message = 'Web login not supported'
260 this.code = 'ENYI'
261 }
262}
263
264module.exports = {
265 adduserCouch,
266 loginCouch,
267 adduserWeb,
268 loginWeb,
269 login,
270 adduser,
271 get,
272 set,
273 listTokens,
274 removeToken,
275 createToken,
276 webAuthCheckLogin,
277 webAuthOpener,
278}
279 