Skip to content

Commit f332465

Browse files
authored
Weekly updates (#746)
* fix(grpc): repeating flags * fix(parser): do not error on empty request * debug(oauth): add logging to TCP server * fix(oauth): allow Redirect URL `/(.*)?` * feat(oauth): add `Browser cmd` to Auth config opts
1 parent c328aeb commit f332465

13 files changed

Lines changed: 205 additions & 27 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,15 @@ http-client.env.json
1414
!/docs/**/*.json
1515

1616
/http-examples
17+
lua/tree-sitter/testing
18+
1719
*.http
1820
!/docs/**/*.http
1921
!/tests/**/*.http
2022
!/tests/functional/requests/*.http
2123

24+
25+
.luarc.json
2226
selene.toml
2327
neovim.yml
2428
.todo.md

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
### Enhancement: include cookies with GRAPQL schema requests
1212
### Enhancement: add `--sub` option to Kulala CLI to provide variable substitutions
1313
### Enhancement: add `split_params` option for formatter [Configuration](getting-started/configuration-options.mdx)
14+
### Enhancement: improve grammar and syntax highlighting for query and form params and values, multipart form data
15+
### Feature: add Electron browser and `Browser CMD` param to `Auth Config` for Oauth2 auth code flow [Authentication](usage/authentication.md)
1416

1517
## Version 5.3.3
1618

docs/docs/usage/authentication.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,11 +257,15 @@ Authorization: Bearer {{$auth.token("auth-id")}}
257257
Accept: application/json
258258
```
259259

260-
Execute the request. Before accessing the protected resource, Kulala will open your default browser and send a request to the authorization server to obtain an access token.
260+
Execute the request. Before accessing the protected resource, Kulala will open your default browser (or whatever is specified in `Browser CMD`) and send a request to the authorization server to obtain an access token.
261261

262262
When prompted, complete the authentication process. The browser will be redirected to the provided `Redirect URL`.
263263

264-
Kulala will intercept this redirect and extract the authorization details from the URL, if the provided Redirect URL is `localhost` or `127.0.0.1`. Otherwise, you need to manually copy the code from the redirect URL and paste it into Kulala prompt.
264+
If the provided `Redirect URL` is `localhost` or `127.0.0.1` or `Browser CMD` is specified, Kulala will start a HTTP server on `localhost`, listening on port specified in `Redirect URL` (or `80`).
265+
266+
The HTTP server will then intercept this redirect and extract the authorization details from the URL.
267+
268+
Otherwise, you need to copy the code from the redirect URL and paste it into Kulala prompt manually.
265269

266270
For Grant Type `Device Authorization`, the code will be copied into clipboard, to be pasted into consent form.
267271

@@ -504,6 +508,24 @@ The user's password sent as part of authorization, used with the Password grant
504508

505509
Specify custom request parameters
506510

511+
#### Browser CMD
512+
513+
Specify a shell command to open a browser/app to intercept `Redirect URL`. Accepts `Auth URL` and `Redirect URL` as postional arguments. By default opens the system default browser.
514+
515+
##### Example:
516+
517+
In `lua/kulala/browser` you will find a working example of a custom Electron-based browser, that will open `Auth URL`, intercept `Redirect URL` (which does not have to point to localhost) and redirect the callback request to Kulala local HTTP server. To install Electron - `npm install -g electron`.
518+
519+
Add to your Authentication configuration:
520+
521+
```json
522+
{
523+
"Browser CMD": "browser.js http://localhost:8080/callback" (optional),
524+
}
525+
```
526+
527+
The browser script takes 2 arguments: the first is the `Auth URL` (passed by Kulala) and the second is the `Redirect URL` to which the intercepted request will be redirected. Both arguments are passed by Kulala, but if your `Redirect URL` does not point to localhost, you have to specify it manually in `Browser CMD`.
528+
507529
## AWS Signature V4
508530

509531
Amazon Web Services (AWS) Signature version 4 is a

lua/kulala/browser/browser.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env electron
2+
3+
import { app, BrowserWindow } from "electron";
4+
5+
const args = process.argv.slice(2);
6+
7+
// Parse named arguments (--key=value) and positional arguments
8+
const argv = args.reduce((acc, arg, index) => {
9+
const i = arg.indexOf("=");
10+
if (arg.startsWith("--")) {
11+
const key = i == -1 ? arg : arg.slice(0, i);
12+
const value = i == -1 ? true : arg.slice(i + 1);
13+
const cleanKey = key.slice(2);
14+
acc[cleanKey] = value;
15+
} else if (index === 0 && !acc.authorizeUrl) {
16+
acc.authorizeUrl = arg;
17+
} else if (index === 1 && !acc.callbackUrl) {
18+
acc.callbackUrl = arg;
19+
}
20+
return acc;
21+
}, {});
22+
23+
async function createWindow() {
24+
const authorizeUrl = argv.authorizeUrl;
25+
const callbackUrl = argv.callbackUrl || "http://localhost:80";
26+
const resetCookies = argv.resetCookies;
27+
const url = new URL(authorizeUrl);
28+
const redirectUrl = url.searchParams.get("redirect_uri");
29+
30+
const mainWindow = new BrowserWindow({
31+
width: 900,
32+
height: 720,
33+
});
34+
35+
if (resetCookies) {
36+
await mainWindow.webContents.session.clearStorageData({
37+
storages: ["cookies"],
38+
});
39+
}
40+
41+
mainWindow.webContents.on("will-redirect", (event, url) => {
42+
if (url.startsWith(redirectUrl)) {
43+
event.preventDefault();
44+
const interceptedUrl = new URL(url);
45+
const newCallbackUrl = new URL(callbackUrl);
46+
interceptedUrl.searchParams.forEach((value, key) => {
47+
newCallbackUrl.searchParams.append(key, value);
48+
});
49+
mainWindow.loadURL(newCallbackUrl.toString());
50+
}
51+
});
52+
53+
mainWindow.webContents.on("did-finish-load", async () => {
54+
try {
55+
const content = await mainWindow.webContents.executeJavaScript(
56+
"document.body.innerText",
57+
);
58+
if (content && content.includes("Code/Token received")) {
59+
setTimeout(() => {
60+
mainWindow.close();
61+
}, 1000);
62+
}
63+
} catch (error) {
64+
// Ignore errors from reading page content
65+
}
66+
});
67+
68+
mainWindow.loadURL(authorizeUrl);
69+
}
70+
71+
app.whenReady().then(createWindow);
72+
73+
app.on("window-all-closed", () => {
74+
app.quit();
75+
});
76+
77+
app.on("activate", () => {
78+
if (BrowserWindow.getAllWindows().length === 0) {
79+
createWindow();
80+
}
81+
});

lua/kulala/browser/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "module"
3+
}

lua/kulala/cmd/oauth.lua

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ local Crypto = require("kulala.cmd.crypto")
44
local DB = require("kulala.db")
55
local Env = require("kulala.parser.env")
66
local Float = require("kulala.ui.float")
7+
local Fs = require("kulala.utils.fs")
78
local Inlay = require("kulala.inlay")
89
local Json = require("kulala.utils.json")
910
local Logger = require("kulala.logger")
@@ -15,9 +16,12 @@ local M = {}
1516

1617
local request_timeout = 30000 -- 30 seconds
1718
local request_interval = 5000 -- 5 seconds
19+
local tcp_server
1820
local co, exit
1921

2022
local function get_curl_flags()
23+
if not DB.current_request then return {} end
24+
2125
local RequestParser = require("kulala.parser.request")
2226
local request = vim.deepcopy(DB.current_request)
2327

@@ -322,7 +326,7 @@ M.receive_code = function(config_id)
322326
local config = get_auth_config(config_id)
323327
local url = config["Redirect URL"]
324328

325-
if not url:find("localhost") and not url:find("127.0.0.1") then
329+
if not (url:find("localhost") or url:find("127.0.0.1") or config["Browser CMD"]) then
326330
local code = vim.uri_decode(vim.fn.input("Enter the Auth code/token: "))
327331

328332
update_auth_data(config_id, {
@@ -335,7 +339,7 @@ M.receive_code = function(config_id)
335339

336340
local port = url:match(":(%d+)") or 80
337341

338-
local server = Tcp.server("127.0.0.1", port, function(request)
342+
tcp_server = Tcp.server("127.0.0.1", port, function(request)
339343
local params = parse_params(request) or {}
340344

341345
if params.code or params.access_token then
@@ -350,12 +354,15 @@ M.receive_code = function(config_id)
350354
end
351355
end)
352356

353-
if not server then return end
357+
if not tcp_server then return end
354358

355359
Logger.info("Waiting for authorization code/token")
356360

357361
local _, result = Async.co_yield(co, request_timeout)
358-
if not result then return Logger.error("Timeout waiting for authorization code/token for: " .. config_id) end
362+
if not result or result == "timeout" then
363+
_ = tcp_server and tcp_server:stop()
364+
return Logger.error("Timeout waiting for authorization code/token for: " .. config_id)
365+
end
359366

360367
return result
361368
end
@@ -438,6 +445,30 @@ M.acquire_client_credentials = function(config_id)
438445
return config.auth_data.access_token
439446
end
440447

448+
local function launch_browser(cmd, auth_url, redirect_url)
449+
local status, error
450+
local browser_cmd = {}
451+
452+
cmd = cmd or ""
453+
454+
if cmd == "" then
455+
browser_cmd = { "system default browser" }
456+
status, error = vim.ui.open(auth_url)
457+
else
458+
cmd = vim.split(cmd, " ")
459+
browser_cmd = { Fs.get_file_path(cmd[1]), auth_url, redirect_url or "http://localhost:80" }
460+
461+
Logger.info("Launching browser with command: " .. vim.inspect(browser_cmd))
462+
status, error = Shell.run(browser_cmd, { err_msg = "Error launching browser" })
463+
end
464+
465+
if not status then
466+
return Logger.error("Failed to open browser: " .. vim.inspect(browser_cmd) .. " " .. (error or ""))
467+
end
468+
469+
return true
470+
end
471+
441472
---Grant Type "Authorization Code" or "Implicit"
442473
---Acquire an auth code for the given config_id
443474
M.acquire_auth = function(config_id)
@@ -465,8 +496,7 @@ M.acquire_auth = function(config_id)
465496
Logger.info("Acquiring code for config: " .. config_id)
466497
Logger.debug("Auth URL: " .. uri)
467498

468-
local browser, status = vim.ui.open(uri)
469-
if not browser then return Logger.error("Failed to open browser: " .. status) end
499+
if not launch_browser(config["Browser CMD"], uri, config["Redirect URL"]) then return end
470500

471501
local code = M.receive_code(config_id)
472502
if not code then return Logger.error("Failed to acquire code for config: " .. config_id) end
@@ -607,6 +637,7 @@ local function run_auth_async(config_id, fn)
607637
Logger.info("Cancelling token acquisition for config: " .. config_id)
608638

609639
Async.co_resume(co)
640+
tcp_server = tcp_server and tcp_server:stop()
610641
exit = true
611642

612643
vim.keymap.del("n", "<C-c>", { buffer = buf })

lua/kulala/cmd/tcp.lua

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,44 +30,52 @@ M.server = {
3030
port = tonumber(port) or 80
3131

3232
local server = vim.uv.new_tcp() or {}
33-
local status = server:bind(host, port)
34-
if not status then Logger.warn("Failed to start TCP server: on " .. host .. ":" .. port) end
33+
local status, err = server:bind(host, port)
3534

36-
status = server:listen(128, function(err)
37-
if err then return Logger.error("Failed to process request: " .. err) end
35+
if not status then
36+
return Logger.warn(("TCP server: failed to bind on %s:%s (%s)"):format(host, port, err or ""))
37+
end
38+
39+
status, err = server:listen(128, function(err)
40+
if err then return Logger.error("TCP server: failed to accept connection: " .. err) end
3841

3942
local client = vim.uv.new_tcp() or {}
4043
server:accept(client)
4144

4245
client:read_start(function(err, chunk)
43-
if err then return Logger.error("Failed to read server response: " .. err) end
44-
---@diagnostic disable-next-line: redundant-return-value
46+
if err then return Logger.error("TCP server: failed to process request: " .. err) end
4547
if not chunk then return self:stop(client) end
4648

4749
local response = ""
4850
local result
4951

5052
if chunk:match("GET / HTTP") then
5153
response = redirect_script()
52-
elseif chunk:match("GET /%?") then
53-
result = on_request(chunk:match("GET /%?(.+) HTTP"))
54+
elseif chunk:match("GET /[^%?]*%?(.+)") then
55+
result = on_request(chunk:match("GET /[^%?]*%?(.+) HTTP"))
5456
response = result or "OK"
5557
end
5658

57-
client:write("HTTP/1.1 200 OKn\r\n\r\n" .. response .. "\n")
59+
client:write("HTTP/1.1 200 OK\r\n\r\n" .. response .. "\n")
5860
self:stop(client)
5961

6062
if result then self:stop(server) end
6163
end)
6264
end)
6365

64-
if not status then return Logger.error("Failed to start TCP server on " .. host .. ":" .. port) end
66+
if not status then
67+
return Logger.warn(("TCP server failed to listen on %s:%s (%s)"):format(host, port, err or ""))
68+
end
6569

66-
local socket = server:getsockname() or {}
67-
Logger.info("Server listening for code/token on " .. host .. ":" .. socket.port)
70+
local socket = server:getsockname()
71+
if socket then
72+
Logger.info("Server listening for code/token on " .. socket.ip .. ":" .. socket.port)
73+
else
74+
return Logger.warn(("TCP server failed to get socket on %s:%s"):format(host, port))
75+
end
6876

69-
vim.uv.run()
7077
self.server = server
78+
-- vim.uv.run() -- not needed, libuv loop is already running in Neovim
7179

7280
return self
7381
end,

lua/kulala/db/init.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,13 @@ M.current_request = nil
6666
---@return number
6767
M.get_current_buffer = function()
6868
local buf = M.current_buffer
69-
return vim.fn.bufexists(buf) > 0 and buf or M.set_current_buffer()
69+
return vim.api.nvim_buf_is_valid(buf or -1) and buf or M.set_current_buffer()
7070
end
7171

7272
---Sets DB.current_buffer to provided buffer_id or to current buffer
7373
---@param id number|nil
7474
M.set_current_buffer = function(id)
75-
M.current_buffer = id and id or vim.fn.bufnr()
75+
M.current_buffer = id and id or vim.api.nvim_get_current_buf()
7676
return M.current_buffer
7777
end
7878

lua/kulala/logger/bug_report.lua

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
local Config = require("kulala.config")
2+
local Db = require("kulala.db")
23
local Float = require("kulala.ui.float")
34
local Health = require("kulala.health")
45
local Json = require("kulala.utils.json")
@@ -23,6 +24,10 @@ local template = [[
2324
2425
*Steps to reproduce the behavior*
2526
27+
## Request
28+
29+
%s
30+
2631
## Error
2732
2833
```lua
@@ -95,13 +100,25 @@ M.create_issue = function(title, body, labels, type)
95100
return true
96101
end
97102

103+
local function get_current_request()
104+
local request, buf = Db.current_request, Db.current_buffer
105+
if not request or not buf then return end
106+
107+
local status, result = pcall(function()
108+
return vim.fn.join(vim.api.nvim_buf_get_lines(buf, request.start_line - 2, request.end_line, false), "\n")
109+
end)
110+
111+
return status and "```http\n" .. result .. "\n```" or ""
112+
end
113+
98114
M.generate_bug_report = function(error)
99115
error = error or ""
100116

101117
local title = vim.split(error, "\n")[1]
118+
local request = get_current_request()
102119
local health = get_health()
103120
local user_config = vim.inspect(Config.user_config)
104-
local report = vim.split(template:format(title, error, health, user_config), "\n")
121+
local report = vim.split(template:format(title, request, error, health, user_config), "\n")
105122

106123
local width = math.floor(vim.o.columns * 0.6)
107124
local height = math.floor(vim.o.lines * 0.6)

lua/kulala/parser/document.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,8 @@ M.get_request_at = function(requests, linenr)
632632
if linenr == 0 then return expand_nested_requests(requests) end
633633

634634
local request = requests[1]
635+
if not request then return {} end
636+
635637
local shared = request.shared
636638
if not request.name:match("^Shared") and is_runnable(shared) then table.insert(requests, 1, shared) end
637639

0 commit comments

Comments
 (0)