summaryrefslogtreecommitdiffhomepage
path: root/lib/dispatch/adapter/claude/oauth/callback_server.rb
blob: bdd24bdb59e8a3fcc365d8ac07c0584ac9ac684a (plain)
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
# frozen_string_literal: true

require "webrick"
require "uri"

module Dispatch
  module Adapter
    class Claude < Base
      module OAuth
        class CallbackServer
          SUCCESS_HTML = <<~HTML
            <!DOCTYPE html>
            <html>
            <head><title>Authentication Successful</title></head>
            <body>
              <h1>Authentication Successful</h1>
              <p>You can close this tab and return to your terminal.</p>
            </body>
            </html>
          HTML

          def initialize(port: 54_545, timeout: 300)
            @port = port
            @timeout = timeout
            @queue = Queue.new
            @server = nil
            @thread = nil
          end

          def callback_url
            "http://localhost:#{@port}/callback"
          end

          def start
            logger = WEBrick::Log.new(File::NULL, WEBrick::Log::FATAL)
            @server = WEBrick::HTTPServer.new(
              BindAddress: "127.0.0.1",
              Port: @port,
              Logger: logger,
              AccessLog: []
            )

            @server.mount_proc("/callback") do |req, res|
              if req.request_method == "GET"
                code  = req.query["code"]
                state = req.query["state"]

                if code
                  res.status = 200
                  res.content_type = "text/html; charset=utf-8"
                  res.body = SUCCESS_HTML
                  @queue << [code, state]
                  shutdown_async
                else
                  res.status = 400
                  res.body = "Missing code parameter"
                end
              else
                res.status = 405
                res.body = "Method Not Allowed"
              end
            end

            @server.mount_proc("/") do |_req, res|
              res.status = 404
              res.body = "Not Found"
            end

            @thread = Thread.new do
              @server.start
            rescue StandardError
              # Server was shut down
            end

            # Start a timeout watchdog
            Thread.new do
              sleep(@timeout)
              @queue << AuthenticationError.new(
                "OAuth callback timed out after #{@timeout}s — no browser response received.",
                provider: ClaudeErrors::PROVIDER
              )
              shutdown_async
            end
          end

          def await_code
            result = @queue.pop
            raise result if result.is_a?(Exception)

            result
          end

          def stop
            shutdown_async
            @thread&.join(5)
          rescue StandardError
            # Ignore errors during shutdown
          end

          private

          def shutdown_async
            return unless @server

            server = @server
            @server = nil
            Thread.new { server.shutdown rescue nil } # rubocop:disable Style/RescueModifier
          end
        end
      end
    end
  end
end