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,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
require 'uri'
|
|
7
|
+
|
|
8
|
+
module Teems
|
|
9
|
+
module Services
|
|
10
|
+
# Compiles and manages the Swift WKWebView helper binary
|
|
11
|
+
module HelperBinary
|
|
12
|
+
SWIFT_FRAMEWORKS = %w[WebKit Security AppKit].freeze
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def ensure_helper_binary
|
|
17
|
+
source = helper_source_path
|
|
18
|
+
binary = helper_binary_path
|
|
19
|
+
return nil unless File.exist?(source)
|
|
20
|
+
return binary if File.exist?(binary) && File.mtime(binary) >= File.mtime(source)
|
|
21
|
+
|
|
22
|
+
compile_helper(source, binary)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def compile_helper(source, binary)
|
|
26
|
+
log('Compiling headless token helper...')
|
|
27
|
+
_stdout, stderr, status = Open3.capture3(*swiftc_command(source, binary))
|
|
28
|
+
log_helper_stderr(stderr)
|
|
29
|
+
status.success? ? binary : log_and_nil('Failed to compile helper')
|
|
30
|
+
rescue Errno::ENOENT
|
|
31
|
+
log_and_nil('swiftc not found')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# :reek:UtilityFunction
|
|
35
|
+
def swiftc_command(source, binary)
|
|
36
|
+
['swiftc', *SWIFT_FRAMEWORKS.flat_map { |fw| ['-framework', fw] }, source, '-o', binary]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def log_and_nil(message)
|
|
40
|
+
log(message)
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def log_helper_stderr(stderr)
|
|
45
|
+
stderr.each_line { |line| log("[helper] #{line.chomp}") }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def helper_source_path
|
|
49
|
+
File.expand_path('../../../support/token_helper.swift', __dir__)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def helper_binary_path
|
|
53
|
+
helper_source_path.sub(/\.swift$/, '')
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# HTTP-based Skype token exchange (no browser needed)
|
|
58
|
+
module HttpSkypeExchange
|
|
59
|
+
AUTHSVC_URL = 'https://teams.microsoft.com/api/authsvc/v1.0/authz'
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# :reek:UtilityFunction :reek:TooManyStatements :reek:UncommunicativeVariableName
|
|
64
|
+
def exchange_skype_via_http(skype_spaces_token)
|
|
65
|
+
return nil unless skype_spaces_token
|
|
66
|
+
|
|
67
|
+
response = post_authsvc_exchange(skype_spaces_token)
|
|
68
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
69
|
+
|
|
70
|
+
JSON.parse(response.body).dig('tokens', 'skypeToken')
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
log("Skype exchange failed: #{e.message}")
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# :reek:UtilityFunction
|
|
77
|
+
def post_authsvc_exchange(token)
|
|
78
|
+
uri = URI(AUTHSVC_URL)
|
|
79
|
+
http = build_authsvc_http(uri)
|
|
80
|
+
http.request(build_authsvc_request(uri, token))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# :reek:UtilityFunction
|
|
84
|
+
def build_authsvc_http(uri)
|
|
85
|
+
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
|
86
|
+
http.use_ssl = true
|
|
87
|
+
http.open_timeout = 10
|
|
88
|
+
http.read_timeout = 30
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# :reek:UtilityFunction
|
|
93
|
+
def build_authsvc_request(uri, token)
|
|
94
|
+
Net::HTTP::Post.new(uri).tap do |req|
|
|
95
|
+
req['Authorization'] = "Bearer #{token}"
|
|
96
|
+
req['Content-Type'] = 'application/json'
|
|
97
|
+
req.body = '{}'
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Headless token extraction via WKWebView Swift helper.
|
|
103
|
+
# Uses OAuth2 implicit flow with redirect interception — no Safari needed.
|
|
104
|
+
# Falls through to Safari when no cached Entra ID session exists.
|
|
105
|
+
module HeadlessExtract
|
|
106
|
+
include HelperBinary
|
|
107
|
+
include HttpSkypeExchange
|
|
108
|
+
|
|
109
|
+
HELPER_TIMEOUT = 15
|
|
110
|
+
NEEDS_SAFARI_EXIT = 2
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# :reek:TooManyStatements :reek:UncommunicativeVariableName
|
|
115
|
+
def try_headless_extract
|
|
116
|
+
binary = ensure_helper_binary
|
|
117
|
+
return nil unless binary
|
|
118
|
+
|
|
119
|
+
log('Trying headless token extraction...')
|
|
120
|
+
output, stderr, status = Open3.capture3(binary, *build_helper_args)
|
|
121
|
+
log_helper_stderr(stderr)
|
|
122
|
+
handle_helper_result(output, status.exitstatus)
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
log("Headless extraction error: #{e.message}")
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def handle_helper_result(output, exit_code)
|
|
129
|
+
return parse_headless_result(output) if exit_code.zero?
|
|
130
|
+
|
|
131
|
+
message = exit_code == NEEDS_SAFARI_EXIT ? 'No cached session' : "Helper exited #{exit_code}"
|
|
132
|
+
log("#{message}, falling back to Safari...")
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def build_helper_args
|
|
137
|
+
hint, tenant = stored_login_hint
|
|
138
|
+
['--timeout', HELPER_TIMEOUT.to_s,
|
|
139
|
+
*(hint ? ['--login-hint', hint] : []),
|
|
140
|
+
*(tenant ? ['--tenant-id', tenant] : []),
|
|
141
|
+
*(@auth_mode == :certauth ? ['--certauth'] : [])]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# :reek:UtilityFunction
|
|
145
|
+
def stored_login_hint
|
|
146
|
+
path = locate_token_store
|
|
147
|
+
return [nil, nil] unless path && File.exist?(path)
|
|
148
|
+
|
|
149
|
+
data = JSON.parse(File.read(path))
|
|
150
|
+
[extract_upn(data['auth_token']), data['tenant_id']]
|
|
151
|
+
rescue StandardError
|
|
152
|
+
[nil, nil]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# :reek:UtilityFunction
|
|
156
|
+
def extract_upn(jwt)
|
|
157
|
+
return nil unless jwt && (payload = jwt.split('.')[1])
|
|
158
|
+
|
|
159
|
+
padded = payload.tr('-_', '+/').ljust((payload.length + 3) & ~3, '=')
|
|
160
|
+
JSON.parse(padded.unpack1('m'))['upn']
|
|
161
|
+
rescue StandardError
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# :reek:UtilityFunction
|
|
166
|
+
def locate_token_store
|
|
167
|
+
config = ENV.fetch('XDG_CONFIG_HOME', File.join(Dir.home, '.config'))
|
|
168
|
+
File.join(config, 'teems', 'tokens.json')
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# :reek:TooManyStatements :reek:UncommunicativeVariableName
|
|
172
|
+
def parse_headless_result(output)
|
|
173
|
+
parsed = JSON.parse(output.strip)
|
|
174
|
+
return nil unless parsed['auth_token']
|
|
175
|
+
|
|
176
|
+
build_headless_tokens(parsed)
|
|
177
|
+
rescue JSON::ParserError => e
|
|
178
|
+
log("Failed to parse headless result: #{e.message}")
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# :reek:FeatureEnvy
|
|
183
|
+
def build_headless_tokens(parsed)
|
|
184
|
+
spaces_token = parsed['skype_spaces_token']
|
|
185
|
+
|
|
186
|
+
{ auth_token: parsed['auth_token'], skype_token: exchange_skype_via_http(spaces_token),
|
|
187
|
+
skype_spaces_token: spaces_token, chatsvc_token: nil,
|
|
188
|
+
refresh_token: parsed['refresh_token'], client_id: parsed['client_id'], tenant_id: parsed['tenant_id'] }
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'uri'
|
|
8
|
+
|
|
9
|
+
module Teems
|
|
10
|
+
module Services
|
|
11
|
+
# Builds OAuth authorize URLs for the Teams app registration
|
|
12
|
+
module OAuthUrlBuilder
|
|
13
|
+
TEAMS_APP_ID = '5e3ce6c0-2b1f-4285-8d4b-75ee78787346'
|
|
14
|
+
REDIRECT_URI = 'https://teams.microsoft.com/go'
|
|
15
|
+
AUTHORIZE_ENDPOINT = 'https://login.microsoftonline.com/%s/oauth2/authorize'
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def build_authorize_url(context, response_type, **opts)
|
|
20
|
+
params = base_authorize_params(response_type)
|
|
21
|
+
apply_optional_params(params, context[:hint], opts)
|
|
22
|
+
build_authorize_uri(context[:tenant], params)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def apply_optional_params(params, hint, opts)
|
|
26
|
+
resource = opts[:resource]
|
|
27
|
+
pkce = opts[:pkce]
|
|
28
|
+
params['resource'] = resource if resource
|
|
29
|
+
add_login_hints(params, hint)
|
|
30
|
+
add_pkce_params(params, pkce) if pkce
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def base_authorize_params(response_type)
|
|
34
|
+
nonces = Array.new(2) { SecureRandom.uuid }
|
|
35
|
+
{ 'response_type' => response_type, 'client_id' => TEAMS_APP_ID,
|
|
36
|
+
'redirect_uri' => REDIRECT_URI, 'state' => nonces[0], 'nonce' => nonces[1] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def add_login_hints(params, hint)
|
|
40
|
+
return unless hint
|
|
41
|
+
|
|
42
|
+
params['login_hint'] = hint
|
|
43
|
+
domain = hint.split('@').last
|
|
44
|
+
params['domain_hint'] = domain if domain
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def add_pkce_params(params, pkce)
|
|
48
|
+
params['code_challenge'] = pkce[:challenge]
|
|
49
|
+
params['code_challenge_method'] = 'S256'
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_authorize_uri(tenant, params)
|
|
53
|
+
uri = URI(format(AUTHORIZE_ENDPOINT, tenant))
|
|
54
|
+
uri.query = URI.encode_www_form(params)
|
|
55
|
+
uri.to_s
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# PKCE helpers and Graph authorization code exchange
|
|
60
|
+
module OAuthCodeExchange
|
|
61
|
+
TOKEN_ENDPOINT = 'https://login.microsoftonline.com/%s/oauth2/token'
|
|
62
|
+
GRAPH_RESOURCE = 'https://graph.microsoft.com'
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def generate_pkce
|
|
67
|
+
verifier = base64url(SecureRandom.random_bytes(32))
|
|
68
|
+
challenge = base64url(Digest::SHA256.digest(verifier))
|
|
69
|
+
{ verifier: verifier, challenge: challenge }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def base64url(data) = [data].pack('m0').tr('+/', '-_').delete('=')
|
|
73
|
+
|
|
74
|
+
def exchange_graph_code(exchange)
|
|
75
|
+
response = post_token_exchange(exchange)
|
|
76
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
77
|
+
|
|
78
|
+
JSON.parse(response.body)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
log("Graph code exchange failed: #{e.message}")
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def post_token_exchange(exchange)
|
|
85
|
+
uri = URI(format(TOKEN_ENDPOINT, exchange[:tenant]))
|
|
86
|
+
post = build_exchange_request(uri, exchange)
|
|
87
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true,
|
|
88
|
+
open_timeout: 10, read_timeout: 30) { |http| http.request(post) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_exchange_request(uri, exchange)
|
|
92
|
+
Net::HTTP::Post.new(uri, 'Content-Type' => 'application/x-www-form-urlencoded',
|
|
93
|
+
'Origin' => 'https://teams.microsoft.com').tap do |post|
|
|
94
|
+
post.body = token_exchange_body(exchange)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def token_exchange_body(exchange)
|
|
99
|
+
URI.encode_www_form(
|
|
100
|
+
'grant_type' => 'authorization_code', 'client_id' => OAuthUrlBuilder::TEAMS_APP_ID,
|
|
101
|
+
'code' => exchange[:code], 'redirect_uri' => OAuthUrlBuilder::REDIRECT_URI,
|
|
102
|
+
'resource' => GRAPH_RESOURCE, 'code_verifier' => exchange[:verifier]
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def decode_jwt(token)
|
|
107
|
+
payload = token.split('.')[1]
|
|
108
|
+
return nil unless payload
|
|
109
|
+
|
|
110
|
+
padded = payload.tr('-_', '+/').ljust((payload.length + 3) & ~3, '=')
|
|
111
|
+
JSON.parse(padded.unpack1('m'))
|
|
112
|
+
rescue StandardError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Polls Safari's URL bar via AppleScript and manages Safari tab navigation
|
|
118
|
+
module SafariOAuthPolling
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def poll_safari_query_redirect
|
|
122
|
+
raw = run_applescript(poll_query_redirect_script).to_s
|
|
123
|
+
if raw.empty? || raw == 'timeout'
|
|
124
|
+
log('Safari OAuth: fast capture missed, falling back')
|
|
125
|
+
return nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
log('Safari OAuth: captured authorization code')
|
|
129
|
+
parse_oauth_params(raw)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Poll for ?code= in the URL — Safari's URL property includes query strings
|
|
133
|
+
def poll_query_redirect_script
|
|
134
|
+
<<~APPLESCRIPT
|
|
135
|
+
tell application "Safari"
|
|
136
|
+
repeat 600 times
|
|
137
|
+
try
|
|
138
|
+
set pageURL to URL of current tab of front window
|
|
139
|
+
if pageURL contains "teams.microsoft.com/go?" then
|
|
140
|
+
set codeStart to offset of "?" in pageURL
|
|
141
|
+
return text (codeStart + 1) thru -1 of pageURL
|
|
142
|
+
end if
|
|
143
|
+
end try
|
|
144
|
+
delay 0.02
|
|
145
|
+
end repeat
|
|
146
|
+
return "timeout"
|
|
147
|
+
end tell
|
|
148
|
+
APPLESCRIPT
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def parse_oauth_params(raw)
|
|
152
|
+
raw.split('&').each_with_object({}) do |pair, hash|
|
|
153
|
+
key, value = pair.split('=', 2)
|
|
154
|
+
hash[key] = URI.decode_www_form_component(value.to_s)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def open_safari_to(url)
|
|
159
|
+
run_applescript(open_safari_tab_script(url))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def open_safari_tab_script(url)
|
|
163
|
+
<<~APPLESCRIPT
|
|
164
|
+
tell application "Safari"
|
|
165
|
+
activate
|
|
166
|
+
if (count of windows) = 0 then
|
|
167
|
+
make new document with properties {URL:"#{url}"}
|
|
168
|
+
else
|
|
169
|
+
tell front window
|
|
170
|
+
set current tab to (make new tab with properties {URL:"#{url}"})
|
|
171
|
+
end tell
|
|
172
|
+
end if
|
|
173
|
+
end tell
|
|
174
|
+
APPLESCRIPT
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def navigate_safari_to(url)
|
|
178
|
+
run_applescript(<<~APPLESCRIPT)
|
|
179
|
+
tell application "Safari"
|
|
180
|
+
set URL of current tab of front window to "#{url}"
|
|
181
|
+
end tell
|
|
182
|
+
APPLESCRIPT
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Single-hop Safari OAuth: one authorization code request via Safari (SSO Extension
|
|
187
|
+
# handles device auth), then HTTP exchanges for remaining tokens. Uses query string
|
|
188
|
+
# (?code=...) which Safari preserves, unlike fragments (#token=...) which get lost.
|
|
189
|
+
module SafariOAuth
|
|
190
|
+
include OAuthUrlBuilder
|
|
191
|
+
include OAuthCodeExchange
|
|
192
|
+
include SafariOAuthPolling
|
|
193
|
+
|
|
194
|
+
SKYPE_RESOURCE = 'https://api.spaces.skype.com'
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def try_safari_oauth
|
|
199
|
+
hint, tenant = stored_login_hint
|
|
200
|
+
return nil unless tenant
|
|
201
|
+
|
|
202
|
+
log('Trying fast Safari OAuth flow...')
|
|
203
|
+
safari_code_flow({ tenant: tenant, hint: hint })
|
|
204
|
+
rescue StandardError => e
|
|
205
|
+
log("Safari OAuth error: #{e.class}: #{e.message}")
|
|
206
|
+
nil
|
|
207
|
+
ensure
|
|
208
|
+
close_teams_tab if tenant
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def safari_code_flow(context)
|
|
212
|
+
graph = fetch_graph_via_safari(context)
|
|
213
|
+
return nil unless graph
|
|
214
|
+
|
|
215
|
+
skype = fetch_skype_via_refresh(graph, context[:tenant])
|
|
216
|
+
return nil unless skype
|
|
217
|
+
|
|
218
|
+
log('Safari OAuth: all tokens acquired!')
|
|
219
|
+
assemble_safari_result(context, graph, skype)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def fetch_graph_via_safari(context)
|
|
223
|
+
pkce = generate_pkce
|
|
224
|
+
url = build_authorize_url(context, 'code', resource: GRAPH_RESOURCE, pkce: pkce)
|
|
225
|
+
log('Safari OAuth: requesting authorization code...')
|
|
226
|
+
open_safari_to(url)
|
|
227
|
+
params = poll_safari_query_redirect
|
|
228
|
+
return nil unless params&.dig('code')
|
|
229
|
+
|
|
230
|
+
log('Safari OAuth: exchanging code for Graph tokens...')
|
|
231
|
+
exchange_graph_code(code: params['code'], verifier: pkce[:verifier], tenant: context[:tenant])
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def fetch_skype_via_refresh(graph, tenant)
|
|
235
|
+
graph_rt = graph['refresh_token']
|
|
236
|
+
return nil unless graph_rt
|
|
237
|
+
|
|
238
|
+
log('Safari OAuth: refreshing for Skype token...')
|
|
239
|
+
result = refresh_for_resource(token: graph_rt, tenant: tenant, resource: SKYPE_RESOURCE)
|
|
240
|
+
return nil unless result
|
|
241
|
+
|
|
242
|
+
{ spaces_token: result['access_token'], refresh_token: result['refresh_token'] }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def refresh_for_resource(grant)
|
|
246
|
+
response = post_refresh_request(grant)
|
|
247
|
+
return log_refresh_error(response) unless response.is_a?(Net::HTTPSuccess)
|
|
248
|
+
|
|
249
|
+
JSON.parse(response.body)
|
|
250
|
+
rescue StandardError => e
|
|
251
|
+
log("Token refresh failed: #{e.class}: #{e.message}")
|
|
252
|
+
nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def log_refresh_error(response)
|
|
256
|
+
log("Token refresh failed: HTTP #{response.code}")
|
|
257
|
+
nil
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# :reek:FeatureEnvy
|
|
261
|
+
def post_refresh_request(grant)
|
|
262
|
+
uri = URI(format(OAuthCodeExchange::TOKEN_ENDPOINT, grant[:tenant]))
|
|
263
|
+
post = Net::HTTP::Post.new(uri, 'Content-Type' => 'application/x-www-form-urlencoded',
|
|
264
|
+
'Origin' => 'https://teams.microsoft.com')
|
|
265
|
+
post.body = URI.encode_www_form('grant_type' => 'refresh_token',
|
|
266
|
+
'client_id' => OAuthUrlBuilder::TEAMS_APP_ID,
|
|
267
|
+
'refresh_token' => grant[:token],
|
|
268
|
+
'resource' => grant[:resource])
|
|
269
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true,
|
|
270
|
+
open_timeout: 10, read_timeout: 30) { |http| http.request(post) }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# :reek:FeatureEnvy
|
|
274
|
+
def assemble_safari_result(context, graph, skype)
|
|
275
|
+
spaces_token = skype[:spaces_token]
|
|
276
|
+
{ auth_token: graph['access_token'],
|
|
277
|
+
skype_token: exchange_skype_via_http(spaces_token),
|
|
278
|
+
skype_spaces_token: spaces_token, chatsvc_token: nil,
|
|
279
|
+
refresh_token: skype[:refresh_token],
|
|
280
|
+
client_id: OAuthUrlBuilder::TEAMS_APP_ID,
|
|
281
|
+
tenant_id: context[:tenant] }
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Services
|
|
5
|
+
# Directory naming helpers for SyncStore
|
|
6
|
+
module SyncDirNaming
|
|
7
|
+
GENERIC_LABELS = ['Group Chat', '1:1 Chat', 'Meeting Chat', 'Channel', 'Space'].freeze
|
|
8
|
+
MAX_DIR_NAME_LENGTH = 100
|
|
9
|
+
TYPE_DIRS = {
|
|
10
|
+
'oneOnOne' => 'dms', 'group' => 'groups', 'meeting' => 'meetings',
|
|
11
|
+
'channel' => 'channels', 'space' => 'spaces'
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def type_dir(chat_type) = TYPE_DIRS[chat_type] || 'other'
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def sanitize_id(id)
|
|
21
|
+
id.gsub(/[:@]/, '_')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def sanitize_display_name(name)
|
|
25
|
+
return nil if name.to_s.strip.empty?
|
|
26
|
+
|
|
27
|
+
sanitized = name.strip.gsub(%r{[/\\:*?"<>|]}, '-').gsub(/\s+/, ' ')
|
|
28
|
+
sanitized = sanitized[0, MAX_DIR_NAME_LENGTH].gsub(/[\s.]+\z/, '')
|
|
29
|
+
sanitized.empty? ? nil : sanitized
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build_dir_name(chat_id, display_name)
|
|
33
|
+
sanitized = sanitize_display_name(display_name)
|
|
34
|
+
safe_id = sanitize_id(chat_id)
|
|
35
|
+
return safe_id unless sanitized
|
|
36
|
+
return sanitized unless GENERIC_LABELS.include?(display_name.strip)
|
|
37
|
+
|
|
38
|
+
"#{sanitized} (#{safe_id[0, 20]})"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|