Skip to content

Commit fc05526

Browse files
authored
Merge pull request #147 from toddheasley/autoconfiguration-oauth
feat(account): OAuth button for account setup
2 parents 3ae3846 + 9456619 commit fc05526

11 files changed

Lines changed: 443 additions & 79 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import Foundation
2+
3+
extension Bundle {
4+
/// Read custom URL schemes declared in Info.plist
5+
public var schemes: [String] {
6+
guard let bundleURLType: [String: Any] = (object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]])?.first else {
7+
return []
8+
}
9+
return bundleURLType["CFBundleURLSchemes"] as? [String] ?? []
10+
}
11+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import Foundation
2+
3+
extension URLRequest {
4+
public static func token(_ request: OAuth2.Request, code: String) throws -> Self {
5+
guard
6+
var components: URLComponents = URLComponents(
7+
url: request.tokenURL(code),
8+
resolvingAgainstBaseURL: false
9+
), let httpBody: Data = components.percentEncodedQuery?.data(using: .utf8)
10+
else {
11+
throw URLError(.badURL)
12+
}
13+
components.queryItems = nil
14+
var request: Self = Self(url: components.url!)
15+
request.httpMethod = "POST"
16+
request.httpBody = httpBody
17+
return request
18+
}
19+
}

Feature/Sources/Autoconfiguration/Foundation/URLSession.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,9 @@ extension URLSession {
5353
return suffixList
5454
}
5555
}
56+
57+
extension URLSession {
58+
func token(_ request: OAuth2.Request, code: String) async throws -> String {
59+
fatalError()
60+
}
61+
}
Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,94 @@
11
import Foundation
22

33
public struct OAuth2: Decodable {
4+
public struct Request: Equatable {
5+
public let authURI: String
6+
public let tokenURI: String
7+
public let redirectURI: String
8+
public let responseType: String
9+
public let scope: [String]
10+
public let hosts: [String]
11+
public let clientID: String
12+
13+
public func authURL(hint: String? = nil) -> URL {
14+
var components: URLComponents = URLComponents(string: authURI)! // Validated during init
15+
components.queryItems = [
16+
URLQueryItem(name: "client_id", value: clientID),
17+
URLQueryItem(name: "redirect_uri", value: redirectURI),
18+
URLQueryItem(name: "response_type", value: responseType),
19+
URLQueryItem(name: "scope", value: scope.joined(separator: " "))
20+
]
21+
if let hint, !hint.isEmpty { // Prepopulate email address for specific user
22+
components.queryItems?.append(URLQueryItem(name: "login_hint", value: hint))
23+
}
24+
return components.url!
25+
}
26+
27+
public func tokenURL(_ code: String) -> URL {
28+
var components: URLComponents = URLComponents(string: tokenURI)! // Validated during init
29+
components.queryItems = [
30+
URLQueryItem(name: "client_id", value: clientID),
31+
URLQueryItem(name: "client_secret", value: ""),
32+
URLQueryItem(name: "redirect_uri", value: redirectURI),
33+
URLQueryItem(name: "grant_type", value: "authorization_code"),
34+
URLQueryItem(name: "code", value: code)
35+
]
36+
return components.url!
37+
}
38+
39+
public func matches(_ host: String) -> Bool {
40+
for _host in hosts {
41+
guard host.hasSuffix(_host) else { continue }
42+
return true
43+
}
44+
return false
45+
}
46+
47+
public init(authURI: String, tokenURI: String, redirectURI: String, responseType: String, scope: [String], clientID: String, hosts: [String] = []) throws {
48+
guard URL(string: authURI) != nil,
49+
URL(string: tokenURI) != nil, // Validate URI strings pass failable URL init
50+
!redirectURI.isEmpty,
51+
!scope.isEmpty, !(scope.first ?? "").isEmpty,
52+
!clientID.isEmpty
53+
else {
54+
throw URLError(.badURL)
55+
}
56+
self.authURI = authURI
57+
self.tokenURI = tokenURI
58+
self.redirectURI = redirectURI
59+
self.responseType = responseType
60+
self.scope = scope
61+
self.hosts = hosts
62+
self.clientID = clientID
63+
}
64+
65+
public init(_ oauth2: OAuth2, redirectURI: String, responseType: String, clientID: String) throws {
66+
try self.init(
67+
authURI: oauth2.authURL.absoluteString,
68+
tokenURI: oauth2.tokenURL.absoluteString,
69+
redirectURI: redirectURI,
70+
responseType: responseType,
71+
scope: oauth2.scope,
72+
clientID: clientID
73+
)
74+
}
75+
}
76+
477
public let authURL: URL
5-
public let issuer: String
6-
public let scope: String
778
public let tokenURL: URL
79+
public let scope: [String]
80+
public let issuer: String
81+
82+
// MARK: Decodable
83+
public init(from decoder: any Decoder) throws {
84+
let container: KeyedDecodingContainer = try decoder.container(keyedBy: Key.self)
85+
self.tokenURL = try container.decode(URL.self, forKey: .tokenURL)
86+
self.authURL = try container.decode(URL.self, forKey: .authURL)
87+
self.issuer = try container.decode(String.self, forKey: .issuer)
88+
self.scope = try container.decode(String.self, forKey: .scope).components(separatedBy: " ")
89+
}
90+
91+
private enum Key: CodingKey {
92+
case authURL, issuer, scope, tokenURL
93+
}
894
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
@testable import Autoconfiguration
2+
import Foundation
3+
import Testing
4+
5+
struct URLRequestTests {
6+
@Test func token() throws {
7+
let request: OAuth2.Request = try OAuth2.Request(
8+
authURI: "https://example.com/authorize",
9+
tokenURI: "https://example.com/token",
10+
redirectURI: "com.example:/oauth2redirect",
11+
responseType: "code",
12+
scope: [
13+
"mail-w"
14+
],
15+
clientID: "Cl13n+-ID",
16+
hosts: [
17+
"example.com",
18+
"examplemail.com"
19+
]
20+
)
21+
#expect(try URLRequest.token(request, code: "0123456789").httpMethod == "POST")
22+
#expect(
23+
try URLRequest.token(request, code: "0123456789").httpBody
24+
== "client_id=Cl13n+-ID&client_secret=&redirect_uri=com.example:/oauth2redirect&grant_type=authorization_code&code=0123456789"
25+
.data(using: .utf8)
26+
)
27+
#expect(try URLRequest.token(request, code: "0123456789").url == URL(string: "https://example.com/token"))
28+
}
29+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
@testable import Autoconfiguration
2+
import Foundation
3+
import Testing
4+
5+
struct OAuth2Tests {
6+
@Test func matches() throws {
7+
let request: OAuth2.Request = try OAuth2.Request(
8+
authURI: "https://example.com/authorize",
9+
tokenURI: "https://example.com/token",
10+
redirectURI: "com.example:/oauth2redirect",
11+
responseType: "code",
12+
scope: [
13+
"mail-w"
14+
],
15+
clientID: "Cl13n+-ID",
16+
hosts: [
17+
"example.com",
18+
"examplemail.com"
19+
]
20+
)
21+
#expect(request.matches("google.com") == false)
22+
#expect(request.matches("mail.example.com") == true)
23+
#expect(request.matches("examplemail.com") == true)
24+
#expect(request.matches("mail.com") == false)
25+
}
26+
@Test func decoderInit() throws {
27+
let oauth2: [OAuth2] = try JSONDecoder().decode([OAuth2].self, from: data)
28+
#expect(
29+
oauth2[0].scope == [
30+
"mail-w"
31+
])
32+
#expect(
33+
oauth2[1].scope == [
34+
"https://mail.google.com/",
35+
"https://www.googleapis.com/auth/contacts",
36+
"https://www.googleapis.com/auth/calendar",
37+
"https://www.googleapis.com/auth/carddav"
38+
])
39+
#expect(
40+
oauth2[2].scope == [
41+
"mail-w"
42+
])
43+
}
44+
}
45+
46+
// swift-format-ignore
47+
let data: Data = """
48+
[
49+
{
50+
"authURL": "https://api.login.aol.com/oauth2/request_auth",
51+
"issuer": "login.aol.com",
52+
"scope": "mail-w",
53+
"tokenURL": "https://api.login.aol.com/oauth2/get_token"
54+
},
55+
{
56+
"authURL": "https://accounts.google.com/o/oauth2/auth",
57+
"issuer": "accounts.google.com",
58+
"scope": "https://mail.google.com/ https://www.googleapis.com/auth/contacts https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/carddav",
59+
"tokenURL": "https://www.googleapis.com/oauth2/v3/token"
60+
},
61+
{
62+
"authURL": "https://api.login.yahoo.com/oauth2/request_auth",
63+
"issuer": "login.yahoo.com",
64+
"scope": "mail-w",
65+
"tokenURL": "https://api.login.yahoo.com/oauth2/get_token"
66+
}
67+
]
68+
""".data(using: .utf8)!

Thunderbird/Thunderbird/Account/AuthorizationView.swift

Lines changed: 0 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ struct AuthorizationView: View {
3232
}
3333
case .oAuth2:
3434
OAuthButton(username)
35-
.disabled(false)
3635
case .none:
3736
EmptyView()
3837
}
@@ -45,73 +44,3 @@ struct AuthorizationView: View {
4544
AuthorizationView($authorization, for: "example@thunderbird.net")
4645
.padding()
4746
}
48-
49-
struct OAuthButton: View {
50-
let emailAddress: EmailAddress
51-
52-
init(_ emailAddress: EmailAddress, oAuth: OAuth2? = nil) {
53-
self.emailAddress = emailAddress
54-
self.oAuth = oAuth
55-
}
56-
57-
@Environment(\.webAuthenticationSession) private var webAuthenticationSession
58-
@State private var oAuth: OAuth2?
59-
@State private var error: Error?
60-
61-
private func configureOAuth() async {
62-
do {
63-
let config: ClientConfig = try await URLSession.shared.autoconfig(emailAddress).config
64-
guard let oAuth: OAuth2 = config.oAuth2 else {
65-
throw URLError(.resourceUnavailable)
66-
}
67-
self.oAuth = oAuth
68-
} catch {
69-
self.error = error
70-
}
71-
}
72-
73-
// MARK: View
74-
var body: some View {
75-
Button(action: {
76-
Task {
77-
do {
78-
guard let oAuth else {
79-
throw URLError(.resourceUnavailable)
80-
}
81-
let url: URL = try await webAuthenticationSession.authenticate(
82-
using: oAuth.authURL,
83-
callback: .https(host: oAuth.tokenURL.host() ?? "", path: oAuth.tokenURL.path()),
84-
preferredBrowserSession: .shared,
85-
additionalHeaderFields: [:]
86-
)
87-
print(url.absoluteString)
88-
} catch {
89-
self.error = error
90-
}
91-
}
92-
}) {
93-
if let oAuth {
94-
Text("Sign in with \(oAuth.issuer)")
95-
} else if error != nil {
96-
Text("Loading OAuth Failed")
97-
} else {
98-
HStack {
99-
Text("Loading OAuth… ")
100-
ProgressView()
101-
}
102-
.task {
103-
// TODO: Debounce email address changes
104-
await configureOAuth()
105-
}
106-
}
107-
}
108-
.buttonStyle(.bordered)
109-
.tint(.blue)
110-
.disabled(oAuth == nil)
111-
}
112-
}
113-
114-
#Preview("OAuth Button") {
115-
OAuthButton("gmail.com")
116-
.padding()
117-
}

0 commit comments

Comments
 (0)