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,485 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import WebKit
|
|
3
|
+
import Security
|
|
4
|
+
import CommonCrypto
|
|
5
|
+
|
|
6
|
+
// Headless WKWebView token extractor for Microsoft Teams
|
|
7
|
+
// Uses OAuth2 implicit flow with redirect interception (inspired by fossteams/teams-token)
|
|
8
|
+
// Optional certificate auth from macOS Keychain for TLS client auth
|
|
9
|
+
// Outputs JSON to stdout, logs to stderr
|
|
10
|
+
// Exit codes: 0 = success, 1 = error, 2 = needs Safari
|
|
11
|
+
|
|
12
|
+
let TEAMS_APP_ID = "5e3ce6c0-2b1f-4285-8d4b-75ee78787346"
|
|
13
|
+
let SKYPE_RESOURCE = "https://api.spaces.skype.com"
|
|
14
|
+
let GRAPH_RESOURCE = "https://graph.microsoft.com"
|
|
15
|
+
let REDIRECT_URI = "https://teams.microsoft.com/go"
|
|
16
|
+
|
|
17
|
+
class TokenExtractor: NSObject, WKNavigationDelegate {
|
|
18
|
+
private let webView: WKWebView
|
|
19
|
+
private let timeoutSeconds: Int
|
|
20
|
+
private var started = Date()
|
|
21
|
+
private var loginPageCount = 0
|
|
22
|
+
private let loginPageThreshold = 30
|
|
23
|
+
|
|
24
|
+
// Collected tokens
|
|
25
|
+
private var teamsToken: String?
|
|
26
|
+
private var skypeToken: String?
|
|
27
|
+
private var graphToken: String?
|
|
28
|
+
private var refreshToken: String?
|
|
29
|
+
private var tenantId: String?
|
|
30
|
+
private var loginHint: String?
|
|
31
|
+
|
|
32
|
+
// PKCE for authorization code flow
|
|
33
|
+
private var codeVerifier: String?
|
|
34
|
+
|
|
35
|
+
private static let safariUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
|
|
36
|
+
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15"
|
|
37
|
+
|
|
38
|
+
private let useCertAuth: Bool
|
|
39
|
+
|
|
40
|
+
init(timeout: Int, loginHint: String?, tenantId: String?, certAuth: Bool = false) {
|
|
41
|
+
self.timeoutSeconds = timeout
|
|
42
|
+
self.loginHint = loginHint
|
|
43
|
+
self.tenantId = tenantId
|
|
44
|
+
self.useCertAuth = certAuth
|
|
45
|
+
let config = WKWebViewConfiguration()
|
|
46
|
+
config.websiteDataStore = WKWebsiteDataStore.default()
|
|
47
|
+
self.webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), configuration: config)
|
|
48
|
+
super.init()
|
|
49
|
+
self.webView.navigationDelegate = self
|
|
50
|
+
self.webView.customUserAgent = Self.safariUA
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func run() {
|
|
54
|
+
started = Date()
|
|
55
|
+
log("Starting OAuth flow...")
|
|
56
|
+
authorizeTeams()
|
|
57
|
+
|
|
58
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeoutSeconds)) { [weak self] in
|
|
59
|
+
self?.fail("Timeout after \(self?.timeoutSeconds ?? 0)s")
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// MARK: - OAuth flow
|
|
64
|
+
|
|
65
|
+
private func authorizeTeams() {
|
|
66
|
+
let tenant = tenantId ?? "common"
|
|
67
|
+
let url = buildAuthorizeURL(responseType: "id_token", tenant: tenant)
|
|
68
|
+
log("Requesting Teams id_token (tenant=\(tenant))...")
|
|
69
|
+
webView.load(URLRequest(url: url))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private func authorizeSkype() {
|
|
73
|
+
guard let tid = tenantId else {
|
|
74
|
+
fail("No tenant ID for Skype authorization")
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
let url = buildAuthorizeURL(responseType: "token", tenant: tid, resource: SKYPE_RESOURCE)
|
|
78
|
+
log("Requesting Skype access_token...")
|
|
79
|
+
webView.load(URLRequest(url: url))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private func authorizeGraph() {
|
|
83
|
+
guard let tid = tenantId else {
|
|
84
|
+
fail("No tenant ID for Graph authorization")
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
let verifier = generateCodeVerifier()
|
|
88
|
+
codeVerifier = verifier
|
|
89
|
+
let challenge = generateCodeChallenge(verifier)
|
|
90
|
+
let baseURL = buildAuthorizeURL(responseType: "code", tenant: tid, resource: GRAPH_RESOURCE)
|
|
91
|
+
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
|
|
92
|
+
fail("Failed to build Graph authorization URL")
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
components.queryItems?.append(URLQueryItem(name: "code_challenge", value: challenge))
|
|
96
|
+
components.queryItems?.append(URLQueryItem(name: "code_challenge_method", value: "S256"))
|
|
97
|
+
guard let url = components.url else {
|
|
98
|
+
fail("Failed to construct Graph authorization URL with PKCE")
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
log("Requesting Graph authorization code...")
|
|
102
|
+
webView.load(URLRequest(url: url))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private func generateCodeVerifier() -> String {
|
|
106
|
+
var bytes = [UInt8](repeating: 0, count: 32)
|
|
107
|
+
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
|
108
|
+
guard status == errSecSuccess else {
|
|
109
|
+
fail("Failed to generate secure random bytes (status: \(status))")
|
|
110
|
+
return ""
|
|
111
|
+
}
|
|
112
|
+
return Data(bytes).base64EncodedString()
|
|
113
|
+
.replacingOccurrences(of: "+", with: "-")
|
|
114
|
+
.replacingOccurrences(of: "/", with: "_")
|
|
115
|
+
.replacingOccurrences(of: "=", with: "")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private func generateCodeChallenge(_ verifier: String) -> String {
|
|
119
|
+
let data = verifier.data(using: .utf8)!
|
|
120
|
+
var hash = [UInt8](repeating: 0, count: 32)
|
|
121
|
+
data.withUnsafeBytes { CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash) }
|
|
122
|
+
return Data(hash).base64EncodedString()
|
|
123
|
+
.replacingOccurrences(of: "+", with: "-")
|
|
124
|
+
.replacingOccurrences(of: "/", with: "_")
|
|
125
|
+
.replacingOccurrences(of: "=", with: "")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private func buildAuthorizeURL(responseType: String, tenant: String, resource: String? = nil) -> URL {
|
|
129
|
+
var components = URLComponents(string: "https://login.microsoftonline.com/\(tenant)/oauth2/authorize")!
|
|
130
|
+
var items = [
|
|
131
|
+
URLQueryItem(name: "response_type", value: responseType),
|
|
132
|
+
URLQueryItem(name: "client_id", value: TEAMS_APP_ID),
|
|
133
|
+
URLQueryItem(name: "redirect_uri", value: REDIRECT_URI),
|
|
134
|
+
URLQueryItem(name: "state", value: UUID().uuidString),
|
|
135
|
+
URLQueryItem(name: "nonce", value: UUID().uuidString),
|
|
136
|
+
]
|
|
137
|
+
if let resource = resource {
|
|
138
|
+
items.append(URLQueryItem(name: "resource", value: resource))
|
|
139
|
+
}
|
|
140
|
+
if let hint = loginHint {
|
|
141
|
+
items.append(URLQueryItem(name: "login_hint", value: hint))
|
|
142
|
+
if let domain = hint.split(separator: "@").last {
|
|
143
|
+
items.append(URLQueryItem(name: "domain_hint", value: String(domain)))
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
components.queryItems = items
|
|
147
|
+
return components.url!
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// MARK: - WKNavigationDelegate
|
|
151
|
+
|
|
152
|
+
// Domains that are part of the normal OAuth/login flow
|
|
153
|
+
private static let allowedDomains = [
|
|
154
|
+
"login.microsoftonline.com",
|
|
155
|
+
"login.microsoft.com",
|
|
156
|
+
"login.live.com",
|
|
157
|
+
"device.login.microsoftonline.com",
|
|
158
|
+
"certauth.login.microsoftonline.com",
|
|
159
|
+
"teams.microsoft.com",
|
|
160
|
+
"aadcdn.msftauth.net",
|
|
161
|
+
"aadcdn.msauth.net",
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
private func isAllowedDomain(_ host: String) -> Bool {
|
|
165
|
+
Self.allowedDomains.contains(where: { host == $0 || host.hasSuffix(".\($0)") })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
func webView(_ webView: WKWebView,
|
|
169
|
+
decidePolicyFor navigationAction: WKNavigationAction,
|
|
170
|
+
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
171
|
+
guard let url = navigationAction.request.url, let host = url.host else {
|
|
172
|
+
decisionHandler(.allow)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let urlStr = url.absoluteString
|
|
177
|
+
if urlStr.hasPrefix(REDIRECT_URI + "#") || urlStr.hasPrefix(REDIRECT_URI + "?") {
|
|
178
|
+
decisionHandler(.cancel)
|
|
179
|
+
handleRedirect(url: url)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Detect Conditional Access / MDM enrollment redirects (e.g. portal.manage.microsoft.com,
|
|
184
|
+
// workspaceone) that WKWebView can't handle — fall back to Safari immediately.
|
|
185
|
+
if !isAllowedDomain(host) {
|
|
186
|
+
log("Redirected to \(host) (Conditional Access / MDM?) — Safari required")
|
|
187
|
+
decisionHandler(.cancel)
|
|
188
|
+
needsSafari()
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
decisionHandler(.allow)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func webView(_ webView: WKWebView,
|
|
196
|
+
didReceive challenge: URLAuthenticationChallenge,
|
|
197
|
+
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
|
198
|
+
let host = challenge.protectionSpace.host
|
|
199
|
+
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate {
|
|
200
|
+
log("Client cert requested from \(host)")
|
|
201
|
+
if useCertAuth, host.hasSuffix(".certauth.login.microsoftonline.com"),
|
|
202
|
+
let credential = findPIVCredential() {
|
|
203
|
+
completionHandler(.useCredential, credential)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
log("Skipping cert for \(host)")
|
|
207
|
+
}
|
|
208
|
+
completionHandler(.performDefaultHandling, nil)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
212
|
+
guard let url = webView.url?.absoluteString else { return }
|
|
213
|
+
|
|
214
|
+
if url.contains("login.microsoftonline.com") {
|
|
215
|
+
loginPageCount += 1
|
|
216
|
+
autoClickKMSI()
|
|
217
|
+
if loginPageCount >= loginPageThreshold {
|
|
218
|
+
needsSafari()
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
224
|
+
let code = (error as NSError).code
|
|
225
|
+
if code != 102 { // 102 = frame load interrupted (expected from cancel)
|
|
226
|
+
log("Load error (\(code)): \(error.localizedDescription)")
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// MARK: - Redirect handling
|
|
231
|
+
|
|
232
|
+
private func handleRedirect(url: URL) {
|
|
233
|
+
let fragmentParams = parseFragment(url.fragment ?? "")
|
|
234
|
+
let queryParams = parseFragment(url.query ?? "")
|
|
235
|
+
|
|
236
|
+
if let error = fragmentParams["error"] ?? queryParams["error"] {
|
|
237
|
+
let desc = (fragmentParams["error_description"] ?? queryParams["error_description"])?
|
|
238
|
+
.removingPercentEncoding ?? "unknown"
|
|
239
|
+
log("OAuth error: \(error) — \(desc)")
|
|
240
|
+
if error == "interaction_required" { needsSafari() } else { fail("OAuth error: \(error)") }
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if let idToken = fragmentParams["id_token"] {
|
|
245
|
+
handleTeamsToken(idToken)
|
|
246
|
+
} else if let accessToken = fragmentParams["access_token"] {
|
|
247
|
+
handleAccessToken(accessToken)
|
|
248
|
+
} else if let code = queryParams["code"] {
|
|
249
|
+
exchangeCodeForTokens(code)
|
|
250
|
+
} else {
|
|
251
|
+
log("Unrecognized redirect — no token, code, or error")
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private func handleTeamsToken(_ token: String) {
|
|
256
|
+
teamsToken = token
|
|
257
|
+
|
|
258
|
+
guard let payload = decodeJWT(token) else {
|
|
259
|
+
fail("Failed to decode Teams JWT")
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
tenantId = payload["tid"] as? String
|
|
264
|
+
loginHint = loginHint ?? (payload["upn"] as? String)
|
|
265
|
+
log("Got Teams token (tenant=\(tenantId ?? "?"), upn=\(loginHint ?? "?"))")
|
|
266
|
+
authorizeSkype()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private func handleAccessToken(_ token: String) {
|
|
270
|
+
guard let payload = decodeJWT(token) else { return }
|
|
271
|
+
let audience = payload["aud"] as? String ?? "unknown"
|
|
272
|
+
|
|
273
|
+
if audience == SKYPE_RESOURCE {
|
|
274
|
+
log("Got Skype spaces token")
|
|
275
|
+
skypeToken = token
|
|
276
|
+
authorizeGraph()
|
|
277
|
+
} else if audience == GRAPH_RESOURCE {
|
|
278
|
+
log("Got Graph access token")
|
|
279
|
+
graphToken = token
|
|
280
|
+
emitResult()
|
|
281
|
+
} else {
|
|
282
|
+
log("Unexpected token audience: \(audience)")
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// MARK: - Authorization code exchange
|
|
287
|
+
|
|
288
|
+
private func urlEncode(_ value: String) -> String {
|
|
289
|
+
value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? value
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private func exchangeCodeForTokens(_ code: String) {
|
|
293
|
+
guard let tid = tenantId else { fail("No tenant ID for code exchange"); return }
|
|
294
|
+
|
|
295
|
+
let tokenURL = URL(string: "https://login.microsoftonline.com/\(tid)/oauth2/token")!
|
|
296
|
+
var request = URLRequest(url: tokenURL)
|
|
297
|
+
request.httpMethod = "POST"
|
|
298
|
+
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
|
299
|
+
request.setValue("https://teams.microsoft.com", forHTTPHeaderField: "Origin")
|
|
300
|
+
|
|
301
|
+
var bodyParts = [
|
|
302
|
+
"grant_type=authorization_code",
|
|
303
|
+
"client_id=\(urlEncode(TEAMS_APP_ID))",
|
|
304
|
+
"code=\(urlEncode(code))",
|
|
305
|
+
"redirect_uri=\(urlEncode(REDIRECT_URI))",
|
|
306
|
+
"resource=\(urlEncode(GRAPH_RESOURCE))"
|
|
307
|
+
]
|
|
308
|
+
if let verifier = codeVerifier {
|
|
309
|
+
bodyParts.append("code_verifier=\(urlEncode(verifier))")
|
|
310
|
+
}
|
|
311
|
+
let body = bodyParts.joined(separator: "&")
|
|
312
|
+
request.httpBody = body.data(using: .utf8)
|
|
313
|
+
|
|
314
|
+
log("Exchanging authorization code for Graph tokens...")
|
|
315
|
+
URLSession.shared.dataTask(with: request) { [weak self] data, _, error in
|
|
316
|
+
DispatchQueue.main.async { self?.handleCodeExchangeResult(data, error) }
|
|
317
|
+
}.resume()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private func handleCodeExchangeResult(_ data: Data?, _ error: Error?) {
|
|
321
|
+
if let error = error { fail("Code exchange error: \(error.localizedDescription)"); return }
|
|
322
|
+
guard let data = data,
|
|
323
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
324
|
+
fail("Failed to parse code exchange response")
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if let err = json["error"] as? String {
|
|
329
|
+
fail("Code exchange failed: \(err) — \(json["error_description"] as? String ?? "")")
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
guard let accessToken = json["access_token"] as? String else {
|
|
334
|
+
fail("No access_token in code exchange response")
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
graphToken = accessToken
|
|
339
|
+
refreshToken = json["refresh_token"] as? String
|
|
340
|
+
log("Got Graph access token + refresh token")
|
|
341
|
+
emitResult()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// MARK: - Auto-click "Stay signed in?"
|
|
345
|
+
|
|
346
|
+
private func autoClickKMSI() {
|
|
347
|
+
webView.evaluateJavaScript("""
|
|
348
|
+
(function() {
|
|
349
|
+
var btn = document.getElementById('idSIButton9');
|
|
350
|
+
if (btn) { btn.click(); return 'clicked'; }
|
|
351
|
+
return null;
|
|
352
|
+
})()
|
|
353
|
+
""") { result, _ in
|
|
354
|
+
if let action = result as? String {
|
|
355
|
+
self.log("KMSI: \(action)")
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// MARK: - Result output
|
|
361
|
+
|
|
362
|
+
private func emitResult() {
|
|
363
|
+
var result: [String: String] = [
|
|
364
|
+
"client_id": TEAMS_APP_ID,
|
|
365
|
+
]
|
|
366
|
+
if let g = graphToken {
|
|
367
|
+
result["auth_token"] = g
|
|
368
|
+
} else if let t = teamsToken {
|
|
369
|
+
log("Warning: Graph token unavailable, falling back to Teams id_token")
|
|
370
|
+
result["auth_token"] = t
|
|
371
|
+
}
|
|
372
|
+
if let s = skypeToken { result["skype_spaces_token"] = s }
|
|
373
|
+
if let tid = tenantId { result["tenant_id"] = tid }
|
|
374
|
+
if let rt = refreshToken { result["refresh_token"] = rt }
|
|
375
|
+
|
|
376
|
+
guard let data = try? JSONSerialization.data(withJSONObject: result),
|
|
377
|
+
let json = String(data: data, encoding: .utf8) else {
|
|
378
|
+
fail("Failed to serialize result")
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
log("Success!")
|
|
383
|
+
print(json)
|
|
384
|
+
exit(0)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// MARK: - JWT decoding
|
|
388
|
+
|
|
389
|
+
private func decodeJWT(_ token: String) -> [String: Any]? {
|
|
390
|
+
let parts = token.split(separator: ".")
|
|
391
|
+
guard parts.count >= 2 else { return nil }
|
|
392
|
+
|
|
393
|
+
// JWT uses base64url encoding — convert to standard base64
|
|
394
|
+
var payload = String(parts[1])
|
|
395
|
+
.replacingOccurrences(of: "-", with: "+")
|
|
396
|
+
.replacingOccurrences(of: "_", with: "/")
|
|
397
|
+
while payload.count % 4 != 0 { payload += "=" }
|
|
398
|
+
|
|
399
|
+
guard let data = Data(base64Encoded: payload),
|
|
400
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
401
|
+
return nil
|
|
402
|
+
}
|
|
403
|
+
return json
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// MARK: - Helpers
|
|
407
|
+
|
|
408
|
+
private func parseFragment(_ fragment: String) -> [String: String] {
|
|
409
|
+
fragment.split(separator: "&").reduce(into: [:]) { result, pair in
|
|
410
|
+
let kv = pair.split(separator: "=", maxSplits: 1)
|
|
411
|
+
if kv.count == 2 {
|
|
412
|
+
result[String(kv[0])] = String(kv[1]).removingPercentEncoding ?? String(kv[1])
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private func findPIVCredential() -> URLCredential? {
|
|
418
|
+
let query: [String: Any] = [
|
|
419
|
+
kSecClass as String: kSecClassIdentity,
|
|
420
|
+
kSecReturnRef as String: true,
|
|
421
|
+
kSecMatchLimit as String: kSecMatchLimitAll
|
|
422
|
+
]
|
|
423
|
+
var result: AnyObject?
|
|
424
|
+
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
|
425
|
+
let identities = result as? [SecIdentity],
|
|
426
|
+
let identity = identities.first else { return nil }
|
|
427
|
+
|
|
428
|
+
return URLCredential(identity: identity, certificates: nil, persistence: .forSession)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private func log(_ message: String) {
|
|
432
|
+
let elapsed = String(format: "%.1f", Date().timeIntervalSince(started))
|
|
433
|
+
FileHandle.standardError.write("[\(elapsed)s] \(message)\n".data(using: .utf8)!)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private func needsSafari() {
|
|
437
|
+
log("Stuck on login — Safari required")
|
|
438
|
+
printJSON(["error": "needs_safari", "message": "No Entra ID session, Safari login required"])
|
|
439
|
+
exit(2)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private func fail(_ message: String) {
|
|
443
|
+
log("FAILED: \(message)")
|
|
444
|
+
printJSON(["error": message])
|
|
445
|
+
exit(1)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private func printJSON(_ dict: [String: String]) {
|
|
449
|
+
if let data = try? JSONSerialization.data(withJSONObject: dict),
|
|
450
|
+
let json = String(data: data, encoding: .utf8) {
|
|
451
|
+
print(json)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// MARK: - Main
|
|
457
|
+
|
|
458
|
+
var timeout = 90
|
|
459
|
+
var loginHint: String?
|
|
460
|
+
var tenantId: String?
|
|
461
|
+
var certAuth = false
|
|
462
|
+
var args = CommandLine.arguments.dropFirst()
|
|
463
|
+
|
|
464
|
+
while let arg = args.first {
|
|
465
|
+
args = args.dropFirst()
|
|
466
|
+
switch arg {
|
|
467
|
+
case "--timeout":
|
|
468
|
+
timeout = Int(args.first ?? "") ?? 90
|
|
469
|
+
args = args.dropFirst()
|
|
470
|
+
case "--login-hint":
|
|
471
|
+
loginHint = String(args.first ?? "")
|
|
472
|
+
args = args.dropFirst()
|
|
473
|
+
case "--tenant-id":
|
|
474
|
+
tenantId = String(args.first ?? "")
|
|
475
|
+
args = args.dropFirst()
|
|
476
|
+
case "--certauth":
|
|
477
|
+
certAuth = true
|
|
478
|
+
default:
|
|
479
|
+
break
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
let extractor = TokenExtractor(timeout: timeout, loginHint: loginHint, tenantId: tenantId, certAuth: certAuth)
|
|
484
|
+
extractor.run()
|
|
485
|
+
RunLoop.main.run()
|
metadata
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: teems
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Eric Boehs
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Read messages, list channels and chats from Microsoft Teams in the terminal.
|
|
13
|
+
Pure Ruby, no dependencies.
|
|
14
|
+
email:
|
|
15
|
+
- ericboehs@gmail.com
|
|
16
|
+
executables:
|
|
17
|
+
- teems
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.md
|
|
24
|
+
- bin/teems
|
|
25
|
+
- lib/teems.rb
|
|
26
|
+
- lib/teems/api/calendar.rb
|
|
27
|
+
- lib/teems/api/channels.rb
|
|
28
|
+
- lib/teems/api/chats.rb
|
|
29
|
+
- lib/teems/api/client.rb
|
|
30
|
+
- lib/teems/api/files.rb
|
|
31
|
+
- lib/teems/api/messages.rb
|
|
32
|
+
- lib/teems/api/users.rb
|
|
33
|
+
- lib/teems/api/users_mailbox.rb
|
|
34
|
+
- lib/teems/api/users_presence.rb
|
|
35
|
+
- lib/teems/cli.rb
|
|
36
|
+
- lib/teems/commands/activity.rb
|
|
37
|
+
- lib/teems/commands/auth.rb
|
|
38
|
+
- lib/teems/commands/base.rb
|
|
39
|
+
- lib/teems/commands/cal.rb
|
|
40
|
+
- lib/teems/commands/channels.rb
|
|
41
|
+
- lib/teems/commands/chats.rb
|
|
42
|
+
- lib/teems/commands/help.rb
|
|
43
|
+
- lib/teems/commands/messages.rb
|
|
44
|
+
- lib/teems/commands/ooo.rb
|
|
45
|
+
- lib/teems/commands/org.rb
|
|
46
|
+
- lib/teems/commands/status.rb
|
|
47
|
+
- lib/teems/commands/sync.rb
|
|
48
|
+
- lib/teems/commands/who.rb
|
|
49
|
+
- lib/teems/formatters/calendar_formatter.rb
|
|
50
|
+
- lib/teems/formatters/format_utils.rb
|
|
51
|
+
- lib/teems/formatters/markdown_formatter.rb
|
|
52
|
+
- lib/teems/formatters/message_formatter.rb
|
|
53
|
+
- lib/teems/formatters/output.rb
|
|
54
|
+
- lib/teems/models/account.rb
|
|
55
|
+
- lib/teems/models/channel.rb
|
|
56
|
+
- lib/teems/models/chat.rb
|
|
57
|
+
- lib/teems/models/duration.rb
|
|
58
|
+
- lib/teems/models/event.rb
|
|
59
|
+
- lib/teems/models/message.rb
|
|
60
|
+
- lib/teems/models/parsing.rb
|
|
61
|
+
- lib/teems/models/user.rb
|
|
62
|
+
- lib/teems/models/user_profile.rb
|
|
63
|
+
- lib/teems/runner.rb
|
|
64
|
+
- lib/teems/services/api_client.rb
|
|
65
|
+
- lib/teems/services/cache_store.rb
|
|
66
|
+
- lib/teems/services/configuration.rb
|
|
67
|
+
- lib/teems/services/file_downloader.rb
|
|
68
|
+
- lib/teems/services/headless_extract.rb
|
|
69
|
+
- lib/teems/services/safari_oauth.rb
|
|
70
|
+
- lib/teems/services/sync_dir_naming.rb
|
|
71
|
+
- lib/teems/services/sync_engine.rb
|
|
72
|
+
- lib/teems/services/sync_store.rb
|
|
73
|
+
- lib/teems/services/teams_url_parser.rb
|
|
74
|
+
- lib/teems/services/token_exchange_scripts.rb
|
|
75
|
+
- lib/teems/services/token_extractor.rb
|
|
76
|
+
- lib/teems/services/token_extractor_scripts.rb
|
|
77
|
+
- lib/teems/services/token_refresher.rb
|
|
78
|
+
- lib/teems/services/token_store.rb
|
|
79
|
+
- lib/teems/support/error_logger.rb
|
|
80
|
+
- lib/teems/support/help_formatter.rb
|
|
81
|
+
- lib/teems/support/timezone.rb
|
|
82
|
+
- lib/teems/support/xdg_paths.rb
|
|
83
|
+
- lib/teems/version.rb
|
|
84
|
+
- support/token_helper.swift
|
|
85
|
+
homepage: https://github.com/ericboehs/teems
|
|
86
|
+
licenses:
|
|
87
|
+
- MIT
|
|
88
|
+
metadata:
|
|
89
|
+
homepage_uri: https://github.com/ericboehs/teems
|
|
90
|
+
source_code_uri: https://github.com/ericboehs/teems
|
|
91
|
+
changelog_uri: https://github.com/ericboehs/teems/blob/main/CHANGELOG.md
|
|
92
|
+
rubygems_mfa_required: 'true'
|
|
93
|
+
rdoc_options: []
|
|
94
|
+
require_paths:
|
|
95
|
+
- lib
|
|
96
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: 3.2.0
|
|
101
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
102
|
+
requirements:
|
|
103
|
+
- - ">="
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '0'
|
|
106
|
+
requirements: []
|
|
107
|
+
rubygems_version: 3.6.9
|
|
108
|
+
specification_version: 4
|
|
109
|
+
summary: A command-line interface for Microsoft Teams
|
|
110
|
+
test_files: []
|