pwn 0.5.642 → 0.5.647

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,9 @@ require 'json'
4
4
  require 'rest-client'
5
5
  require 'tty-spinner'
6
6
  require 'securerandom'
7
+ require 'base64'
8
+ require 'digest'
9
+ require 'uri'
7
10
 
8
11
  module PWN
9
12
  module AI
@@ -12,6 +15,205 @@ module PWN
12
15
  # API documentation: https://docs.anthropic.com/en/api
13
16
  # Obtain an API key from https://console.anthropic.com/
14
17
  module Anthropic
18
+ # Internal helper: true when +opts[:value]+ is a *real* configured value coming
19
+ # from PWN::Config / pwn-vault (i.e. not nil, not blank, and not one of
20
+ # the "optional - ..." / "required - ..." placeholder strings that
21
+ # PWN::Config.default_env writes into a fresh ~/.pwn/pwn.yaml).
22
+ private_class_method def self.real_config_value?(opts = {})
23
+ s = opts[:value].to_s.strip
24
+ return false if s.empty?
25
+ return false if s.match?(/\A(optional|required)\b/i)
26
+
27
+ true
28
+ end
29
+
30
+ # ------------------------------------------------------------------
31
+ # Anthropic / Claude OAuth (Claude Pro/Max) -- public-client PKCE.
32
+ #
33
+ # Same identity path Claude Code / community tools use:
34
+ # * public client_id 9d1c250a-e61b-44d9-88ed-5944d1962f5e (no secret)
35
+ # * authorization_code + S256 PKCE against claude.ai
36
+ # * token endpoint at platform.claude.com
37
+ # * redirect_uri is the official hosted callback that returns a
38
+ # pasteable code (code=true) -- SSH / headless friendly, no
39
+ # localhost listener required.
40
+ #
41
+ # Access tokens are short-lived; refresh_token is durable. Persist via
42
+ # pwn-vault under ai.anthropic.oauth.refresh_token.
43
+ #
44
+ # Wire format for OAuth bearers is different from API keys:
45
+ # Authorization: Bearer <access_token>
46
+ # anthropic-beta: <claude-code betas>
47
+ # (NO x-api-key)
48
+ # ------------------------------------------------------------------
49
+ ANTHROPIC_OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
50
+ ANTHROPIC_OAUTH_AUTHORIZE_URI = 'https://claude.ai/oauth/authorize'
51
+ ANTHROPIC_OAUTH_TOKEN_URI = 'https://platform.claude.com/v1/oauth/token'
52
+ ANTHROPIC_OAUTH_REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback'
53
+ ANTHROPIC_OAUTH_SCOPE = 'user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload'
54
+ ANTHROPIC_OAUTH_USER_AGENT = 'claude-cli/2.1.81 (external, cli)'
55
+ ANTHROPIC_OAUTH_BETA_FLAGS = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05'
56
+
57
+ private_class_method def self.pkce_pair
58
+ verifier = Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false)
59
+ challenge = Base64.urlsafe_encode64(
60
+ Digest::SHA256.digest(verifier),
61
+ padding: false
62
+ )
63
+ { verifier: verifier, challenge: challenge }
64
+ end
65
+
66
+ private_class_method def self.jwt_exp(opts = {})
67
+ seg = opts[:token].to_s.split('.')[1]
68
+ return nil unless seg
69
+
70
+ seg += '=' * ((4 - (seg.length % 4)) % 4)
71
+ JSON.parse(Base64.urlsafe_decode64(seg))['exp']
72
+ rescue StandardError
73
+ nil
74
+ end
75
+
76
+ private_class_method def self.oauth_token_expiring?(opts = {})
77
+ token = opts[:token]
78
+ skew = opts[:skew] || 120
79
+ return true unless real_config_value?(value: token)
80
+
81
+ # Prefer explicit expires_at when present (Anthropic access tokens are often opaque).
82
+ expires_at = opts[:expires_at]
83
+ return Time.now.to_i >= (expires_at.to_i - skew) if expires_at
84
+
85
+ exp = jwt_exp(token: token)
86
+ return false if exp.nil? # opaque token -- trust it until 401
87
+
88
+ Time.now.to_i >= (exp.to_i - skew)
89
+ end
90
+
91
+ # Supported Method Parameters::
92
+ # access_token = PWN::AI::Anthropic.refresh_oauth_bearer_token(
93
+ # refresh_token: 'required - Anthropic OAuth refresh_token',
94
+ # client_id: 'optional - defaults to Claude Code public client',
95
+ # token_uri: 'optional - defaults to platform.claude.com token endpoint'
96
+ # )
97
+ public_class_method def self.refresh_oauth_bearer_token(opts = {})
98
+ refresh_token = opts[:refresh_token]
99
+ raise 'refresh_token is required' unless real_config_value?(value: refresh_token)
100
+
101
+ client_id = real_config_value?(value: opts[:client_id]) ? opts[:client_id] : ANTHROPIC_OAUTH_CLIENT_ID
102
+ token_uri = real_config_value?(value: opts[:token_uri]) ? opts[:token_uri] : ANTHROPIC_OAUTH_TOKEN_URI
103
+
104
+ resp = RestClient.post(
105
+ token_uri,
106
+ {
107
+ grant_type: 'refresh_token',
108
+ refresh_token: refresh_token,
109
+ client_id: client_id
110
+ },
111
+ content_type: 'application/x-www-form-urlencoded',
112
+ accept: 'application/json',
113
+ user_agent: ANTHROPIC_OAUTH_USER_AGENT
114
+ )
115
+ data = JSON.parse(resp.body)
116
+ raise "Anthropic OAuth refresh error: #{data['error']} - #{data['error_description'] || data['message']}" if data['error']
117
+
118
+ opts[:bearer_token] = data['access_token']
119
+ opts[:refresh_token] = data['refresh_token'] if data['refresh_token']
120
+ opts[:expires_at] = Time.now.to_i + data['expires_in'].to_i if data['expires_in']
121
+ data['access_token']
122
+ rescue RestClient::ExceptionWithResponse => e
123
+ raise "Anthropic OAuth refresh failed (HTTP #{e.http_code}): #{e.response&.body}"
124
+ end
125
+
126
+ # Supported Method Parameters::
127
+ # bearer = PWN::AI::Anthropic.obtain_oauth_bearer_token(
128
+ # client_id: 'optional - Claude Code public client id',
129
+ # scope: 'optional - space-delimited scopes',
130
+ # redirect_uri: 'optional - must match registered callback',
131
+ # authorize_uri:'optional - claude.ai authorize endpoint',
132
+ # token_uri: 'optional - platform.claude.com token endpoint'
133
+ # )
134
+ #
135
+ # Runs the OAuth 2.0 Authorization Code + PKCE (S256) flow against
136
+ # Anthropic's public Claude Code client. The hosted redirect returns a
137
+ # pasteable code (code=true) so this works over SSH with no localhost
138
+ # listener -- same UX class as the Grok device flow.
139
+ public_class_method def self.obtain_oauth_bearer_token(opts = {})
140
+ client_id = real_config_value?(value: opts[:client_id]) ? opts[:client_id] : ANTHROPIC_OAUTH_CLIENT_ID
141
+ scope = real_config_value?(value: opts[:scope]) ? opts[:scope] : ANTHROPIC_OAUTH_SCOPE
142
+ redirect_uri = real_config_value?(value: opts[:redirect_uri]) ? opts[:redirect_uri] : ANTHROPIC_OAUTH_REDIRECT_URI
143
+ authorize_uri = real_config_value?(value: opts[:authorize_uri]) ? opts[:authorize_uri] : ANTHROPIC_OAUTH_AUTHORIZE_URI
144
+ token_uri = real_config_value?(value: opts[:token_uri]) ? opts[:token_uri] : ANTHROPIC_OAUTH_TOKEN_URI
145
+
146
+ pkce = pkce_pair
147
+ params = {
148
+ 'code' => 'true',
149
+ 'response_type' => 'code',
150
+ 'client_id' => client_id,
151
+ 'redirect_uri' => redirect_uri,
152
+ 'scope' => scope,
153
+ 'code_challenge' => pkce[:challenge],
154
+ 'code_challenge_method' => 'S256',
155
+ 'state' => pkce[:verifier]
156
+ }
157
+ auth_url = "#{authorize_uri}?#{URI.encode_www_form(params)}"
158
+
159
+ puts "\n[*] Anthropic / Claude OAuth -- Authorization Code + PKCE (S256, public client, no secret)"
160
+ puts ' A Claude Pro / Max subscription on the approving account is required for inference scope.'
161
+ puts ''
162
+ puts ' Step 1: In a browser (any device), open:'
163
+ puts " #{auth_url}"
164
+ puts ' Step 2: Approve access for Claude Code / CLI.'
165
+ puts ' Step 3: Copy the authorization code shown after redirect and paste it below.'
166
+ puts ''
167
+ print ' Authorization code> '
168
+ raw = $stdin.gets
169
+ raise 'Anthropic OAuth enrollment aborted: no code provided.' if raw.nil?
170
+
171
+ # Hosted callback sometimes returns "code#state" — take the code segment only.
172
+ code = raw.to_s.strip.split('#', 2).first
173
+ raise 'Anthropic OAuth enrollment aborted: empty code.' if code.empty?
174
+
175
+ resp = RestClient.post(
176
+ token_uri,
177
+ {
178
+ grant_type: 'authorization_code',
179
+ code: code,
180
+ code_verifier: pkce[:verifier],
181
+ client_id: client_id,
182
+ redirect_uri: redirect_uri,
183
+ state: pkce[:verifier]
184
+ },
185
+ content_type: 'application/x-www-form-urlencoded',
186
+ accept: 'application/json',
187
+ user_agent: ANTHROPIC_OAUTH_USER_AGENT
188
+ )
189
+ data = JSON.parse(resp.body)
190
+ raise "Anthropic OAuth token error: #{data['error']} - #{data['error_description'] || data['message']}" if data['error']
191
+
192
+ access_token = data['access_token']
193
+ refresh_token = data['refresh_token']
194
+ raise 'Anthropic OAuth token endpoint returned no access_token.' unless access_token
195
+
196
+ opts[:bearer_token] = access_token
197
+ opts[:refresh_token] = refresh_token if refresh_token
198
+ opts[:expires_at] = Time.now.to_i + data['expires_in'].to_i if data['expires_in']
199
+
200
+ puts "\n[*] SUCCESS: Anthropic OAuth bearer obtained via authorization_code + PKCE."
201
+ puts ' Cached in-memory for this pwn / pwn-ai process.'
202
+ puts ''
203
+ puts ' TO MAKE THIS PERMANENT (recommended -- one-time), store via pwn-vault:'
204
+ puts " ai.anthropic.oauth.refresh_token = #{refresh_token}" if refresh_token
205
+ puts " ai.anthropic.oauth.bearer_token = #{access_token}"
206
+ puts ' On future runs the refresh_token alone is enough -- PWN::AI::Anthropic will'
207
+ puts ' silently exchange it for a fresh access_token (no browser, no prompt).'
208
+ puts ''
209
+
210
+ access_token
211
+ rescue RestClient::ExceptionWithResponse => e
212
+ raise "Failed to obtain Anthropic OAuth bearer token (HTTP #{e.http_code}): #{e.response&.body}"
213
+ rescue StandardError => e
214
+ raise "Failed to obtain Anthropic OAuth bearer token: #{e.message}"
215
+ end
216
+
15
217
  # Supported Method Parameters::
16
218
  # anthropic_rest_call(
17
219
  # token: 'required - anthropic api key',
@@ -25,10 +227,55 @@ module PWN
25
227
  # )
26
228
 
27
229
  private_class_method def self.anthropic_rest_call(opts = {})
28
- engine = PWN::Env[:ai][:anthropic]
230
+ engine = PWN::Env[:ai][:anthropic] if defined?(PWN::Env)
29
231
  raise 'ERROR: Anthropic Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
30
232
 
31
- token = engine[:key] ||= PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'Anthropic API Key')
233
+ # ------------------------------------------------------------------
234
+ # Credential resolution (PWN::Config / pwn-vault via PWN::Env).
235
+ # Priority:
236
+ # 1. oauth[:bearer_token] (not expiring)
237
+ # 2. oauth[:refresh_token] (silent refresh)
238
+ # 3. oauth device/PKCE enroll when oauth opted-in OR no API key
239
+ # 4. engine[:key] (classic console API key via x-api-key)
240
+ # 5. interactive prompt
241
+ # ------------------------------------------------------------------
242
+ oauth = engine[:oauth].is_a?(Hash) ? engine[:oauth] : (engine[:oauth] ||= {})
243
+ token = nil
244
+ using_oauth = false
245
+
246
+ if real_config_value?(value: oauth[:bearer_token]) &&
247
+ !oauth_token_expiring?(token: oauth[:bearer_token], expires_at: oauth[:expires_at])
248
+ token = oauth[:bearer_token]
249
+ using_oauth = true
250
+ end
251
+
252
+ if token.nil? && real_config_value?(value: oauth[:refresh_token])
253
+ begin
254
+ token = refresh_oauth_bearer_token(oauth)
255
+ using_oauth = true
256
+ rescue StandardError => e
257
+ warn "[!] Anthropic OAuth refresh failed, falling back: #{e.message}"
258
+ end
259
+ end
260
+
261
+ oauth_opt_in = real_config_value?(value: oauth[:client_id]) ||
262
+ oauth[:enroll] == true ||
263
+ real_config_value?(value: oauth[:bearer_token]) ||
264
+ real_config_value?(value: oauth[:refresh_token])
265
+
266
+ if token.nil? && (oauth_opt_in || !real_config_value?(value: engine[:key]))
267
+ token = obtain_oauth_bearer_token(oauth)
268
+ using_oauth = true
269
+ end
270
+
271
+ if token.nil? && real_config_value?(value: engine[:key])
272
+ token = engine[:key]
273
+ using_oauth = false
274
+ end
275
+
276
+ token ||= PWN::Plugins::AuthenticationHelper.mask_password(
277
+ prompt: 'Anthropic API Key (or run PWN::AI::Anthropic.obtain_oauth_bearer_token for Claude Pro/Max OAuth)'
278
+ )
32
279
 
33
280
  http_method = if opts[:http_method].nil?
34
281
  :get
@@ -36,14 +283,24 @@ module PWN
36
283
  opts[:http_method].to_s.scrub.to_sym
37
284
  end
38
285
 
39
- base_uri = engine[:base_uri] ||= 'https://api.anthropic.com/v1'
286
+ base_uri = real_config_value?(value: engine[:base_uri]) ? engine[:base_uri] : 'https://api.anthropic.com/v1'
40
287
  rest_call = opts[:rest_call].to_s.scrub
41
288
  params = opts[:params]
42
289
  headers = {
43
290
  content_type: 'application/json; charset=UTF-8',
44
- 'x-api-key': token,
45
291
  'anthropic-version': '2023-06-01'
46
292
  }
293
+ if using_oauth
294
+ beta = real_config_value?(value: oauth[:beta_flags]) ? oauth[:beta_flags] : ANTHROPIC_OAUTH_BETA_FLAGS
295
+ ua = real_config_value?(value: oauth[:user_agent]) ? oauth[:user_agent] : ANTHROPIC_OAUTH_USER_AGENT
296
+ headers[:authorization] = "Bearer #{token}"
297
+ headers['anthropic-beta'] = beta
298
+ headers['anthropic-dangerous-direct-browser-access'] = 'true'
299
+ headers['user-agent'] = ua
300
+ headers['x-app'] = 'cli'
301
+ else
302
+ headers['x-api-key'] = token
303
+ end
47
304
 
48
305
  http_body = opts[:http_body]
49
306
  http_body ||= {}
@@ -476,6 +733,10 @@ module PWN
476
733
  puts "USAGE:
477
734
  models = #{self}.get_models
478
735
 
736
+ # One-time Claude Pro/Max OAuth enrollment (PKCE paste flow):
737
+ bearer = #{self}.obtain_oauth_bearer_token
738
+ # Subsequent runs silently refresh via ai.anthropic.oauth.refresh_token
739
+
479
740
  response = #{self}.chat(
480
741
  request: 'required - message to Anthropic',
481
742
  model: 'optional - model to use for text generation (defaults to PWN::Env[:ai][:anthropic][:model])',
@@ -4,6 +4,8 @@ require 'json'
4
4
  require 'base64'
5
5
  require 'securerandom'
6
6
  require 'tty-spinner'
7
+ require 'digest'
8
+ require 'uri'
7
9
 
8
10
  module PWN
9
11
  module AI
@@ -12,6 +14,264 @@ module PWN
12
14
  # This is based on the following OpenAI API Specification:
13
15
  # https://api.openai.com/v1
14
16
  module OpenAI
17
+ # Internal helper: true when +opts[:value]+ is a *real* configured value
18
+ # coming from PWN::Config / pwn-vault (not a placeholder string).
19
+ private_class_method def self.real_config_value?(opts = {})
20
+ s = opts[:value].to_s.strip
21
+ return false if s.empty?
22
+ return false if s.match?(/\A(optional|required)\b/i)
23
+
24
+ true
25
+ end
26
+
27
+ # ------------------------------------------------------------------
28
+ # OpenAI / ChatGPT OAuth (Codex / ChatGPT subscription) -- public client.
29
+ #
30
+ # Same identity path openai/codex uses:
31
+ # * public client_id app_EMoamEEZ73f0CkXaXp7hrann (no secret)
32
+ # * issuer https://auth.openai.com
33
+ # * device-code UX via /api/accounts/deviceauth/* then authorization_code
34
+ # exchange at /oauth/token (PKCE verifier supplied by the deviceauth
35
+ # token response -- no localhost listener)
36
+ # * refresh_token grant at /oauth/token (JSON body, same as codex)
37
+ #
38
+ # Access tokens are short-lived JWTs. Persist refresh_token via
39
+ # pwn-vault under ai.openai.oauth.refresh_token.
40
+ # ------------------------------------------------------------------
41
+ OPENAI_OAUTH_ISSUER = 'https://auth.openai.com'
42
+ OPENAI_OAUTH_TOKEN_URI = "#{OPENAI_OAUTH_ISSUER}/oauth/token".freeze
43
+ OPENAI_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
44
+ OPENAI_OAUTH_SCOPE = 'openid profile email offline_access'
45
+ OPENAI_OAUTH_DEVICE_USERCODE_URI = "#{OPENAI_OAUTH_ISSUER}/api/accounts/deviceauth/usercode".freeze
46
+ OPENAI_OAUTH_DEVICE_TOKEN_URI = "#{OPENAI_OAUTH_ISSUER}/api/accounts/deviceauth/token".freeze
47
+ OPENAI_OAUTH_DEVICE_VERIFY_URI = "#{OPENAI_OAUTH_ISSUER}/codex/device".freeze
48
+ OPENAI_OAUTH_DEVICE_REDIRECT_URI = "#{OPENAI_OAUTH_ISSUER}/deviceauth/callback".freeze
49
+
50
+ private_class_method def self.jwt_exp(opts = {})
51
+ seg = opts[:token].to_s.split('.')[1]
52
+ return nil unless seg
53
+
54
+ seg += '=' * ((4 - (seg.length % 4)) % 4)
55
+ JSON.parse(Base64.urlsafe_decode64(seg))['exp']
56
+ rescue StandardError
57
+ nil
58
+ end
59
+
60
+ private_class_method def self.oauth_token_expiring?(opts = {})
61
+ token = opts[:token]
62
+ skew = opts[:skew] || 120
63
+ return true unless real_config_value?(value: token)
64
+
65
+ expires_at = opts[:expires_at]
66
+ return Time.now.to_i >= (expires_at.to_i - skew) if expires_at
67
+
68
+ exp = jwt_exp(token: token)
69
+ return false if exp.nil?
70
+
71
+ Time.now.to_i >= (exp.to_i - skew)
72
+ end
73
+
74
+ # Supported Method Parameters::
75
+ # access_token = PWN::AI::OpenAI.refresh_oauth_bearer_token(
76
+ # refresh_token: 'required - OpenAI/ChatGPT OAuth refresh_token',
77
+ # client_id: 'optional - defaults to Codex public client',
78
+ # token_uri: 'optional - defaults to https://auth.openai.com/oauth/token'
79
+ # )
80
+ #
81
+ # Codex posts JSON (not form-urlencoded) to the refresh endpoint.
82
+ public_class_method def self.refresh_oauth_bearer_token(opts = {})
83
+ refresh_token = opts[:refresh_token]
84
+ raise 'refresh_token is required' unless real_config_value?(value: refresh_token)
85
+
86
+ client_id = real_config_value?(value: opts[:client_id]) ? opts[:client_id] : OPENAI_OAUTH_CLIENT_ID
87
+ token_uri = real_config_value?(value: opts[:token_uri]) ? opts[:token_uri] : OPENAI_OAUTH_TOKEN_URI
88
+
89
+ resp = RestClient.post(
90
+ token_uri,
91
+ {
92
+ client_id: client_id,
93
+ grant_type: 'refresh_token',
94
+ refresh_token: refresh_token
95
+ }.to_json,
96
+ content_type: 'application/json',
97
+ accept: 'application/json'
98
+ )
99
+ data = JSON.parse(resp.body)
100
+ raise "OpenAI OAuth refresh error: #{data['error']} - #{data['error_description'] || data.dig('error', 'message')}" if data['error'] && !data['access_token']
101
+
102
+ access = data['access_token']
103
+ raise 'OpenAI OAuth refresh returned no access_token.' unless access
104
+
105
+ opts[:bearer_token] = access
106
+ opts[:refresh_token] = data['refresh_token'] if data['refresh_token']
107
+ opts[:id_token] = data['id_token'] if data['id_token']
108
+ opts[:expires_at] = Time.now.to_i + data['expires_in'].to_i if data['expires_in']
109
+ # Prefer explicit account id; fall back to JWT claim when present.
110
+ if data['id_token']
111
+ begin
112
+ exp_seg = data['id_token'].to_s.split('.')[1]
113
+ if exp_seg
114
+ exp_seg += '=' * ((4 - (exp_seg.length % 4)) % 4)
115
+ claims = JSON.parse(Base64.urlsafe_decode64(exp_seg))
116
+ acct = claims.dig('https://api.openai.com/auth', 'chatgpt_account_id')
117
+ opts[:account_id] = acct if acct
118
+ end
119
+ rescue StandardError
120
+ # ignore claim parse failures
121
+ end
122
+ end
123
+ access
124
+ rescue RestClient::ExceptionWithResponse => e
125
+ raise "OpenAI OAuth refresh failed (HTTP #{e.http_code}): #{e.response&.body}"
126
+ end
127
+
128
+ # Supported Method Parameters::
129
+ # bearer = PWN::AI::OpenAI.obtain_oauth_bearer_token(
130
+ # client_id: 'optional - Codex public client id',
131
+ # issuer: 'optional - defaults to https://auth.openai.com',
132
+ # timeout: 'optional - seconds to wait for user consent (default 900)'
133
+ # )
134
+ #
135
+ # Runs the Codex device-code login:
136
+ # 1. POST /api/accounts/deviceauth/usercode -> device_auth_id, user_code
137
+ # 2. User opens https://auth.openai.com/codex/device and enters code
138
+ # 3. Poll POST /api/accounts/deviceauth/token until authorization_code + pkce
139
+ # 4. POST /oauth/token authorization_code grant -> access/refresh/id tokens
140
+ public_class_method def self.obtain_oauth_bearer_token(opts = {})
141
+ client_id = real_config_value?(value: opts[:client_id]) ? opts[:client_id] : OPENAI_OAUTH_CLIENT_ID
142
+ issuer = real_config_value?(value: opts[:issuer]) ? opts[:issuer].to_s.sub(%r{/*\z}, '') : OPENAI_OAUTH_ISSUER
143
+ token_uri = real_config_value?(value: opts[:token_uri]) ? opts[:token_uri] : "#{issuer}/oauth/token"
144
+ usercode_uri = "#{issuer}/api/accounts/deviceauth/usercode"
145
+ poll_uri = "#{issuer}/api/accounts/deviceauth/token"
146
+ verify_uri = "#{issuer}/codex/device"
147
+ redirect_uri = "#{issuer}/deviceauth/callback"
148
+ timeout = (opts[:timeout] || 900).to_i
149
+
150
+ # -- Step 1: request user code ---------------------------------------
151
+ uc = JSON.parse(
152
+ RestClient.post(
153
+ usercode_uri,
154
+ { client_id: client_id }.to_json,
155
+ content_type: 'application/json',
156
+ accept: 'application/json'
157
+ ).body
158
+ )
159
+ raise "OpenAI device usercode error: #{uc['error'] || uc}" if uc['error'] && !uc['device_auth_id']
160
+
161
+ device_auth_id = uc['device_auth_id']
162
+ user_code = uc['user_code'] || uc['usercode']
163
+ interval = (uc['interval'] || 5).to_i
164
+ interval = 5 if interval <= 0
165
+ deadline = Time.now.to_i + timeout
166
+
167
+ raise 'OpenAI device usercode response missing device_auth_id/user_code' if device_auth_id.to_s.empty? || user_code.to_s.empty?
168
+
169
+ puts "\n[*] OpenAI / ChatGPT OAuth -- Device Authorization (Codex public client, no secret)"
170
+ puts ' A ChatGPT Plus / Pro / Team / Enterprise plan is typically required for Codex API access.'
171
+ puts ''
172
+ puts ' Step 1: In a browser (any device), open:'
173
+ puts " #{verify_uri}"
174
+ puts " Step 2: Enter this one-time code: #{user_code}"
175
+ puts ' Step 3: Approve access for Codex / ChatGPT.'
176
+ puts ''
177
+ puts " Waiting for approval (polling every #{interval}s, timeout #{timeout}s)..."
178
+
179
+ # -- Step 2: poll until authorization_code + pkce material -----------
180
+ code_resp = nil
181
+ loop do
182
+ sleep interval
183
+ begin
184
+ poll = RestClient.post(
185
+ poll_uri,
186
+ {
187
+ device_auth_id: device_auth_id,
188
+ user_code: user_code
189
+ }.to_json,
190
+ content_type: 'application/json',
191
+ accept: 'application/json'
192
+ )
193
+ code_resp = JSON.parse(poll.body)
194
+ break if code_resp['authorization_code']
195
+ rescue RestClient::ExceptionWithResponse => e
196
+ # Codex treats 403/404 as "still pending"
197
+ if [403, 404].include?(e.http_code)
198
+ raise 'OpenAI OAuth device flow timed out waiting for user approval.' if Time.now.to_i >= deadline
199
+
200
+ next
201
+ end
202
+ body = begin
203
+ JSON.parse(e.response.body)
204
+ rescue StandardError
205
+ { 'error' => "http_#{e.http_code}", 'error_description' => e.response&.body }
206
+ end
207
+ raise "OpenAI OAuth device poll error: #{body['error']} - #{body['error_description'] || body}"
208
+ end
209
+ raise 'OpenAI OAuth device flow timed out waiting for user approval.' if Time.now.to_i >= deadline
210
+ end
211
+
212
+ authorization_code = code_resp['authorization_code']
213
+ code_verifier = code_resp['code_verifier']
214
+ raise 'OpenAI deviceauth/token returned no authorization_code.' unless authorization_code
215
+ raise 'OpenAI deviceauth/token returned no code_verifier.' unless code_verifier
216
+
217
+ # -- Step 3: exchange authorization_code for tokens ------------------
218
+ resp = RestClient.post(
219
+ token_uri,
220
+ URI.encode_www_form(
221
+ grant_type: 'authorization_code',
222
+ code: authorization_code,
223
+ redirect_uri: redirect_uri,
224
+ client_id: client_id,
225
+ code_verifier: code_verifier
226
+ ),
227
+ content_type: 'application/x-www-form-urlencoded',
228
+ accept: 'application/json'
229
+ )
230
+ data = JSON.parse(resp.body)
231
+ raise "OpenAI OAuth token error: #{data['error']} - #{data['error_description']}" if data['error'] && !data['access_token']
232
+
233
+ access_token = data['access_token']
234
+ refresh_token = data['refresh_token']
235
+ id_token = data['id_token']
236
+ raise 'OpenAI OAuth token endpoint returned no access_token.' unless access_token
237
+
238
+ opts[:bearer_token] = access_token
239
+ opts[:refresh_token] = refresh_token if refresh_token
240
+ opts[:id_token] = id_token if id_token
241
+ opts[:expires_at] = Time.now.to_i + data['expires_in'].to_i if data['expires_in']
242
+
243
+ if id_token
244
+ begin
245
+ seg = id_token.to_s.split('.')[1]
246
+ if seg
247
+ seg += '=' * ((4 - (seg.length % 4)) % 4)
248
+ claims = JSON.parse(Base64.urlsafe_decode64(seg))
249
+ acct = claims.dig('https://api.openai.com/auth', 'chatgpt_account_id')
250
+ opts[:account_id] = acct if acct
251
+ end
252
+ rescue StandardError
253
+ # ignore
254
+ end
255
+ end
256
+
257
+ puts "\n[*] SUCCESS: OpenAI / ChatGPT OAuth bearer obtained via device_code grant."
258
+ puts ' Cached in-memory for this pwn / pwn-ai process.'
259
+ puts ''
260
+ puts ' TO MAKE THIS PERMANENT (recommended -- one-time), store via pwn-vault:'
261
+ puts " ai.openai.oauth.refresh_token = #{refresh_token}" if refresh_token
262
+ puts " ai.openai.oauth.bearer_token = #{access_token}"
263
+ puts " ai.openai.oauth.account_id = #{opts[:account_id]}" if opts[:account_id]
264
+ puts ' On future runs the refresh_token alone is enough -- PWN::AI::OpenAI will'
265
+ puts ' silently exchange it for a fresh access_token (no browser, no prompt).'
266
+ puts ''
267
+
268
+ access_token
269
+ rescue RestClient::ExceptionWithResponse => e
270
+ raise "Failed to obtain OpenAI OAuth bearer token (HTTP #{e.http_code}): #{e.response&.body}"
271
+ rescue StandardError => e
272
+ raise "Failed to obtain OpenAI OAuth bearer token: #{e.message}"
273
+ end
274
+
15
275
  # Supported Method Parameters::
16
276
  # open_ai_rest_call(
17
277
  # http_method: 'optional HTTP method (defaults to GET)
@@ -23,23 +283,62 @@ module PWN
23
283
  # )
24
284
 
25
285
  private_class_method def self.open_ai_rest_call(opts = {})
26
- engine = PWN::Env[:ai][:openai]
27
- raise 'ERROR: Jira Server Hash not found in PWN::Env. Run i`pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
286
+ engine = PWN::Env[:ai][:openai] if defined?(PWN::Env)
287
+ raise 'ERROR: OpenAI Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
288
+
289
+ # ------------------------------------------------------------------
290
+ # Credential resolution (PWN::Config / pwn-vault via PWN::Env).
291
+ # Priority:
292
+ # 1. oauth[:bearer_token] (not expiring)
293
+ # 2. oauth[:refresh_token] (silent refresh)
294
+ # 3. oauth device flow when oauth opted-in OR no API key
295
+ # 4. engine[:key] (classic OpenAI API key)
296
+ # 5. interactive prompt
297
+ # ------------------------------------------------------------------
298
+ oauth = engine[:oauth].is_a?(Hash) ? engine[:oauth] : (engine[:oauth] ||= {})
299
+ token = nil
300
+
301
+ if real_config_value?(value: oauth[:bearer_token]) &&
302
+ !oauth_token_expiring?(token: oauth[:bearer_token], expires_at: oauth[:expires_at])
303
+ token = oauth[:bearer_token]
304
+ end
305
+
306
+ if token.nil? && real_config_value?(value: oauth[:refresh_token])
307
+ begin
308
+ token = refresh_oauth_bearer_token(oauth)
309
+ rescue StandardError => e
310
+ warn "[!] OpenAI OAuth refresh failed, falling back: #{e.message}"
311
+ end
312
+ end
313
+
314
+ oauth_opt_in = real_config_value?(value: oauth[:client_id]) ||
315
+ oauth[:enroll] == true ||
316
+ real_config_value?(value: oauth[:bearer_token]) ||
317
+ real_config_value?(value: oauth[:refresh_token])
318
+
319
+ token = obtain_oauth_bearer_token(oauth) if token.nil? && (oauth_opt_in || !real_config_value?(value: engine[:key]))
320
+
321
+ token = engine[:key] if token.nil? && real_config_value?(value: engine[:key])
322
+
323
+ token ||= PWN::Plugins::AuthenticationHelper.mask_password(
324
+ prompt: 'OpenAI API Key (or run PWN::AI::OpenAI.obtain_oauth_bearer_token for ChatGPT/Codex OAuth)'
325
+ )
28
326
 
29
- token = engine[:key] ||= PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'OpenAI API Key')
30
327
  http_method = if opts[:http_method].nil?
31
328
  :get
32
329
  else
33
330
  opts[:http_method].to_s.scrub.to_sym
34
331
  end
35
332
 
36
- base_uri = engine[:base_uri] ||= 'https://api.openai.com/v1'
333
+ base_uri = real_config_value?(value: engine[:base_uri]) ? engine[:base_uri] : 'https://api.openai.com/v1'
37
334
  rest_call = opts[:rest_call].to_s.scrub
38
335
  params = opts[:params]
39
336
  headers = {
40
337
  content_type: 'application/json; charset=UTF-8',
41
338
  authorization: "Bearer #{token}"
42
339
  }
340
+ # ChatGPT subscription tokens often need the account id header (codex).
341
+ headers['ChatGPT-Account-Id'] = oauth[:account_id] if real_config_value?(value: oauth[:account_id])
43
342
 
44
343
  http_body = opts[:http_body]
45
344
  http_body ||= {}
@@ -746,6 +1045,10 @@ module PWN
746
1045
  puts "USAGE:
747
1046
  models = #{self}.get_models
748
1047
 
1048
+ # One-time ChatGPT/Codex OAuth enrollment (device-code flow):
1049
+ bearer = #{self}.obtain_oauth_bearer_token
1050
+ # Subsequent runs silently refresh via ai.openai.oauth.refresh_token
1051
+
749
1052
  response = #{self}.chat(
750
1053
  request: 'required - message to ChatGPT',
751
1054
  model: 'optional - model to use for text generation (defaults to PWN::Env[:ai][:openai][:model])',