Skip to content

Commit 121f472

Browse files
authored
Show an card to prompt application password authentication (#24561)
* Update wordpress-rs * Add dot-org REST API via WP.com to `WordPressClient` * Add blog id to the `reauthentication` context So that the new application password can be added to the blog * Add API to accept an auto-discovery result * Ask users to grant the app an application password Only available when the "Application Passwords for self-hosted sites" feature flag is on. The feature flag is available as one of the "Experimental Features". * Prefer to directly access site content via dot-org REST API * Fix a unit test compiling issue * Fix a swiftlint issue * Fix a swiftlint issue * Fix a compiling issue in the Reader app * Prefer to use REST API to manage comments * Add a comment about why choosing WP.com API instead of REST API * Revert "Prefer to use REST API to manage comments" This reverts commit b23d2a4. * Add a comment about use WP.com API for comments
1 parent 3329b59 commit 121f472

14 files changed

+297
-55
lines changed

Modules/Package.resolved

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Modules/Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ let package = Package(
5454
),
5555
.package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"),
5656
// We can't use wordpress-rs branches nor commits here. Only tags work.
57-
.package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20250520"),
57+
.package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20250523"),
5858
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", revision: "fdfe788530bbff864ce7147b5a68608d7025e078"),
5959
.package(
6060
url: "https://github.com/Automattic/color-studio",

Sources/WordPressData/Swift/Blog+SelfHosted.swift

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,18 @@ public extension Blog {
2020
with details: WpApiApplicationPasswordDetails,
2121
restApiRootURL: URL,
2222
xmlrpcEndpointURL: URL,
23+
blogID: TaggedManagedObjectID<Blog>?,
2324
in contextManager: ContextManager,
2425
using keychainImplementation: KeychainAccessible = KeychainUtils()
2526
) async throws -> TaggedManagedObjectID<Blog> {
2627
try await contextManager.performAndSave { context in
27-
let blog = Blog.lookup(username: details.userLogin, xmlrpc: xmlrpcEndpointURL.absoluteString, in: context)
28-
?? Blog.createBlankBlog(in: context)
28+
let blog = if let blogID {
29+
try context.existingObject(with: blogID)
30+
} else {
31+
Blog.lookup(username: details.userLogin, xmlrpc: xmlrpcEndpointURL.absoluteString, in: context)
32+
?? Blog.createBlankBlog(in: context)
33+
}
34+
2935
blog.url = details.siteUrl
3036
blog.username = details.userLogin
3137
blog.restApiRootURL = restApiRootURL.absoluteString
@@ -183,13 +189,41 @@ public enum WordPressSite {
183189
case selfHosted(blogId: TaggedManagedObjectID<Blog>, apiRootURL: ParsedUrl, username: String, authToken: String)
184190

185191
public init(blog: Blog) throws {
186-
if let _ = blog.account {
187-
// WP.com support is not ready yet.
188-
throw NSError(domain: "WordPressAPI", code: 0)
192+
// Directly access the site content when available.
193+
if let restApiRootURL = blog.restApiRootURL,
194+
let restApiRootURL = try? ParsedUrl.parse(input: restApiRootURL),
195+
let username = blog.username,
196+
let authToken = try? blog.getApplicationToken() {
197+
self = .selfHosted(blogId: TaggedManagedObjectID(blog), apiRootURL: restApiRootURL, username: username, authToken: authToken)
198+
} else if let account = blog.account, let siteId = blog.dotComID?.intValue {
199+
// When the site is added via a WP.com account, access the site via WP.com
200+
let authToken = try account.authToken ?? WPAccount.token(forUsername: account.username)
201+
self = .dotCom(siteId: siteId, authToken: authToken)
189202
} else {
203+
// In theory, this branch should never run, because the two if statements above should have covered all paths.
204+
// But we'll keep it here as the fallback.
190205
let url = try blog.restApiRootURL ?? blog.getUrl().appending(path: "wp-json").absoluteString
191206
let apiRootURL = try ParsedUrl.parse(input: url)
192207
self = .selfHosted(blogId: TaggedManagedObjectID(blog), apiRootURL: apiRootURL, username: try blog.getUsername(), authToken: try blog.getApplicationToken())
193208
}
194209
}
210+
211+
public static func throughDotCom(blog: Blog) -> Self? {
212+
guard
213+
let account = blog.account,
214+
let siteId = blog.dotComID?.intValue,
215+
let authToken = try? account.authToken ?? WPAccount.token(forUsername: account.username)
216+
else { return nil }
217+
218+
return .dotCom(siteId: siteId, authToken: authToken)
219+
}
220+
221+
public func blog(in context: NSManagedObjectContext) throws -> Blog? {
222+
switch self {
223+
case let .dotCom(siteId, _):
224+
return try Blog.lookup(withID: siteId, in: context)
225+
case let .selfHosted(blogId, _, _, _):
226+
return try context.existingObject(with: blogId)
227+
}
228+
}
195229
}

Tests/KeystoneTests/Tests/Models/Blog+RestAPITests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ final class Blog_RestAPITests: CoreDataTestCase {
1919
with: loginDetails,
2020
restApiRootURL: URL(string: "https://example.com/wp-json")!,
2121
xmlrpcEndpointURL: URL(string: "https://example.com/dir/xmlrpc.php")!,
22+
blogID: nil,
2223
in: contextManager,
2324
using: testKeychain
2425
)

WordPress/Classes/Login/ApplicationPasswordReAuthenticationView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ struct ApplicationPasswordReAuthenticationView: View {
3737
.signIn(
3838
site: blog.getUrl().absoluteString,
3939
from: presenter,
40-
context: .reauthentication(username: blog.getUsername())
40+
context: .reauthentication(TaggedManagedObjectID(blog), username: blog.getUsername())
4141
)
4242

4343
// Automatically dismiss this view upon a successful re-authentication.

WordPress/Classes/Login/ApplicationPasswordRequiredView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ struct ApplicationPasswordRequiredView<Content: View>: View {
4545
do {
4646
// Get an application password for the given site.
4747
let authenticator = SelfHostedSiteAuthenticator()
48-
let blogID = try await authenticator.signIn(site: url, from: presenter, context: .reauthentication(username: blog.username))
48+
let blogID = try await authenticator.signIn(site: url, from: presenter, context: .reauthentication(TaggedManagedObjectID(blog), username: blog.username))
4949

5050
// Modify the `site` variable to display the intended feature.
5151
let blog = try ContextManager.shared.mainContext.existingObject(with: blogID)

WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,16 @@ struct SelfHostedSiteAuthenticator {
1616
// Sign in to a self-hosted site. Using this context results in automatically reloading the app to display the site dashboard.
1717
case `default`
1818
// Sign in to a site that's alredy added to the app. This is typically used when the app needs to get a new application password.
19-
case reauthentication(username: String?)
19+
case reauthentication(TaggedManagedObjectID<Blog>, username: String?)
20+
21+
var blogID: TaggedManagedObjectID<Blog>? {
22+
switch self {
23+
case .default:
24+
return nil
25+
case let .reauthentication(blogID, _):
26+
return blogID
27+
}
28+
}
2029
}
2130

2231
private static let callbackURL = URL(string: "x-wordpress-app://login-callback")!
@@ -78,30 +87,32 @@ struct SelfHostedSiteAuthenticator {
7887

7988
@MainActor
8089
func signIn(site: String, from viewController: UIViewController, context: SignInContext) async throws(SignInError) -> TaggedManagedObjectID<Blog> {
90+
let details: AutoDiscoveryAttemptSuccess
8191
do {
82-
let result = try await _signIn(site: site, from: viewController, context: context)
83-
trackSuccess(url: site)
84-
return result
92+
details = try await internalClient.details(ofSite: site)
8593
} catch {
86-
trackTypedError(error, url: site)
87-
throw error
94+
trackTypedError(.authentication(error), url: site)
95+
throw .authentication(error)
8896
}
97+
98+
return try await signIn(details: details, from: viewController, context: context)
8999
}
90100

91101
@MainActor
92-
private func _signIn(site: String, from viewController: UIViewController, context: SignInContext) async throws(SignInError) -> TaggedManagedObjectID<Blog> {
102+
func signIn(details: AutoDiscoveryAttemptSuccess, from viewController: UIViewController, context: SignInContext) async throws(SignInError) -> TaggedManagedObjectID<Blog> {
93103
do {
94-
let (apiRootURL, credentials) = try await authenticate(site: site, from: viewController)
95-
return try await handle(credentials: credentials, apiRootURL: apiRootURL, context: context)
96-
} catch let error as SignInError {
97-
throw error
104+
let (apiRootURL, credentials) = try await authenticate(details: details, from: viewController)
105+
let result = try await handle(credentials: credentials, apiRootURL: apiRootURL, context: context)
106+
trackSuccess(url: details.parsedSiteUrl.url())
107+
return result
98108
} catch {
99-
throw .authentication(error)
109+
trackTypedError(error, url: details.parsedSiteUrl.url())
110+
throw error
100111
}
101112
}
102113

103114
@MainActor
104-
private func authenticate(site: String, from viewController: UIViewController) async throws -> (apiRootURL: URL, credentials: WpApiApplicationPasswordDetails) {
115+
private func authenticate(details: AutoDiscoveryAttemptSuccess, from viewController: UIViewController) async throws(SignInError) -> (apiRootURL: URL, credentials: WpApiApplicationPasswordDetails) {
105116
let appId: WpUuid
106117
let appName: String
107118

@@ -117,10 +128,13 @@ struct SelfHostedSiteAuthenticator {
117128
let timestamp = ISO8601DateFormatter.string(from: .now, timeZone: .current, formatOptions: .withInternetDateTime)
118129
let appNameValue = "\(appName) - \(deviceName) (\(timestamp))"
119130

120-
let details = try await internalClient.details(ofSite: site)
121-
let loginURL = try details.loginURL(for: .init(id: appId, name: appNameValue, callbackUrl: SelfHostedSiteAuthenticator.callbackURL.absoluteString))
122-
let callback = try await authorize(url: loginURL, callbackURL: SelfHostedSiteAuthenticator.callbackURL, from: viewController)
123-
return (details.apiRootUrl.asURL(), try internalClient.credentials(from: callback))
131+
do {
132+
let loginURL = details.loginURL(for: .init(id: appId, name: appNameValue, callbackUrl: SelfHostedSiteAuthenticator.callbackURL.absoluteString))
133+
let callback = try await authorize(url: loginURL, callbackURL: SelfHostedSiteAuthenticator.callbackURL, from: viewController)
134+
return (details.apiRootUrl.asURL(), try internalClient.credentials(from: callback))
135+
} catch {
136+
throw .authentication(error)
137+
}
124138
}
125139

126140
@MainActor
@@ -150,7 +164,7 @@ struct SelfHostedSiteAuthenticator {
150164
SVProgressHUD.dismiss()
151165
}
152166

153-
if case let .reauthentication(username) = context, let username, username != credentials.userLogin {
167+
if case let .reauthentication(_, username) = context, let username, username != credentials.userLogin {
154168
throw .mismatchedUser(expectedUsername: username)
155169
}
156170

@@ -165,7 +179,13 @@ struct SelfHostedSiteAuthenticator {
165179
// Only store the new site after credentials are validated.
166180
let blog: TaggedManagedObjectID<Blog>
167181
do {
168-
blog = try await Blog.createRestApiBlog(with: credentials, restApiRootURL: apiRootURL, xmlrpcEndpointURL: xmlrpc, in: ContextManager.shared)
182+
blog = try await Blog.createRestApiBlog(
183+
with: credentials,
184+
restApiRootURL: apiRootURL,
185+
xmlrpcEndpointURL: xmlrpc,
186+
blogID: context.blogID,
187+
in: ContextManager.shared
188+
)
169189
} catch {
170190
throw .savingSiteFailure
171191
}

WordPress/Classes/Networking/WordPressClient.swift

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,28 @@ extension WordPressClient {
2121
// rather than using the shared one on disk).
2222
let session = URLSession(configuration: .ephemeral)
2323

24+
let notifier = AppNotifier()
25+
let provider = WpAuthenticationProvider.dynamic(
26+
dynamicAuthenticationProvider: AutoUpdateAuthenticationProvider(site: site, coreDataStack: ContextManager.shared)
27+
)
28+
let apiRootURL: ParsedUrl
29+
let resolver: ApiUrlResolver
2430
switch site {
25-
case let .dotCom(siteId, authToken):
26-
let apiRootURL = try! ParsedUrl.parse(input: "https://public-api.wordpress.com/wpcom/v2/site/\(siteId)")
27-
let api = WordPressAPI(urlSession: session, apiRootUrl: apiRootURL, authentication: .bearer(token: authToken))
28-
self.init(api: api, rootUrl: apiRootURL)
29-
case let .selfHosted(blogId, apiRootURL, username, authToken):
30-
let provider = AutoUpdateAuthenticationProvider(
31-
authentication: .init(username: username, password: authToken),
32-
blogId: blogId,
33-
coreDataStack: ContextManager.shared
34-
)
35-
let notifier = AppNotifier()
36-
let api = WordPressAPI(urlSession: session, apiRootUrl: apiRootURL, authenticationProvider: .dynamic(dynamicAuthenticationProvider: provider), appNotifier: notifier)
37-
notifier.api = api
38-
self.init(api: api, rootUrl: apiRootURL)
31+
case let .dotCom(siteId, _):
32+
apiRootURL = try! ParsedUrl.parse(input: "https://public-api.wordpress.com/wp/v2/site/\(siteId)")
33+
resolver = WpComDotOrgApiUrlResolver(siteUrl: "\(siteId)")
34+
case let .selfHosted(_, url, _, _):
35+
apiRootURL = url
36+
resolver = WpOrgSiteApiUrlResolver(apiRootUrl: url)
3937
}
38+
let api = WordPressAPI(
39+
urlSession: session,
40+
apiUrlResolver: resolver,
41+
authenticationProvider: provider,
42+
appNotifier: notifier
43+
)
44+
notifier.api = api
45+
self.init(api: api, rootUrl: apiRootURL)
4046
}
4147

4248
func installJetpack() async throws -> PluginWithEditContext {
@@ -57,23 +63,44 @@ extension PluginWpOrgDirectorySlug: @retroactive ExpressibleByStringLiteral {
5763

5864
private final class AutoUpdateAuthenticationProvider: @unchecked Sendable, WpDynamicAuthenticationProvider {
5965
private let lock = NSLock()
66+
private let site: WordPressSite
67+
private let coreDataStack: CoreDataStack
6068
private var authentication: WpAuthentication
6169
private var cancellable: AnyCancellable?
6270

63-
init(authentication: WpAuthentication, blogId: TaggedManagedObjectID<Blog>, coreDataStack: CoreDataStack) {
64-
self.authentication = authentication
71+
init(site: WordPressSite, coreDataStack: CoreDataStack) {
72+
self.site = site
73+
self.coreDataStack = coreDataStack
74+
self.authentication = switch site {
75+
case let .dotCom(_, authToken):
76+
.bearer(token: authToken)
77+
case let .selfHosted(_, _, username, authToken):
78+
.init(username: username, password: authToken)
79+
}
80+
6581
self.cancellable = NotificationCenter.default.publisher(for: SelfHostedSiteAuthenticator.applicationPasswordUpdated).sink { [weak self] _ in
66-
guard let self else { return }
82+
self?.update()
83+
}
84+
}
6785

68-
self.lock.lock()
69-
defer {
70-
self.lock.unlock()
71-
}
86+
func update() {
87+
self.lock.lock()
88+
defer {
89+
self.lock.unlock()
90+
}
7291

73-
self.authentication = coreDataStack.performQuery { context in
92+
self.authentication = coreDataStack.performQuery { [site] context in
93+
switch site {
94+
case let .dotCom(siteId, _):
95+
guard let blog = try? Blog.lookup(withID: siteId, in: context),
96+
let token = blog.authToken else {
97+
return WpAuthentication.none
98+
}
99+
return WpAuthentication.bearer(token: token)
100+
case let .selfHosted(blogId, _, _, _):
74101
guard let blog = try? context.existingObject(with: blogId),
75-
let username = try? blog.getUsername(),
76-
let password = try? blog.getApplicationToken()
102+
let username = try? blog.getUsername(),
103+
let password = try? blog.getApplicationToken()
77104
else {
78105
return WpAuthentication.none
79106
}

WordPress/Classes/Services/CommentServiceRemoteFactory.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import WordPressKit
1515
return CommentServiceRemoteREST(wordPressComRestApi: api, siteID: dotComID)
1616
}
1717

18+
// The REST API does not have information about comment "likes". We'll continue to use WordPress.com API for now.
1819
if let site = try? WordPressSite(blog: blog) {
1920
return CommentServiceRemoteCoreRESTAPI(client: .init(site: site))
2021
}

WordPress/Classes/Services/MediaRepository.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ private extension MediaRepository {
9494
return MediaServiceRemoteREST(wordPressComRestApi: api, siteID: dotComID)
9595
}
9696

97+
// We use WordPress.com API for media management instead of WordPress core REST API to ensure
98+
// compatibility with WordPress.com-specific features such as video upload restrictions
99+
// and storage limits based on the site's plan.
97100
if let site = try? WordPressSite(blog: blog) {
98101
return MediaServiceRemoteCoreREST(client: .init(site: site))
99102
}

0 commit comments

Comments
 (0)