Module: GemContribute::Auth
- Defined in:
- lib/gem_contribute/auth.rb
Overview
OAuth 2.0 Device Authorization Grant against github.com. See ADR-0004.
Pure state-machine design:
request_device_code(client_id) → DeviceCode | raises AuthError
poll(device_code, client_id) → Result (status: :ok | :pending |
:slow_down | :expired |
:denied | :error)
The CLI orchestrates these with sleep-based polling. The future Stage 3 TUI wraps the same functions in Rooibos Command.http / Command.wait without changing the protocol. ADR-0008 stays clean because the state-transition functions don’t own any I/O orchestration themselves —they’re pure request/response.
Defined Under Namespace
Classes: AuthError, DeviceCode, Result
Constant Summary collapse
- DEVICE_CODE_URL =
"https://github.com/login/device/code"- TOKEN_URL =
"https://github.com/login/oauth/access_token"- DEFAULT_SCOPE =
"public_repo"- CLIENT_ID =
OAuth App Client ID. Public by design — see ADR-0004 and MAINTAINER.md. The sentinel below is intentionally unusable; replace with the real value after walking through MAINTAINER.md’s OAuth App registration.
ENV.fetch("GEM_CONTRIBUTE_CLIENT_ID", "Ov23liZNcwIo17OIVUsv")
Class Method Summary collapse
- .build_device_code(body, clock:) ⇒ Object
-
.build_result(response) ⇒ Object
Caller convention: device-flow errors are protocol states, not exceptions.
- .check_client_id!(client_id) ⇒ Object
- .classify_body(body) ⇒ Object
-
.poll(device_code, client_id, http: Net::HTTP) ⇒ Result
Step 2: one polling step.
- .post_form(url, params, http:) ⇒ Object
-
.request_device_code(client_id, scope: DEFAULT_SCOPE, http: Net::HTTP, clock: -> { Time.now }) ⇒ DeviceCode
Step 1: request a device code.
Class Method Details
.build_device_code(body, clock:) ⇒ Object
140 141 142 143 144 145 146 147 148 |
# File 'lib/gem_contribute/auth.rb', line 140 def build_device_code(body, clock:) DeviceCode.new( device_code: body.fetch("device_code"), user_code: body.fetch("user_code"), verification_uri: body.fetch("verification_uri"), expires_at: clock.call + body.fetch("expires_in"), interval: body.fetch("interval") ) end |
.build_result(response) ⇒ Object
Caller convention: device-flow errors are protocol states, not exceptions. Network / parse errors raise. Returning a Result keeps the state machine pure.
102 103 104 105 106 107 108 109 110 |
# File 'lib/gem_contribute/auth.rb', line 102 def build_result(response) unless response.is_a?(Net::HTTPSuccess) return Result.new(status: :error, token: nil, scope: nil, error_message: "HTTP #{response.code}") end body = JSON.parse(response.body) classify_body(body) end |
.check_client_id!(client_id) ⇒ Object
131 132 133 134 135 136 137 138 |
# File 'lib/gem_contribute/auth.rb', line 131 def check_client_id!(client_id) return unless client_id.nil? || client_id.empty? || client_id == "FILL_ME_IN_FROM_MAINTAINER_MD" raise AuthError, "GemContribute::Auth::CLIENT_ID is not set. Walk through MAINTAINER.md to register " \ "an OAuth App, then paste the Client ID into lib/gem_contribute/auth.rb (or set " \ "GEM_CONTRIBUTE_CLIENT_ID in the environment for testing)." end |
.classify_body(body) ⇒ Object
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/gem_contribute/auth.rb', line 112 def classify_body(body) if body["access_token"] Result.new(status: :ok, token: body["access_token"], scope: body["scope"], error_message: nil) else case body["error"] when "authorization_pending" Result.new(status: :pending, token: nil, scope: nil, error_message: nil) when "slow_down" Result.new(status: :slow_down, token: nil, scope: nil, error_message: nil) when "expired_token" Result.new(status: :expired, token: nil, scope: nil, error_message: nil) when "access_denied" Result.new(status: :denied, token: nil, scope: nil, error_message: nil) else Result.new(status: :error, token: nil, scope: nil, error_message: body["error"] || "unknown") end end end |
.poll(device_code, client_id, http: Net::HTTP) ⇒ Result
Step 2: one polling step. Call repeatedly until status != :pending and != :slow_down.
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
# File 'lib/gem_contribute/auth.rb', line 83 def poll(device_code, client_id, http: Net::HTTP) check_client_id!(client_id) response = post_form( TOKEN_URL, { client_id: client_id, device_code: device_code.device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code" }, http: http ) build_result(response) end |
.post_form(url, params, http:) ⇒ Object
150 151 152 153 154 155 156 157 158 159 |
# File 'lib/gem_contribute/auth.rb', line 150 def post_form(url, params, http:) uri = URI(url) http.start(uri.host, uri.port, use_ssl: true) do |conn| request = Net::HTTP::Post.new(uri.request_uri) request["Accept"] = "application/json" request["User-Agent"] = "gem-contribute/#{GemContribute::VERSION}" request.set_form_data(params) conn.request(request) end end |
.request_device_code(client_id, scope: DEFAULT_SCOPE, http: Net::HTTP, clock: -> { Time.now }) ⇒ DeviceCode
Step 1: request a device code.
67 68 69 70 71 72 73 74 75 76 77 |
# File 'lib/gem_contribute/auth.rb', line 67 def request_device_code(client_id, scope: DEFAULT_SCOPE, http: Net::HTTP, clock: -> { Time.now }) check_client_id!(client_id) response = post_form(DEVICE_CODE_URL, { client_id: client_id, scope: scope }, http: http) raise AuthError, "device code request failed: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess) body = JSON.parse(response.body) raise AuthError, "device code request returned: #{body["error"]}" if body["error"] build_device_code(body, clock: clock) end |