teems 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +136 -0
- data/bin/teems +7 -0
- data/lib/teems/api/calendar.rb +94 -0
- data/lib/teems/api/channels.rb +26 -0
- data/lib/teems/api/chats.rb +29 -0
- data/lib/teems/api/client.rb +40 -0
- data/lib/teems/api/files.rb +12 -0
- data/lib/teems/api/messages.rb +58 -0
- data/lib/teems/api/users.rb +88 -0
- data/lib/teems/api/users_mailbox.rb +16 -0
- data/lib/teems/api/users_presence.rb +43 -0
- data/lib/teems/cli.rb +133 -0
- data/lib/teems/commands/activity.rb +222 -0
- data/lib/teems/commands/auth.rb +268 -0
- data/lib/teems/commands/base.rb +146 -0
- data/lib/teems/commands/cal.rb +891 -0
- data/lib/teems/commands/channels.rb +115 -0
- data/lib/teems/commands/chats.rb +159 -0
- data/lib/teems/commands/help.rb +107 -0
- data/lib/teems/commands/messages.rb +281 -0
- data/lib/teems/commands/ooo.rb +385 -0
- data/lib/teems/commands/org.rb +232 -0
- data/lib/teems/commands/status.rb +224 -0
- data/lib/teems/commands/sync.rb +390 -0
- data/lib/teems/commands/who.rb +377 -0
- data/lib/teems/formatters/calendar_formatter.rb +227 -0
- data/lib/teems/formatters/format_utils.rb +56 -0
- data/lib/teems/formatters/markdown_formatter.rb +113 -0
- data/lib/teems/formatters/message_formatter.rb +67 -0
- data/lib/teems/formatters/output.rb +105 -0
- data/lib/teems/models/account.rb +59 -0
- data/lib/teems/models/channel.rb +31 -0
- data/lib/teems/models/chat.rb +111 -0
- data/lib/teems/models/duration.rb +46 -0
- data/lib/teems/models/event.rb +124 -0
- data/lib/teems/models/message.rb +125 -0
- data/lib/teems/models/parsing.rb +56 -0
- data/lib/teems/models/user.rb +25 -0
- data/lib/teems/models/user_profile.rb +45 -0
- data/lib/teems/runner.rb +81 -0
- data/lib/teems/services/api_client.rb +217 -0
- data/lib/teems/services/cache_store.rb +32 -0
- data/lib/teems/services/configuration.rb +56 -0
- data/lib/teems/services/file_downloader.rb +39 -0
- data/lib/teems/services/headless_extract.rb +192 -0
- data/lib/teems/services/safari_oauth.rb +285 -0
- data/lib/teems/services/sync_dir_naming.rb +42 -0
- data/lib/teems/services/sync_engine.rb +194 -0
- data/lib/teems/services/sync_store.rb +193 -0
- data/lib/teems/services/teams_url_parser.rb +78 -0
- data/lib/teems/services/token_exchange_scripts.rb +56 -0
- data/lib/teems/services/token_extractor.rb +401 -0
- data/lib/teems/services/token_extractor_scripts.rb +116 -0
- data/lib/teems/services/token_refresher.rb +169 -0
- data/lib/teems/services/token_store.rb +116 -0
- data/lib/teems/support/error_logger.rb +35 -0
- data/lib/teems/support/help_formatter.rb +80 -0
- data/lib/teems/support/timezone.rb +44 -0
- data/lib/teems/support/xdg_paths.rb +62 -0
- data/lib/teems/version.rb +5 -0
- data/lib/teems.rb +117 -0
- data/support/token_helper.swift +485 -0
- metadata +110 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require_relative 'token_extractor_scripts'
|
|
5
|
+
require_relative 'token_exchange_scripts'
|
|
6
|
+
require_relative 'headless_extract'
|
|
7
|
+
require_relative 'safari_oauth'
|
|
8
|
+
|
|
9
|
+
module Teems
|
|
10
|
+
module Services
|
|
11
|
+
# Safari automation: AppleScript execution and JS injection
|
|
12
|
+
module SafariAutomation
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def run_safari_js(js_code)
|
|
16
|
+
escaped = js_code.gsub('\\', '\\\\\\\\').gsub('"', '\\"').gsub("\n", '\\n')
|
|
17
|
+
run_applescript(safari_js_script(escaped))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run_safari_readystate
|
|
21
|
+
run_applescript(safari_readystate_script)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def safari_js_script(escaped_js)
|
|
25
|
+
<<~APPLESCRIPT
|
|
26
|
+
tell application "Safari"
|
|
27
|
+
if (count of windows) > 0 then
|
|
28
|
+
return do JavaScript "#{escaped_js}" in current tab of front window
|
|
29
|
+
end if
|
|
30
|
+
return ""
|
|
31
|
+
end tell
|
|
32
|
+
APPLESCRIPT
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def safari_readystate_script
|
|
36
|
+
<<~APPLESCRIPT
|
|
37
|
+
tell application "Safari"
|
|
38
|
+
if (count of windows) > 0 then
|
|
39
|
+
set pageURL to URL of current tab of front window
|
|
40
|
+
try
|
|
41
|
+
set readyState to do JavaScript "document.readyState" in current tab of front window
|
|
42
|
+
on error
|
|
43
|
+
set readyState to "loading"
|
|
44
|
+
end try
|
|
45
|
+
return pageURL & "|" & readyState
|
|
46
|
+
end if
|
|
47
|
+
return ""
|
|
48
|
+
end tell
|
|
49
|
+
APPLESCRIPT
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def run_safari_script(body)
|
|
53
|
+
run_applescript(<<~APPLESCRIPT)
|
|
54
|
+
tell application "Safari"
|
|
55
|
+
#{body}
|
|
56
|
+
end tell
|
|
57
|
+
APPLESCRIPT
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def run_applescript(script)
|
|
61
|
+
output, _stderr, status = Open3.capture3('osascript', '-e', script)
|
|
62
|
+
return applescript_failure(status) unless status.success?
|
|
63
|
+
|
|
64
|
+
output.strip
|
|
65
|
+
rescue IOError, SystemCallError => e
|
|
66
|
+
log_applescript_error(e)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def applescript_failure(status)
|
|
70
|
+
log("AppleScript execution failed with status #{status.exitstatus}")
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def log_applescript_error(run_error)
|
|
75
|
+
error_text = format_applescript_error(run_error)
|
|
76
|
+
log(error_text)
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def format_applescript_error(run_error)
|
|
81
|
+
label = run_error.is_a?(Errno::ENOENT) ? 'osascript not found' : 'AppleScript I/O error'
|
|
82
|
+
"#{label}: #{run_error.message}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# V2 token decryption via AES-CBC encrypted localStorage keys
|
|
87
|
+
module TokenV2Decryptor
|
|
88
|
+
include TokenExtractorScripts
|
|
89
|
+
|
|
90
|
+
DECRYPT_RESULT_KEY = '_teems_decrypt_result'
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def extract_encrypted_tokens
|
|
95
|
+
poll_decrypt_result unless kick_off_decryption == 'no_key'
|
|
96
|
+
rescue JSON::ParserError => e
|
|
97
|
+
log("Failed to parse v2 token decryption result: #{e.message}")
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def kick_off_decryption
|
|
102
|
+
decrypt_js = DECRYPT_TOKENS_JS.gsub('{{result_key}}', DECRYPT_RESULT_KEY)
|
|
103
|
+
status = run_safari_js(decrypt_js).to_s.strip
|
|
104
|
+
log("Decryption kick-off: #{status}")
|
|
105
|
+
status
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def poll_decrypt_result
|
|
109
|
+
read_js = READ_DECRYPT_RESULT_JS.gsub('{{result_key}}', DECRYPT_RESULT_KEY)
|
|
110
|
+
result = poll_decrypt_attempts(read_js)
|
|
111
|
+
log('Timed out waiting for v2 token decryption') unless result
|
|
112
|
+
result
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def poll_decrypt_attempts(read_js)
|
|
116
|
+
10.times do |attempt|
|
|
117
|
+
result = check_decrypt_result(read_js, attempt)
|
|
118
|
+
return result if result
|
|
119
|
+
end
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def check_decrypt_result(read_js, attempt)
|
|
124
|
+
sleep 1
|
|
125
|
+
result = run_safari_js(read_js).to_s.strip
|
|
126
|
+
return nil if result.empty? || result == 'null'
|
|
127
|
+
|
|
128
|
+
parse_decrypt_result(result, attempt)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parse_decrypt_result(result, attempt)
|
|
132
|
+
parsed = JSON.parse(result)
|
|
133
|
+
decrypt_error = parsed['error']
|
|
134
|
+
return log_decrypt_error(decrypt_error) if decrypt_error
|
|
135
|
+
|
|
136
|
+
finalize_decrypted_tokens(parsed, attempt)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def finalize_decrypted_tokens(parsed, attempt)
|
|
140
|
+
auth_token = parsed['auth_token']
|
|
141
|
+
return nil unless auth_token
|
|
142
|
+
|
|
143
|
+
log("V2 tokens decrypted after #{attempt + 1}s")
|
|
144
|
+
finalize_tokens(auth_token, parsed['skype_spaces_token'], **extract_v1_refresh_data)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def log_decrypt_error(message)
|
|
148
|
+
log("Decryption error: #{message}")
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Token exchange: converting skype spaces token to skype token
|
|
154
|
+
module TokenExchanger
|
|
155
|
+
include TokenExchangeScripts
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def exchange_skype_if_available(skype_spaces_token)
|
|
160
|
+
return nil unless skype_spaces_token
|
|
161
|
+
|
|
162
|
+
log('Exchanging skype spaces token via authsvc...')
|
|
163
|
+
result = exchange_skype_token(skype_spaces_token)
|
|
164
|
+
result&.dig(:skype_token)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def exchange_skype_token(skype_spaces_token)
|
|
168
|
+
parse_exchange_result(run_safari_js(build_exchange_script(skype_spaces_token)))
|
|
169
|
+
rescue JSON::ParserError => e
|
|
170
|
+
log("Failed to parse token exchange result: #{e.message}")
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def parse_exchange_result(result)
|
|
175
|
+
return nil if result.to_s.empty?
|
|
176
|
+
|
|
177
|
+
parsed = JSON.parse(result)
|
|
178
|
+
return nil if parsed['error']
|
|
179
|
+
|
|
180
|
+
{ skype_token: parsed['skype_token'], region: parsed['region'], chat_service: parsed['chat_service'] }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def build_exchange_script(skype_spaces_token)
|
|
184
|
+
format(EXCHANGE_TOKEN_JS, JSON.generate(skype_spaces_token))
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Token polling: v1/v2 extraction loop and finalization
|
|
189
|
+
module TokenPolling
|
|
190
|
+
include TokenExtractorScripts
|
|
191
|
+
|
|
192
|
+
TOKEN_POLL_MAX_SECONDS = 30
|
|
193
|
+
TOKEN_POLL_INTERVAL = 1
|
|
194
|
+
V1_POLL_MAX_SECONDS = 5
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def wait_for_tokens
|
|
199
|
+
@v2_attempted = false
|
|
200
|
+
TOKEN_POLL_MAX_SECONDS.times do |attempt|
|
|
201
|
+
tokens = poll_iteration(attempt)
|
|
202
|
+
return tokens if tokens
|
|
203
|
+
end
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def poll_iteration(attempt)
|
|
208
|
+
tokens = poll_once(attempt)
|
|
209
|
+
return tokens if tokens&.dig(:auth_token)
|
|
210
|
+
|
|
211
|
+
log_poll_progress(attempt)
|
|
212
|
+
sleep TOKEN_POLL_INTERVAL
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def poll_once(attempt)
|
|
217
|
+
tokens = extract_plaintext_tokens
|
|
218
|
+
return tokens if tokens&.dig(:auth_token)
|
|
219
|
+
|
|
220
|
+
try_v2_if_needed(attempt)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def try_v2_if_needed(attempt)
|
|
224
|
+
return nil if @v2_attempted || attempt < V1_POLL_MAX_SECONDS
|
|
225
|
+
|
|
226
|
+
log('V1 tokens not found, trying v2 encrypted token decryption...')
|
|
227
|
+
@v2_attempted = true
|
|
228
|
+
extract_encrypted_tokens
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def log_poll_progress(attempt)
|
|
232
|
+
elapsed = attempt + 1
|
|
233
|
+
log("Tokens not yet available, retrying... (#{elapsed}s)") if (elapsed % 5).zero?
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def extract_plaintext_tokens
|
|
237
|
+
build_v1_tokens(parse_safari_json(EXTRACT_TOKENS_JS))
|
|
238
|
+
rescue JSON::ParserError => e
|
|
239
|
+
log("Failed to parse v1 token extraction result: #{e.message}")
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def build_v1_tokens(parsed)
|
|
244
|
+
return nil unless parsed&.dig('auth_token')
|
|
245
|
+
|
|
246
|
+
finalize_tokens(parsed['auth_token'], parsed['skype_spaces_token'], **v1_extras(parsed))
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def parse_safari_json(js_code)
|
|
250
|
+
result = run_safari_js(js_code)
|
|
251
|
+
return nil if result.to_s.empty?
|
|
252
|
+
|
|
253
|
+
JSON.parse(result)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Extract refresh token data from V1 localStorage (always unencrypted)
|
|
257
|
+
def extract_v1_refresh_data
|
|
258
|
+
parsed = parse_safari_json(EXTRACT_TOKENS_JS)
|
|
259
|
+
return {} unless parsed&.dig('refresh_token')
|
|
260
|
+
|
|
261
|
+
v1_extras(parsed)
|
|
262
|
+
rescue JSON::ParserError
|
|
263
|
+
{}
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def v1_extras(parsed)
|
|
267
|
+
{ refresh_token: parsed['refresh_token'],
|
|
268
|
+
client_id: parsed['client_id'],
|
|
269
|
+
tenant_id: parsed['tenant_id'] }
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def finalize_tokens(auth_token, skype_spaces_token, **extras)
|
|
273
|
+
skype_token = exchange_skype_if_available(skype_spaces_token)
|
|
274
|
+
{ auth_token: auth_token, skype_token: skype_token,
|
|
275
|
+
skype_spaces_token: skype_spaces_token, chatsvc_token: nil,
|
|
276
|
+
refresh_token: nil, client_id: nil, tenant_id: nil, **extras }
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Extracts Teams authentication tokens using Safari automation
|
|
281
|
+
class TokenExtractor
|
|
282
|
+
include TokenExtractorScripts
|
|
283
|
+
include TokenExchangeScripts
|
|
284
|
+
include SafariAutomation
|
|
285
|
+
include TokenV2Decryptor
|
|
286
|
+
include TokenExchanger
|
|
287
|
+
include TokenPolling
|
|
288
|
+
include HeadlessExtract
|
|
289
|
+
include SafariOAuth
|
|
290
|
+
|
|
291
|
+
TEAMS_URL = 'https://teams.microsoft.com'
|
|
292
|
+
|
|
293
|
+
def initialize(output: nil, auth_mode: :default)
|
|
294
|
+
@output = output
|
|
295
|
+
@auth_mode = auth_mode
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def extract
|
|
299
|
+
try_headless_extract || try_safari_oauth || safari_extract
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def manual_instructions = MANUAL_TOKEN_INSTRUCTIONS
|
|
303
|
+
|
|
304
|
+
private
|
|
305
|
+
|
|
306
|
+
def try_headless_extract
|
|
307
|
+
tokens = super
|
|
308
|
+
tokens if tokens&.dig(:auth_token) && tokens[:skype_token]
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def safari_extract
|
|
312
|
+
unless safari_available?
|
|
313
|
+
log('Safari is not available')
|
|
314
|
+
return
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
log('Opening Teams in Safari...')
|
|
318
|
+
open_teams_in_safari
|
|
319
|
+
extract_and_close
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def extract_and_close
|
|
323
|
+
log('Waiting for login to complete...')
|
|
324
|
+
wait_for_login
|
|
325
|
+
log('Extracting tokens...')
|
|
326
|
+
tokens = wait_for_tokens
|
|
327
|
+
validate_tokens(tokens)
|
|
328
|
+
ensure
|
|
329
|
+
close_teams_tab
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def validate_tokens(tokens)
|
|
333
|
+
if tokens&.dig(:auth_token)
|
|
334
|
+
log('Tokens extracted successfully')
|
|
335
|
+
tokens
|
|
336
|
+
else
|
|
337
|
+
log('Failed to extract tokens')
|
|
338
|
+
nil
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def safari_available? = system('which', 'osascript', out: File::NULL, err: File::NULL)
|
|
343
|
+
|
|
344
|
+
def open_teams_in_safari
|
|
345
|
+
run_applescript(open_teams_script)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def open_teams_script
|
|
349
|
+
<<~APPLESCRIPT
|
|
350
|
+
tell application "Safari"
|
|
351
|
+
activate
|
|
352
|
+
if (count of windows) = 0 then
|
|
353
|
+
make new document with properties {URL:"#{TEAMS_URL}"}
|
|
354
|
+
else
|
|
355
|
+
tell front window
|
|
356
|
+
set current tab to (make new tab with properties {URL:"#{TEAMS_URL}"})
|
|
357
|
+
end tell
|
|
358
|
+
end if
|
|
359
|
+
end tell
|
|
360
|
+
APPLESCRIPT
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def close_teams_tab
|
|
364
|
+
run_safari_script(<<~APPLESCRIPT)
|
|
365
|
+
if (count of windows) > 0 then
|
|
366
|
+
close current tab of front window
|
|
367
|
+
end if
|
|
368
|
+
APPLESCRIPT
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def wait_for_login
|
|
372
|
+
consecutive_ready = 0
|
|
373
|
+
60.times do |second|
|
|
374
|
+
consecutive_ready = check_login_readiness(consecutive_ready, second)
|
|
375
|
+
break if consecutive_ready >= 3
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def check_login_readiness(consecutive_ready, second)
|
|
380
|
+
sleep 1
|
|
381
|
+
consecutive_ready = page_ready? ? consecutive_ready + 1 : 0
|
|
382
|
+
log_login_wait(second + 1)
|
|
383
|
+
consecutive_ready
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def log_login_wait(elapsed)
|
|
387
|
+
log("Waiting... (#{elapsed}s)") if (elapsed % 10).zero?
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def page_ready?
|
|
391
|
+
result = run_safari_readystate
|
|
392
|
+
return false unless result
|
|
393
|
+
|
|
394
|
+
page_url, ready_state = result.split('|', 2)
|
|
395
|
+
page_url.to_s.match?(/teams\.microsoft\.com(?!.*login)/) && ready_state.to_s == 'complete'
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def log(message) = @output&.debug(message)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Services
|
|
5
|
+
# JavaScript constants used by TokenExtractor for v1 token extraction
|
|
6
|
+
# and v2 AES-CBC decryption. Separated to keep modules under line limits.
|
|
7
|
+
module TokenExtractorScripts
|
|
8
|
+
# JavaScript to extract tokens from Teams web app localStorage.
|
|
9
|
+
EXTRACT_TOKENS_JS = <<~JS
|
|
10
|
+
(function() {
|
|
11
|
+
var result = { auth_token: null, skype_spaces_token: null,
|
|
12
|
+
refresh_token: null, client_id: null, tenant_id: null };
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
for (var i = 0; i < localStorage.length; i++) {
|
|
16
|
+
var key = localStorage.key(i);
|
|
17
|
+
|
|
18
|
+
// Teams v1 / MSAL format: accesstoken keys with secret field
|
|
19
|
+
if (key.includes('accesstoken')) {
|
|
20
|
+
var value = localStorage.getItem(key);
|
|
21
|
+
var parsed = JSON.parse(value);
|
|
22
|
+
if (parsed.secret) {
|
|
23
|
+
if (key.includes('graph.microsoft.com')) {
|
|
24
|
+
result.auth_token = parsed.secret;
|
|
25
|
+
}
|
|
26
|
+
if (key.includes('api.spaces.skype.com')) {
|
|
27
|
+
result.skype_spaces_token = parsed.secret;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// MSAL refresh token (always v1/unencrypted)
|
|
33
|
+
if (key.includes('refreshtoken')) {
|
|
34
|
+
var rtVal = localStorage.getItem(key);
|
|
35
|
+
var rt = JSON.parse(rtVal);
|
|
36
|
+
if (rt.secret) {
|
|
37
|
+
result.refresh_token = rt.secret;
|
|
38
|
+
result.client_id = rt.clientId;
|
|
39
|
+
if (rt.homeAccountId && rt.homeAccountId.indexOf('.') !== -1) {
|
|
40
|
+
result.tenant_id = rt.homeAccountId.split('.')[1];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch(e) {}
|
|
46
|
+
|
|
47
|
+
return JSON.stringify(result);
|
|
48
|
+
})()
|
|
49
|
+
JS
|
|
50
|
+
|
|
51
|
+
# JavaScript to kick off async decryption of Teams v2 encrypted tokens.
|
|
52
|
+
DECRYPT_TOKENS_JS = <<~JS
|
|
53
|
+
(function() {
|
|
54
|
+
var RESULT_KEY = '{{result_key}}';
|
|
55
|
+
localStorage.removeItem(RESULT_KEY);
|
|
56
|
+
|
|
57
|
+
var keyDataRaw = localStorage.getItem('tmp.auth.v1.GLOBAL.ExportedEncryptionKey.ExportedEncryptionKey');
|
|
58
|
+
if (!keyDataRaw) {
|
|
59
|
+
localStorage.setItem(RESULT_KEY, JSON.stringify({error: 'no_encryption_key'}));
|
|
60
|
+
return 'no_key';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
var keyData = JSON.parse(keyDataRaw);
|
|
64
|
+
var exportedKey = keyData.item.exportedKey;
|
|
65
|
+
var keyBytes = Uint8Array.from(atob(exportedKey), function(c) { return c.charCodeAt(0); });
|
|
66
|
+
|
|
67
|
+
function getEncItem(keyPart) {
|
|
68
|
+
for (var i = 0; i < localStorage.length; i++) {
|
|
69
|
+
var k = localStorage.key(i);
|
|
70
|
+
if (k.includes(keyPart)) {
|
|
71
|
+
var val = JSON.parse(localStorage.getItem(k));
|
|
72
|
+
return val.item || val;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function decryptToken(item) {
|
|
79
|
+
if (!item || !item.encryptedToken || !item.iv) return Promise.resolve(null);
|
|
80
|
+
var iv = Uint8Array.from(atob(item.iv), function(c) { return c.charCodeAt(0); });
|
|
81
|
+
var enc = Uint8Array.from(atob(item.encryptedToken), function(c) { return c.charCodeAt(0); });
|
|
82
|
+
return crypto.subtle.importKey('raw', keyBytes, {name: 'AES-CBC'}, false, ['decrypt'])
|
|
83
|
+
.then(function(ck) { return crypto.subtle.decrypt({name: 'AES-CBC', iv: iv}, ck, enc); })
|
|
84
|
+
.then(function(d) {
|
|
85
|
+
var text = new TextDecoder().decode(d);
|
|
86
|
+
var padLen = text.charCodeAt(text.length - 1);
|
|
87
|
+
if (padLen > 0 && padLen <= 16) text = text.substring(0, text.length - padLen);
|
|
88
|
+
return text;
|
|
89
|
+
})
|
|
90
|
+
.catch(function() { return null; });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
var graph = getEncItem('Token.HTTPS://GRAPH.MICROSOFT.COM');
|
|
94
|
+
var skype = getEncItem('Token.HTTPS://API.SPACES.SKYPE.COM');
|
|
95
|
+
|
|
96
|
+
Promise.all([decryptToken(graph), decryptToken(skype)])
|
|
97
|
+
.then(function(results) {
|
|
98
|
+
var r = { auth_token: results[0], skype_spaces_token: results[1] };
|
|
99
|
+
localStorage.setItem(RESULT_KEY, JSON.stringify(r));
|
|
100
|
+
})
|
|
101
|
+
.catch(function(e) {
|
|
102
|
+
localStorage.setItem(RESULT_KEY, JSON.stringify({error: e.message}));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return 'started';
|
|
106
|
+
})()
|
|
107
|
+
JS
|
|
108
|
+
|
|
109
|
+
READ_DECRYPT_RESULT_JS = <<~JS
|
|
110
|
+
(function() {
|
|
111
|
+
return localStorage.getItem('{{result_key}}');
|
|
112
|
+
})()
|
|
113
|
+
JS
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
|
|
8
|
+
module Teems
|
|
9
|
+
module Services
|
|
10
|
+
# OIDC refresh: exchange refresh_token for new access tokens via Entra ID
|
|
11
|
+
module OidcRefresh
|
|
12
|
+
OIDC_TOKEN_ENDPOINT = 'https://login.microsoftonline.com/%s/oauth2/v2.0/token'
|
|
13
|
+
GRAPH_SCOPE = 'https://graph.microsoft.com/.default'
|
|
14
|
+
SKYPE_SCOPE = 'https://api.spaces.skype.com/.default'
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def oidc_capable?
|
|
19
|
+
@token_store.refresh_token && @token_store.client_id && @token_store.tenant_id
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def try_oidc_refresh
|
|
23
|
+
oidc_refresh
|
|
24
|
+
rescue *TokenRefresher::RECOVERABLE_ERRORS => e
|
|
25
|
+
log("OIDC refresh error: #{e.class}: #{e.message}, falling back to authsvc...")
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def oidc_refresh
|
|
30
|
+
log('Attempting OIDC token refresh...')
|
|
31
|
+
return nil unless (tokens = fetch_oidc_tokens)
|
|
32
|
+
|
|
33
|
+
@token_store.update_all_tokens(**tokens)
|
|
34
|
+
log('OIDC token refresh successful')
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def fetch_oidc_tokens
|
|
39
|
+
graph = oidc_token_request(GRAPH_SCOPE, @token_store.refresh_token)
|
|
40
|
+
return nil unless graph
|
|
41
|
+
|
|
42
|
+
skype = oidc_token_request(SKYPE_SCOPE, graph['refresh_token'])
|
|
43
|
+
return nil unless skype
|
|
44
|
+
|
|
45
|
+
build_oidc_result(graph, skype)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_oidc_result(graph, skype)
|
|
49
|
+
skype_access, skype_refresh = skype.values_at('access_token', 'refresh_token')
|
|
50
|
+
skype_token = exchange_token(skype_access)
|
|
51
|
+
return nil unless skype_token
|
|
52
|
+
|
|
53
|
+
{ auth_token: graph['access_token'], skype_spaces_token: skype_access,
|
|
54
|
+
skype_token: skype_token, refresh_token: skype_refresh }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def oidc_token_request(scope, refresh_token)
|
|
58
|
+
response = send_oidc_request(scope, refresh_token)
|
|
59
|
+
return log_oidc_failure(response) unless response.is_a?(Net::HTTPSuccess)
|
|
60
|
+
|
|
61
|
+
JSON.parse(response.body)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def send_oidc_request(scope, refresh_token)
|
|
65
|
+
uri = oidc_token_uri
|
|
66
|
+
post = Net::HTTP::Post.new(uri, 'Content-Type' => 'application/x-www-form-urlencoded',
|
|
67
|
+
'Origin' => 'https://teams.microsoft.com')
|
|
68
|
+
post.body = oidc_request_body(scope, refresh_token)
|
|
69
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10,
|
|
70
|
+
read_timeout: 30) { |http| http.request(post) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def oidc_request_body(scope, token)
|
|
74
|
+
URI.encode_www_form(
|
|
75
|
+
'client_id' => @token_store.client_id, 'grant_type' => 'refresh_token',
|
|
76
|
+
'refresh_token' => token, 'scope' => scope
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def log_oidc_failure(response)
|
|
81
|
+
log("OIDC token request failed: HTTP #{response.code}")
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def oidc_token_uri
|
|
86
|
+
@oidc_token_uri ||= URI(format(OIDC_TOKEN_ENDPOINT, @token_store.tenant_id))
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Refreshes tokens via OIDC refresh_token flow or authsvc exchange fallback
|
|
91
|
+
class TokenRefresher
|
|
92
|
+
include OidcRefresh
|
|
93
|
+
|
|
94
|
+
AUTHSVC_URL = 'https://teams.microsoft.com/api/authsvc/v1.0/authz'
|
|
95
|
+
|
|
96
|
+
RECOVERABLE_ERRORS = [
|
|
97
|
+
SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT,
|
|
98
|
+
Errno::EHOSTUNREACH, Net::OpenTimeout, Net::ReadTimeout,
|
|
99
|
+
OpenSSL::SSL::SSLError, JSON::ParserError
|
|
100
|
+
].freeze
|
|
101
|
+
|
|
102
|
+
def initialize(token_store:, output: nil)
|
|
103
|
+
@token_store = token_store
|
|
104
|
+
@output = output
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def refresh
|
|
108
|
+
(oidc_capable? && try_oidc_refresh) || attempt_authsvc_refresh
|
|
109
|
+
rescue *RECOVERABLE_ERRORS => e
|
|
110
|
+
log("Token exchange error: #{e.class}: #{e.message}")
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def attempt_authsvc_refresh
|
|
117
|
+
skype_spaces_token = @token_store.skype_spaces_token
|
|
118
|
+
return log_and_abandon('No skype_spaces_token available for refresh') unless skype_spaces_token
|
|
119
|
+
|
|
120
|
+
attempt_refresh(skype_spaces_token)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def attempt_refresh(token)
|
|
124
|
+
new_token = exchange_and_log(token)
|
|
125
|
+
return log_and_abandon('Token refresh failed - skype_spaces_token may be expired') unless new_token
|
|
126
|
+
|
|
127
|
+
@token_store.update_skype_token(new_token)
|
|
128
|
+
log('Token refresh successful')
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def exchange_and_log(token)
|
|
133
|
+
log('Attempting to refresh skype_token...')
|
|
134
|
+
exchange_token(token)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def exchange_token(skype_spaces_token)
|
|
138
|
+
response = post_authsvc_exchange(skype_spaces_token)
|
|
139
|
+
return log_exchange_failure(response) unless response.is_a?(Net::HTTPSuccess)
|
|
140
|
+
|
|
141
|
+
JSON.parse(response.body).dig('tokens', 'skypeToken')
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def post_authsvc_exchange(token)
|
|
145
|
+
post = Net::HTTP::Post.new(authsvc_uri,
|
|
146
|
+
'Authorization' => "Bearer #{token}",
|
|
147
|
+
'Content-Type' => 'application/json')
|
|
148
|
+
post.body = '{}'
|
|
149
|
+
Net::HTTP.start(authsvc_uri.host, authsvc_uri.port,
|
|
150
|
+
use_ssl: true, open_timeout: 10,
|
|
151
|
+
read_timeout: 30) { |http| http.request(post) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def log_exchange_failure(response)
|
|
155
|
+
log("Token exchange failed: HTTP #{response.code}")
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def authsvc_uri = @authsvc_uri ||= URI(AUTHSVC_URL)
|
|
160
|
+
|
|
161
|
+
def log_and_abandon(message)
|
|
162
|
+
log(message)
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def log(message) = @output&.debug(message)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|