Module: PWN::Plugins::GoogleWorkspace

Defined in:
lib/pwn/plugins/google_workspace.rb

Overview

Google Workspace REST client (Gmail, Calendar, Drive, Docs, Sheets). OAuth 2.0 authorization-code + PKCE, same vault/env pattern as PWN::AI::* obtain/refresh_oauth_bearer_token.

Credentials live in PWN::Env[:google_workspace][:oauth] (seeded by PWN::Config / pwn-vault). Desktop-app client_id + client_secret come from Google Cloud Console. After obtain_oauth_auth_url. When wait is true (default), a loopback listener captures the redirect as soon as the operator authorizes the URL; refresh/bearer are written into PWN::Env and the encrypted ~/.pwn/pwn.yaml the same way PWN::AI::Grok persists OAuth tokens.

Constant Summary collapse

AUTH_URI =
'https://accounts.google.com/o/oauth2/v2/auth'
TOKEN_URI =
'https://oauth2.googleapis.com/token'
REVOKE_URI =
'https://oauth2.googleapis.com/revoke'
REDIRECT_URI =
'http://127.0.0.1:1/'
GMAIL_API =
'https://gmail.googleapis.com/gmail/v1'
CAL_API =
'https://www.googleapis.com/calendar/v3'
DRIVE_API =
'https://www.googleapis.com/drive/v3'
DRIVE_UPLOAD =
'https://www.googleapis.com/upload/drive/v3'
DOCS_API =
'https://docs.googleapis.com/v1'
SHEETS_API =
'https://sheets.googleapis.com/v4'
PENDING_FILE =
File.join(Dir.home, '.pwn', 'google_oauth_pending.json')
SERVICE_SCOPES =
{
  'email' => 'https://www.googleapis.com/auth/gmail.modify',
  'gmail' => 'https://www.googleapis.com/auth/gmail.modify',
  'calendar' => 'https://www.googleapis.com/auth/calendar',
  'drive' => 'https://www.googleapis.com/auth/drive',
  'docs' => 'https://www.googleapis.com/auth/documents',
  'sheets' => 'https://www.googleapis.com/auth/spreadsheets'
}.freeze
EXPORT_MIME =
{
  'application/vnd.google-apps.document' => 'application/pdf',
  'application/vnd.google-apps.spreadsheet' => 'text/csv',
  'application/vnd.google-apps.presentation' => 'application/pdf',
  'application/vnd.google-apps.drawing' => 'image/png'
}.freeze

Class Method Summary collapse

Class Method Details

.authenticated?(opts = {}) ⇒ Boolean

Returns:

  • (Boolean)


347
348
349
350
# File 'lib/pwn/plugins/google_workspace.rb', line 347

public_class_method def self.authenticated?(opts = {})
  c = cfg(opts)
  real_config_value?(value: c[:refresh_token]) || real_config_value?(value: c[:bearer_token])
end

.authorsObject



874
875
876
# File 'lib/pwn/plugins/google_workspace.rb', line 874

public_class_method def self.authors
  "AUTHOR(S):\n  0day Inc. <support@0dayinc.com>\n"
end

.bearer_token(opts = {}) ⇒ Object



336
337
338
339
340
341
342
343
344
345
# File 'lib/pwn/plugins/google_workspace.rb', line 336

public_class_method def self.bearer_token(opts = {})
  c = cfg(opts)
  token = c[:bearer_token]
  exp = c[:expires_at].to_i
  stale = !real_config_value?(value: token) || (exp.positive? && Time.now.to_i >= (exp - 120))
  return refresh_oauth_bearer_token(opts.merge(refresh_token: c[:refresh_token])) if stale && real_config_value?(value: c[:refresh_token])
  raise 'not authenticated — run obtain_oauth_auth_url (tokens persist on authorize)' unless real_config_value?(value: token)

  token
end

.calendar_create(opts = {}) ⇒ Object



604
605
606
607
608
609
610
# File 'lib/pwn/plugins/google_workspace.rb', line 604

public_class_method def self.calendar_create(opts = {})
  cal = opts[:calendar_id].to_s
  cal = 'primary' if cal.empty?
  ev = calendar_event_body(opts)
  created = api(http_method: :post, url: "#{CAL_API}/calendars/#{CGI.escape(cal)}/events", http_body: ev)
  { status: 'created', id: created[:id], summary: created[:summary], htmlLink: created[:htmlLink] }
end

.calendar_delete(opts = {}) ⇒ Object



623
624
625
626
627
628
629
630
631
# File 'lib/pwn/plugins/google_workspace.rb', line 623

public_class_method def self.calendar_delete(opts = {})
  id = opts[:id].to_s
  raise 'id is required' if id.empty?

  cal = opts[:calendar_id].to_s
  cal = 'primary' if cal.empty?
  api(http_method: :delete, url: "#{CAL_API}/calendars/#{CGI.escape(cal)}/events/#{id}")
  { status: 'deleted', id: id }
end

.calendar_list(opts = {}) ⇒ Object

── Calendar ───────────────────────────────────────────────────────



582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
# File 'lib/pwn/plugins/google_workspace.rb', line 582

public_class_method def self.calendar_list(opts = {})
  cal = opts[:calendar_id].to_s
  cal = 'primary' if cal.empty?
  params = { singleEvents: true, orderBy: 'startTime' }
  params[:timeMin] = opts[:start] if opts[:start]
  params[:timeMax] = opts[:end] if opts[:end]
  params[:timeMin] ||= Time.now.utc.iso8601
  params[:maxResults] = (opts[:max] || 25).to_i
  data = api(url: "#{CAL_API}/calendars/#{CGI.escape(cal)}/events", params: params)
  Array(data[:items]).map do |ev|
    {
      id: ev[:id],
      summary: ev[:summary],
      start: ev.dig(:start, :dateTime) || ev.dig(:start, :date),
      end: ev.dig(:end, :dateTime) || ev.dig(:end, :date),
      location: ev[:location],
      description: ev[:description],
      htmlLink: ev[:htmlLink]
    }
  end
end

.calendar_update(opts = {}) ⇒ Object



612
613
614
615
616
617
618
619
620
621
# File 'lib/pwn/plugins/google_workspace.rb', line 612

public_class_method def self.calendar_update(opts = {})
  id = opts[:id].to_s
  raise 'id is required' if id.empty?

  cal = opts[:calendar_id].to_s
  cal = 'primary' if cal.empty?
  ev = calendar_event_body(opts)
  updated = api(http_method: :patch, url: "#{CAL_API}/calendars/#{CGI.escape(cal)}/events/#{id}", http_body: ev)
  { status: 'updated', id: updated[:id], summary: updated[:summary], htmlLink: updated[:htmlLink] }
end

.docs_append(opts = {}) ⇒ Object



802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
# File 'lib/pwn/plugins/google_workspace.rb', line 802

public_class_method def self.docs_append(opts = {})
  id = opts[:id].to_s
  raise 'id is required' if id.empty?

  text = opts[:text].to_s
  doc = docs_get(id: id)
  end_idx = doc.dig(:body, :content)&.last&.dig(:endIndex).to_i
  end_idx = 1 if end_idx <= 1
  api(
    http_method: :post,
    url: "#{DOCS_API}/documents/#{id}:batchUpdate",
    http_body: {
      requests: [{ insertText: { location: { index: end_idx - 1 }, text: text } }]
    }
  )
  { status: 'appended', documentId: id, inserted_at: end_idx - 1, characters: text.length }
end

.docs_create(opts = {}) ⇒ Object



789
790
791
792
793
794
795
796
797
798
799
800
# File 'lib/pwn/plugins/google_workspace.rb', line 789

public_class_method def self.docs_create(opts = {})
  title = opts[:title].to_s
  title = 'Untitled' if title.empty?
  created = api(http_method: :post, url: "#{DOCS_API}/documents", http_body: { title: title })
  docs_append(id: created[:documentId], text: opts[:body]) if opts[:body].to_s != ''
  {
    status: 'created',
    documentId: created[:documentId],
    title: created[:title],
    url: "https://docs.google.com/document/d/#{created[:documentId]}/edit"
  }
end

.docs_get(opts = {}) ⇒ Object

── Docs ───────────────────────────────────────────────────────────



782
783
784
785
786
787
# File 'lib/pwn/plugins/google_workspace.rb', line 782

public_class_method def self.docs_get(opts = {})
  id = opts[:id].to_s
  raise 'id is required' if id.empty?

  api(url: "#{DOCS_API}/documents/#{id}")
end

.drive_create_folder(opts = {}) ⇒ Object



736
737
738
739
740
741
742
743
744
# File 'lib/pwn/plugins/google_workspace.rb', line 736

public_class_method def self.drive_create_folder(opts = {})
  name = opts[:name].to_s
  raise 'name is required' if name.empty?

  meta = { name: name, mimeType: 'application/vnd.google-apps.folder' }
  meta[:parents] = [opts[:parent]] if opts[:parent]
  created = api(http_method: :post, url: "#{DRIVE_API}/files", http_body: meta)
  { status: 'created', id: created[:id], name: created[:name], webViewLink: created[:webViewLink] }
end

.drive_delete(opts = {}) ⇒ Object



767
768
769
770
771
772
773
774
775
776
777
778
# File 'lib/pwn/plugins/google_workspace.rb', line 767

public_class_method def self.drive_delete(opts = {})
  id = opts[:id].to_s
  raise 'id is required' if id.empty?

  if opts[:permanent]
    api(http_method: :delete, url: "#{DRIVE_API}/files/#{id}")
    { status: 'deleted', fileId: id, permanent: true }
  else
    api(http_method: :patch, url: "#{DRIVE_API}/files/#{id}", http_body: { trashed: true })
    { status: 'trashed', fileId: id, permanent: false }
  end
end

.drive_download(opts = {}) ⇒ Object



719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
# File 'lib/pwn/plugins/google_workspace.rb', line 719

public_class_method def self.drive_download(opts = {})
  id = opts[:id].to_s
  raise 'id is required' if id.empty?

  meta = drive_get(id: id)
  export = opts[:export_mime] || EXPORT_MIME[meta[:mimeType].to_s]
  raw = if export
          api(url: "#{DRIVE_API}/files/#{id}/export", params: { mimeType: export }, raw: true)
        else
          api(url: "#{DRIVE_API}/files/#{id}", params: { alt: 'media' }, raw: true)
        end
  out = opts[:output].to_s
  out = File.join(Dir.tmpdir, meta[:name].to_s.tr('/', '_')) if out.empty?
  File.binwrite(out, raw.respond_to?(:file) ? File.binread(raw.file) : raw.to_s)
  { status: 'downloaded', id: id, name: meta[:name], path: out, mimeType: export || meta[:mimeType] }
end

.drive_get(opts = {}) ⇒ Object



667
668
669
670
671
672
673
674
675
# File 'lib/pwn/plugins/google_workspace.rb', line 667

public_class_method def self.drive_get(opts = {})
  id = opts[:id].to_s
  raise 'id is required' if id.empty?

  api(
    url: "#{DRIVE_API}/files/#{id}",
    params: { fields: 'id,name,mimeType,modifiedTime,size,webViewLink,parents,owners' }
  )
end

.drive_search(opts = {}) ⇒ Object

── Drive ──────────────────────────────────────────────────────────



653
654
655
656
657
658
659
660
661
662
663
664
665
# File 'lib/pwn/plugins/google_workspace.rb', line 653

public_class_method def self.drive_search(opts = {})
  q = opts[:query].to_s
  q = "fullText contains '#{q.gsub("'", "\\'")}'" unless opts[:raw_query]
  data = api(
    url: "#{DRIVE_API}/files",
    params: {
      q: q,
      pageSize: (opts[:max] || 10).to_i,
      fields: 'files(id,name,mimeType,modifiedTime,webViewLink,size,parents)'
    }
  )
  Array(data[:files])
end

.drive_share(opts = {}) ⇒ Object



746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
# File 'lib/pwn/plugins/google_workspace.rb', line 746

public_class_method def self.drive_share(opts = {})
  id = opts[:id].to_s
  raise 'id is required' if id.empty?

  perm = {
    role: (opts[:role] || 'reader').to_s,
    type: (opts[:type] || (opts[:email] ? 'user' : 'anyone')).to_s
  }
  perm[:emailAddress] = opts[:email] if opts[:email]
  perm[:domain] = opts[:domain] if opts[:domain]
  params = {}
  params[:sendNotificationEmail] = true if opts[:notify]
  created = api(
    http_method: :post,
    url: "#{DRIVE_API}/files/#{id}/permissions",
    params: params,
    http_body: perm
  )
  { status: 'shared', permissionId: created[:id], fileId: id, role: perm[:role], type: perm[:type] }
end

.drive_upload(opts = {}) ⇒ Object



677
678
679
680
681
682
683
684
685
686
687
688
# File 'lib/pwn/plugins/google_workspace.rb', line 677

public_class_method def self.drive_upload(opts = {})
  path = opts[:path].to_s
  raise 'path is required' unless File.file?(path)

  name = opts[:name].to_s
  name = File.basename(path) if name.empty?
  meta = { name: name }
  meta[:parents] = [opts[:parent]] if opts[:parent]
  mime = opts[:mime] || drive_mime(path: path)
  uploaded = drive_multipart_upload(meta: meta, path: path, mime: mime)
  { status: 'uploaded', id: uploaded[:id], name: uploaded[:name], mimeType: uploaded[:mimeType], webViewLink: uploaded[:webViewLink] }
end

.exchange_oauth_code(opts = {}) ⇒ Object



253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
# File 'lib/pwn/plugins/google_workspace.rb', line 253

public_class_method def self.exchange_oauth_code(opts = {})
  c = cfg(opts)
  code = parse_oauth_code(code: opts[:code])
  raise 'code is required (paste the redirect URL or the code= value)' if code.empty?

  pending = load_pending
  client_id = real_config_value?(value: c[:client_id]) ? c[:client_id] : pending[:client_id]
  secret = c[:client_secret]
  redirect = real_config_value?(value: c[:redirect_uri]) ? c[:redirect_uri] : (pending[:redirect_uri] || REDIRECT_URI)
  verifier = opts[:code_verifier] || pending[:verifier]
  raise 'client_id is required' unless real_config_value?(value: client_id)
  raise 'client_secret is required' unless real_config_value?(value: secret)
  raise 'missing PKCE verifier — call obtain_oauth_auth_url first' unless real_config_value?(value: verifier)

  resp = RestClient.post(
    TOKEN_URI,
    {
      grant_type: 'authorization_code',
      code: code,
      client_id: client_id,
      client_secret: secret,
      redirect_uri: redirect,
      code_verifier: verifier
    },
    content_type: 'application/x-www-form-urlencoded',
    accept: 'application/json'
  )
  data = JSON.parse(resp.body)
  raise "Google OAuth error: #{data['error']} - #{data['error_description']}" if data['error']

  oauth = {
    bearer_token: data['access_token'],
    refresh_token: data['refresh_token'] || c[:refresh_token],
    expires_at: data['expires_in'] ? Time.now.to_i + data['expires_in'].to_i : nil,
    client_id: client_id,
    client_secret: secret,
    redirect_uri: redirect
  }
  oauth[:scope] = data['scope'] if data['scope']
  sync_oauth_into_env(oauth: oauth)
  persist_oauth_to_vault(oauth: oauth)
  FileUtils.rm_f(PENDING_FILE)
  oauth
rescue RestClient::ExceptionWithResponse => e
  raise "Google OAuth code exchange failed (HTTP #{e.http_code}): #{e.response&.body}"
end

.gmail_get(opts = {}) ⇒ Object



486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
# File 'lib/pwn/plugins/google_workspace.rb', line 486

public_class_method def self.gmail_get(opts = {})
  id = opts[:id].to_s
  raise 'id is required' if id.empty?

  fmt = opts[:format] || 'full'
  msg = api(url: "#{GMAIL_API}/users/me/messages/#{id}", params: { format: fmt })
  headers = {}
  Array(msg.dig(:payload, :headers)).each { |h| headers[h[:name].to_s.downcase] = h[:value] }
  {
    id: msg[:id],
    threadId: msg[:threadId],
    from: headers['from'],
    to: headers['to'],
    subject: headers['subject'],
    date: headers['date'],
    snippet: msg[:snippet],
    labels: msg[:labelIds],
    body: gmail_body(payload: msg[:payload])
  }
end

.gmail_labels(opts = {}) ⇒ Object



564
565
566
# File 'lib/pwn/plugins/google_workspace.rb', line 564

public_class_method def self.gmail_labels(opts = {})
  api(opts.merge(url: "#{GMAIL_API}/users/me/labels"))
end

.gmail_modify(opts = {}) ⇒ Object



568
569
570
571
572
573
574
575
576
577
578
# File 'lib/pwn/plugins/google_workspace.rb', line 568

public_class_method def self.gmail_modify(opts = {})
  id = opts[:id].to_s
  raise 'id is required' if id.empty?

  body = {}
  add = Array(opts[:add_labels] || opts[:add]).map(&:to_s).reject(&:empty?)
  rem = Array(opts[:remove_labels] || opts[:remove]).map(&:to_s).reject(&:empty?)
  body[:addLabelIds] = add unless add.empty?
  body[:removeLabelIds] = rem unless rem.empty?
  api(http_method: :post, url: "#{GMAIL_API}/users/me/messages/#{id}/modify", http_body: body)
end

.gmail_reply(opts = {}) ⇒ Object



531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
# File 'lib/pwn/plugins/google_workspace.rb', line 531

public_class_method def self.gmail_reply(opts = {})
  id = opts[:id].to_s
  raise 'id is required' if id.empty?

  orig = gmail_get(id: id)
  subj = orig[:subject].to_s
  subj = "Re: #{subj}" unless subj.downcase.start_with?('re:')
  raw = gmail_rfc822(opts.merge(to: orig[:from], subject: subj, in_reply_to: orig[:id]))
  sent = api(
    http_method: :post,
    url: "#{GMAIL_API}/users/me/messages/send",
    http_body: { raw: raw, threadId: orig[:threadId] }
  )
  { status: 'sent', id: sent[:id], threadId: sent[:threadId] }
end

.gmail_search(opts = {}) ⇒ Object

── Gmail ──────────────────────────────────────────────────────────



478
479
480
481
482
483
484
# File 'lib/pwn/plugins/google_workspace.rb', line 478

public_class_method def self.gmail_search(opts = {})
  q = opts[:query].to_s
  max = (opts[:max] || 10).to_i
  listed = api(url: "#{GMAIL_API}/users/me/messages", params: { q: q, maxResults: max }.compact)
  ids = Array(listed[:messages]).map { |m| m[:id] }
  ids.map { |id| gmail_get(id: id, format: 'metadata') }
end

.gmail_send(opts = {}) ⇒ Object



522
523
524
525
526
527
528
529
# File 'lib/pwn/plugins/google_workspace.rb', line 522

public_class_method def self.gmail_send(opts = {})
  to = opts[:to].to_s
  raise 'to is required' if to.empty?

  raw = gmail_rfc822(opts)
  sent = api(http_method: :post, url: "#{GMAIL_API}/users/me/messages/send", http_body: { raw: raw })
  { status: 'sent', id: sent[:id], threadId: sent[:threadId] }
end

.helpObject



878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
# File 'lib/pwn/plugins/google_workspace.rb', line 878

public_class_method def self.help
  puts <<~USAGE
    USAGE:
      # OAuth (Desktop client from Google Cloud Console)
      # authorize the printed URL; refresh/bearer persist to Env + pwn.yaml
      #{self}.obtain_oauth_auth_url(services: 'email,calendar')
      #{self}.obtain_oauth_auth_url(wait: false)  # URL only; no listener
      #{self}.exchange_oauth_code(code: 'optional paste fallback')
      #{self}.authenticated?
      #{self}.revoke

      # Gmail
      #{self}.gmail_search(query: 'is:unread', max: 10)
      #{self}.gmail_get(id: 'MESSAGE_ID')
      #{self}.gmail_send(to: 'a@b.com', subject: 'Hi', body: '...')
      #{self}.gmail_reply(id: 'MESSAGE_ID', body: 'Thanks')
      #{self}.gmail_labels
      #{self}.gmail_modify(id: 'MESSAGE_ID', add_labels: ['STARRED'], remove_labels: ['UNREAD'])

      # Calendar
      #{self}.calendar_list(start: '2026-03-01T00:00:00Z')
      #{self}.calendar_create(summary: 'Standup', start: '2026-03-01T10:00:00-06:00', end: '2026-03-01T10:30:00-06:00')
      #{self}.calendar_update(id: 'EVENT_ID', summary: 'Standup (moved)')
      #{self}.calendar_delete(id: 'EVENT_ID')

      # Drive
      #{self}.drive_search(query: 'quarterly report')
      #{self}.drive_get(id: 'FILE_ID')
      #{self}.drive_upload(path: '/tmp/report.pdf')
      #{self}.drive_download(id: 'FILE_ID', output: '/tmp/out.pdf')
      #{self}.drive_create_folder(name: 'Reports')
      #{self}.drive_share(id: 'FILE_ID', email: 'a@b.com', role: 'reader')
      #{self}.drive_delete(id: 'FILE_ID')

      # Docs / Sheets
      #{self}.docs_create(title: 'Notes', body: 'Hello')
      #{self}.docs_get(id: 'DOC_ID')
      #{self}.docs_append(id: 'DOC_ID', text: ' more')
      #{self}.sheets_create(title: 'Budget')
      #{self}.sheets_get(id: 'SHEET_ID', range: 'Sheet1!A1:D10')
      #{self}.sheets_update(id: 'SHEET_ID', range: 'Sheet1!A1', values: [['Name']])
      #{self}.sheets_append(id: 'SHEET_ID', range: 'Sheet1!A:C', values: [['a', 'b', 'c']])

      Config: PWN::Env[:plugins][:google_workspace][:oauth]
      #{self}.authors
  USAGE
end

.obtain_oauth_auth_url(opts = {}) ⇒ Object



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/pwn/plugins/google_workspace.rb', line 129

public_class_method def self.obtain_oauth_auth_url(opts = {})
  c = cfg(opts)
  client_id = c[:client_id]
  raise 'client_id is required (Google Cloud Desktop OAuth client)' unless real_config_value?(value: client_id)

  wait = opts.key?(:wait) ? opts[:wait] : true
  timeout_s = (opts[:timeout] || 300).to_i
  redirect = real_config_value?(value: c[:redirect_uri]) ? c[:redirect_uri] : REDIRECT_URI
  wanted = scopes(opts.merge(services: c[:services], scope: c[:scope]))
  raise 'no Google scopes selected' if wanted.empty?

  server = nil
  if wait && !opts[:probe].respond_to?(:call)
    server = open_oauth_listener(redirect_uri: redirect)
    redirect = listener_redirect(server: server, fallback: redirect)
  end

  pkce = pkce_pair
  save_pending!(verifier: pkce[:verifier], redirect_uri: redirect, client_id: client_id)

  params = {
    client_id: client_id,
    redirect_uri: redirect,
    response_type: 'code',
    scope: wanted.join(' '),
    access_type: 'offline',
    prompt: 'consent',
    include_granted_scopes: 'true',
    code_challenge: pkce[:challenge],
    code_challenge_method: 'S256'
  }
  url = "#{AUTH_URI}?#{URI.encode_www_form(params)}"
  return { auth_url: url, scope: wanted, redirect_uri: redirect } unless wait

  puts "\n[*] Google Workspace OAuth — authorize this URL, then tokens persist automatically:"
  puts "            #{url}"
  puts "    Waiting for authorization on #{redirect} (timeout #{timeout_s}s)..."
  code = await_oauth_redirect(
    redirect_uri: redirect,
    timeout: timeout_s,
    probe: opts[:probe],
    server: server
  )
  exchange_oauth_code(
    opts.merge(
      code: code,
      redirect_uri: redirect,
      code_verifier: pkce[:verifier],
      client_id: client_id,
      client_secret: c[:client_secret]
    )
  )
ensure
  begin
    server&.close
  rescue StandardError
    nil
  end
end

.parse_oauth_code(opts = {}) ⇒ Object



88
89
90
91
92
93
94
95
96
97
# File 'lib/pwn/plugins/google_workspace.rb', line 88

public_class_method def self.parse_oauth_code(opts = {})
  raw = opts[:code].to_s.strip
  return '' if raw.empty?
  return raw unless raw.include?('://') || raw.include?('code=')

  uri = URI.parse(raw)
  URI.decode_www_form(uri.query.to_s).to_h['code'].to_s
rescue URI::InvalidURIError
  raw[/code=([^&\s]+)/, 1].to_s
end

.refresh_oauth_bearer_token(opts = {}) ⇒ Object



300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/pwn/plugins/google_workspace.rb', line 300

public_class_method def self.refresh_oauth_bearer_token(opts = {})
  c = cfg(opts)
  refresh = opts[:refresh_token] || c[:refresh_token]
  raise 'refresh_token is required' unless real_config_value?(value: refresh)

  client_id = opts[:client_id] || c[:client_id]
  secret = opts[:client_secret] || c[:client_secret]
  raise 'client_id is required' unless real_config_value?(value: client_id)
  raise 'client_secret is required' unless real_config_value?(value: secret)

  resp = RestClient.post(
    TOKEN_URI,
    {
      grant_type: 'refresh_token',
      refresh_token: refresh,
      client_id: client_id,
      client_secret: secret
    },
    content_type: 'application/x-www-form-urlencoded',
    accept: 'application/json'
  )
  data = JSON.parse(resp.body)
  raise "Google OAuth refresh error: #{data['error']} - #{data['error_description']}" if data['error']

  oauth = c.merge(
    bearer_token: data['access_token'],
    refresh_token: data['refresh_token'] || refresh,
    expires_at: data['expires_in'] ? Time.now.to_i + data['expires_in'].to_i : c[:expires_at]
  )
  sync_oauth_into_env(oauth: oauth)
  persist_oauth_to_vault(oauth: oauth)
  data['access_token']
rescue RestClient::ExceptionWithResponse => e
  raise "Google OAuth refresh failed (HTTP #{e.http_code}): #{e.response&.body}"
end

.revoke(opts = {}) ⇒ Object



352
353
354
355
356
357
358
359
360
361
362
363
# File 'lib/pwn/plugins/google_workspace.rb', line 352

public_class_method def self.revoke(opts = {})
  c = cfg(opts)
  tok = c[:refresh_token] || c[:bearer_token]
  RestClient.post(REVOKE_URI, { token: tok }, content_type: 'application/x-www-form-urlencoded') if real_config_value?(value: tok)
  blank = { bearer_token: nil, refresh_token: nil, expires_at: nil }
  sync_oauth_into_env(oauth: c.merge(blank))
  persist_oauth_to_vault(oauth: c.merge(blank.merge(bearer_token: '')))
  FileUtils.rm_f(PENDING_FILE)
  true
rescue RestClient::ExceptionWithResponse
  true
end

.scopes(opts = {}) ⇒ Object



77
78
79
80
81
82
83
84
85
86
# File 'lib/pwn/plugins/google_workspace.rb', line 77

public_class_method def self.scopes(opts = {})
  explicit = opts[:scope].to_s.strip
  return explicit.split(/\s+/) if real_config_value?(value: explicit)

  raw = opts[:services]
  raw = cfg[:services] unless real_config_value?(value: raw)
  names = raw.to_s.split(/[,\s]+/).map(&:downcase).reject(&:empty?)
  names = SERVICE_SCOPES.keys if names.empty? || names.include?('all')
  names.filter_map { |n| SERVICE_SCOPES[n] }.uniq
end

.sheets_append(opts = {}) ⇒ Object



851
852
853
# File 'lib/pwn/plugins/google_workspace.rb', line 851

public_class_method def self.sheets_append(opts = {})
  sheets_write(opts.merge(verb: :post, path: 'values', suffix: ':append'))
end

.sheets_create(opts = {}) ⇒ Object

── Sheets ─────────────────────────────────────────────────────────



822
823
824
825
826
827
828
829
830
831
832
833
834
835
# File 'lib/pwn/plugins/google_workspace.rb', line 822

public_class_method def self.sheets_create(opts = {})
  title = opts[:title].to_s
  title = 'Untitled spreadsheet' if title.empty?
  body = { properties: { title: title } }
  sheet = opts[:sheet_name].to_s
  body[:sheets] = [{ properties: { title: sheet } }] unless sheet.empty?
  created = api(http_method: :post, url: "#{SHEETS_API}/spreadsheets", http_body: body)
  {
    status: 'created',
    spreadsheetId: created[:spreadsheetId],
    title: created.dig(:properties, :title),
    spreadsheetUrl: created[:spreadsheetUrl]
  }
end

.sheets_get(opts = {}) ⇒ Object



837
838
839
840
841
842
843
844
845
# File 'lib/pwn/plugins/google_workspace.rb', line 837

public_class_method def self.sheets_get(opts = {})
  id = opts[:id].to_s
  raise 'id is required' if id.empty?

  range = opts[:range].to_s
  range = 'Sheet1' if range.empty?
  data = api(url: "#{SHEETS_API}/spreadsheets/#{id}/values/#{CGI.escape(range)}")
  data[:values] || []
end

.sheets_update(opts = {}) ⇒ Object



847
848
849
# File 'lib/pwn/plugins/google_workspace.rb', line 847

public_class_method def self.sheets_update(opts = {})
  sheets_write(opts.merge(verb: :put, path: 'values'))
end