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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -0
  5. data/bin/teems +7 -0
  6. data/lib/teems/api/calendar.rb +94 -0
  7. data/lib/teems/api/channels.rb +26 -0
  8. data/lib/teems/api/chats.rb +29 -0
  9. data/lib/teems/api/client.rb +40 -0
  10. data/lib/teems/api/files.rb +12 -0
  11. data/lib/teems/api/messages.rb +58 -0
  12. data/lib/teems/api/users.rb +88 -0
  13. data/lib/teems/api/users_mailbox.rb +16 -0
  14. data/lib/teems/api/users_presence.rb +43 -0
  15. data/lib/teems/cli.rb +133 -0
  16. data/lib/teems/commands/activity.rb +222 -0
  17. data/lib/teems/commands/auth.rb +268 -0
  18. data/lib/teems/commands/base.rb +146 -0
  19. data/lib/teems/commands/cal.rb +891 -0
  20. data/lib/teems/commands/channels.rb +115 -0
  21. data/lib/teems/commands/chats.rb +159 -0
  22. data/lib/teems/commands/help.rb +107 -0
  23. data/lib/teems/commands/messages.rb +281 -0
  24. data/lib/teems/commands/ooo.rb +385 -0
  25. data/lib/teems/commands/org.rb +232 -0
  26. data/lib/teems/commands/status.rb +224 -0
  27. data/lib/teems/commands/sync.rb +390 -0
  28. data/lib/teems/commands/who.rb +377 -0
  29. data/lib/teems/formatters/calendar_formatter.rb +227 -0
  30. data/lib/teems/formatters/format_utils.rb +56 -0
  31. data/lib/teems/formatters/markdown_formatter.rb +113 -0
  32. data/lib/teems/formatters/message_formatter.rb +67 -0
  33. data/lib/teems/formatters/output.rb +105 -0
  34. data/lib/teems/models/account.rb +59 -0
  35. data/lib/teems/models/channel.rb +31 -0
  36. data/lib/teems/models/chat.rb +111 -0
  37. data/lib/teems/models/duration.rb +46 -0
  38. data/lib/teems/models/event.rb +124 -0
  39. data/lib/teems/models/message.rb +125 -0
  40. data/lib/teems/models/parsing.rb +56 -0
  41. data/lib/teems/models/user.rb +25 -0
  42. data/lib/teems/models/user_profile.rb +45 -0
  43. data/lib/teems/runner.rb +81 -0
  44. data/lib/teems/services/api_client.rb +217 -0
  45. data/lib/teems/services/cache_store.rb +32 -0
  46. data/lib/teems/services/configuration.rb +56 -0
  47. data/lib/teems/services/file_downloader.rb +39 -0
  48. data/lib/teems/services/headless_extract.rb +192 -0
  49. data/lib/teems/services/safari_oauth.rb +285 -0
  50. data/lib/teems/services/sync_dir_naming.rb +42 -0
  51. data/lib/teems/services/sync_engine.rb +194 -0
  52. data/lib/teems/services/sync_store.rb +193 -0
  53. data/lib/teems/services/teams_url_parser.rb +78 -0
  54. data/lib/teems/services/token_exchange_scripts.rb +56 -0
  55. data/lib/teems/services/token_extractor.rb +401 -0
  56. data/lib/teems/services/token_extractor_scripts.rb +116 -0
  57. data/lib/teems/services/token_refresher.rb +169 -0
  58. data/lib/teems/services/token_store.rb +116 -0
  59. data/lib/teems/support/error_logger.rb +35 -0
  60. data/lib/teems/support/help_formatter.rb +80 -0
  61. data/lib/teems/support/timezone.rb +44 -0
  62. data/lib/teems/support/xdg_paths.rb +62 -0
  63. data/lib/teems/version.rb +5 -0
  64. data/lib/teems.rb +117 -0
  65. data/support/token_helper.swift +485 -0
  66. 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