From bbd45664065b2bcbd87e12d2b1237818e7d11c7e Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:35:57 +0800 Subject: [PATCH] fix: ios native ui missing social login --- Package.resolved | 11 +- Package.swift | 6 +- Sources/RxAuthSwift/OAuthManager.swift | 38 +++++- Sources/RxAuthSwift/UISchema.swift | 61 +++++++++ .../Components/IdentityProviderButton.swift | 103 +++++++++++++++ Sources/RxAuthSwiftUI/RxSignInView.swift | 49 ++++++- Tests/RxAuthSwiftTests/RxAuthSwiftTests.swift | 124 ++++++++++++++++++ docs/code/rxauthswift-core.md | 3 +- docs/code/rxauthswiftui.md | 7 +- docs/guides/server-driven-ui-schema.md | 38 +++++- .../xcshareddata/swiftpm/Package.resolved | 9 ++ 11 files changed, 437 insertions(+), 12 deletions(-) create mode 100644 Sources/RxAuthSwiftUI/Components/IdentityProviderButton.swift diff --git a/Package.resolved b/Package.resolved index 9dd717a..7e479ee 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b683e50b71f5add4ff075bddd008be5671bcb540f0c87bf6554fa6bcee5de77f", + "originHash" : "a469fae9251bbf7923e5efe8e191d521b67d8d4b9c87205d1dc80d754e0c7960", "pins" : [ { "identity" : "swift-log", @@ -9,6 +9,15 @@ "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", "version" : "1.9.1" } + }, + { + "identity" : "swiftdraw", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swhitty/SwiftDraw.git", + "state" : { + "revision" : "82699f4bdf2f075793cfa0d392eb27c59b41a3fe", + "version" : "0.29.0" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 80d9861..9f18551 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-log.git", from: "1.6.0"), + .package(url: "https://github.com/swhitty/SwiftDraw.git", from: "0.29.0"), ], targets: [ .target( @@ -31,7 +32,10 @@ let package = Package( ), .target( name: "RxAuthSwiftUI", - dependencies: ["RxAuthSwift"] + dependencies: [ + "RxAuthSwift", + .product(name: "SwiftDraw", package: "SwiftDraw"), + ] ), .testTarget( name: "RxAuthSwiftTests", diff --git a/Sources/RxAuthSwift/OAuthManager.swift b/Sources/RxAuthSwift/OAuthManager.swift index 62c3bff..9e8053b 100644 --- a/Sources/RxAuthSwift/OAuthManager.swift +++ b/Sources/RxAuthSwift/OAuthManager.swift @@ -99,7 +99,15 @@ public final class OAuthManager: Sendable { } } - public func authenticate() async throws { + /// Browser-based authorization-code + PKCE sign-in. + /// + /// - Parameter additionalAuthorizationParameters: Extra query items for the + /// authorize request. Servers use these for hints such as + /// `identity_provider`; keys that collide with the standard OAuth + /// parameters are ignored so a hint can never break the core flow. + public func authenticate( + additionalAuthorizationParameters: [String: String] = [:] + ) async throws { isAuthenticating = true errorMessage = nil defer { isAuthenticating = false } @@ -108,7 +116,10 @@ public final class OAuthManager: Sendable { let codeVerifier = PKCEHelper.generateCodeVerifier() let codeChallenge = PKCEHelper.generateCodeChallenge(from: codeVerifier) - guard let authorizeURL = buildAuthorizationURL(codeChallenge: codeChallenge) else { + guard let authorizeURL = buildAuthorizationURL( + codeChallenge: codeChallenge, + additionalParameters: additionalAuthorizationParameters + ) else { throw OAuthError.invalidConfiguration } @@ -126,6 +137,15 @@ public final class OAuthManager: Sendable { } } + /// Sign in through a third-party identity provider advertised by the + /// server's UI schema (Google, GitHub, …). Runs the same browser flow as + /// `authenticate()`, with the provider's `authorizationParameters` attached + /// so the server skips its own login page and hands off to the provider. + public func authenticate(identityProvider: AuthUISchema.IdentityProvider) async throws { + logger.info("Starting identity provider sign-in: \(identityProvider.id)") + try await authenticate(additionalAuthorizationParameters: identityProvider.authorizationParameters) + } + public func authenticate(username: String, password: String) async throws { isAuthenticating = true errorMessage = nil @@ -981,12 +1001,15 @@ public final class OAuthManager: Sendable { .data(using: .utf8) } - private func buildAuthorizationURL(codeChallenge: String) -> URL? { + func buildAuthorizationURL( + codeChallenge: String, + additionalParameters: [String: String] = [:] + ) -> URL? { guard var components = URLComponents(string: configuration.issuer + configuration.authorizePath) else { return nil } - components.queryItems = [ + var queryItems = [ URLQueryItem(name: "response_type", value: "code"), URLQueryItem(name: "client_id", value: configuration.clientID), URLQueryItem(name: "redirect_uri", value: configuration.redirectURI), @@ -995,6 +1018,13 @@ public final class OAuthManager: Sendable { URLQueryItem(name: "code_challenge_method", value: "S256"), ] + let reserved = Set(queryItems.map(\.name)) + for (name, value) in additionalParameters.sorted(by: { $0.key < $1.key }) + where !reserved.contains(name) { + queryItems.append(URLQueryItem(name: name, value: value)) + } + components.queryItems = queryItems + return components.url } diff --git a/Sources/RxAuthSwift/UISchema.swift b/Sources/RxAuthSwift/UISchema.swift index e9d88ed..ce411c1 100644 --- a/Sources/RxAuthSwift/UISchema.swift +++ b/Sources/RxAuthSwift/UISchema.swift @@ -16,8 +16,29 @@ public struct AuthUISchema: Codable, Sendable, Equatable { public let submitLabel: String public let fields: [Field] public let supportedMethods: [SupportedMethod] + /// Third-party identity providers (Google, GitHub, …) the server will + /// broker on the client's behalf. Absent or empty when none are enabled. + public let identityProviders: [IdentityProvider]? public let links: [Link]? + public init( + flow: Flow, + title: String, + submitLabel: String, + fields: [Field], + supportedMethods: [SupportedMethod], + identityProviders: [IdentityProvider]? = nil, + links: [Link]? = nil + ) { + self.flow = flow + self.title = title + self.submitLabel = submitLabel + self.fields = fields + self.supportedMethods = supportedMethods + self.identityProviders = identityProviders + self.links = links + } + public struct Field: Codable, Sendable, Equatable, Identifiable { public enum FieldType: String, Codable, Sendable, Equatable { case text @@ -61,6 +82,46 @@ public struct AuthUISchema: Codable, Sendable, Equatable { public let primary: Bool } + /// A social / federated sign-in option. Selecting one runs the standard + /// browser authorization-code flow with `authorizationParameters` appended + /// to the authorize request, which tells the server to hand the user + /// straight to that provider instead of its own login page. + public struct IdentityProvider: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let label: String + /// Server-hosted brand mark for light appearances, usually SVG. + /// `RxAuthSwiftUI` renders it with SwiftDraw; a plain `AsyncImage` + /// cannot decode SVG, so hosts drawing their own buttons need an SVG + /// renderer too. + public let iconUrl: String? + /// Variant for dark appearances. Falls back to `iconUrl` when absent. + public let darkIconUrl: String? + /// Extra query items to add to the authorize URL, e.g. + /// `["identity_provider": "google"]`. + public let authorizationParameters: [String: String] + + public init( + id: String, + label: String, + iconUrl: String? = nil, + darkIconUrl: String? = nil, + authorizationParameters: [String: String] + ) { + self.id = id + self.label = label + self.iconUrl = iconUrl + self.darkIconUrl = darkIconUrl + self.authorizationParameters = authorizationParameters + } + + /// The icon URL for the given appearance, resolved to a `URL`. + public func iconURL(dark: Bool) -> URL? { + let raw = (dark ? darkIconUrl : nil) ?? iconUrl + guard let raw, let url = URL(string: raw), url.scheme != nil else { return nil } + return url + } + } + public struct Link: Codable, Sendable, Equatable, Identifiable { public let id: String public let label: String diff --git a/Sources/RxAuthSwiftUI/Components/IdentityProviderButton.swift b/Sources/RxAuthSwiftUI/Components/IdentityProviderButton.swift new file mode 100644 index 0000000..404452e --- /dev/null +++ b/Sources/RxAuthSwiftUI/Components/IdentityProviderButton.swift @@ -0,0 +1,103 @@ +import RxAuthSwift +import SwiftDraw +import SwiftUI + +/// One "Continue with …" row for a third-party identity provider advertised by +/// the server's UI schema. +/// +/// It shares the alternative-method shape from `AuthMethodButton` so Google +/// and GitHub sit in the same list as "Sign in with passkey" rather than +/// forming a second, differently styled block. The brand mark streams from the +/// schema's icon URL — SVG rendered by SwiftDraw, since `AsyncImage` cannot +/// decode it — so a provider the server enables tomorrow shows up with its +/// real logo without a client release. +struct IdentityProviderButton: View { + let provider: AuthUISchema.IdentityProvider + let accentColor: Color + let isBusy: Bool + let isRunning: Bool + let namespace: Namespace.ID + let action: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Button(action: action) { + ZStack { + label.opacity(isRunning ? 0 : 1) + + if isRunning { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) + .tint(accentColor) + } + } + .frame(maxWidth: .infinity) + .frame(height: Metrics.height) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: Metrics.corner)) + .overlay { + RoundedRectangle(cornerRadius: Metrics.corner, style: .continuous) + .strokeBorder(.primary.opacity(0.14), lineWidth: 1) + } + } + .buttonStyle(.pressScale) + .glassEffectID("identity-\(provider.id)", in: namespace) + .disabled(isBusy) + .opacity(isBusy && !isRunning ? 0.45 : 1) + .animation(.easeInOut(duration: 0.2), value: isBusy) + .accessibilityIdentifier("identity-provider-\(provider.id)-button") + } + + private var label: some View { + HStack(spacing: 10) { + icon + .frame(width: Metrics.iconSide, height: Metrics.iconSide) + Text(provider.label) + .font(.system(size: 17, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + .foregroundStyle(.primary) + .padding(.horizontal, 16) + } + + /// The icon slot keeps its frame through every phase so the label never + /// jumps sideways when the SVG arrives. + @ViewBuilder + private var icon: some View { + if let url = provider.iconURL(dark: colorScheme == .dark) { + AsyncSVGView(url: url) { phase in + switch phase { + case .success(let svg): + SVGView(svg: svg) + .resizable() + .scaledToFit() + .transition(.opacity) + case .failure: + fallbackIcon + case .empty: + Color.clear + } + } + .animation(.easeOut(duration: 0.2), value: url) + } else { + fallbackIcon + } + } + + private var fallbackIcon: some View { + Image(systemName: "person.crop.circle.badge.checkmark") + .font(.system(size: 16, weight: .semibold)) + } + + private enum Metrics { + #if os(iOS) + static let height: CGFloat = 52 + #else + static let height: CGFloat = 44 + #endif + static let corner: CGFloat = 14 + static let iconSide: CGFloat = 18 + } +} diff --git a/Sources/RxAuthSwiftUI/RxSignInView.swift b/Sources/RxAuthSwiftUI/RxSignInView.swift index 903ddfd..e0190ab 100644 --- a/Sources/RxAuthSwiftUI/RxSignInView.swift +++ b/Sources/RxAuthSwiftUI/RxSignInView.swift @@ -10,6 +10,8 @@ public struct RxSignInView: View { @State private var hasAttemptedSchemaLoad = false /// The method the user actually tapped, so only that button spins. @State private var activeMethod: AuthUISchema.SupportedMethod.MethodID? + /// The identity provider (Google, GitHub, …) currently in the browser flow. + @State private var activeIdentityProvider: String? @State private var stage: FormStage = .methodPicker /// The method whose credentials the form is currently collecting. @State private var pendingMethod: AuthUISchema.SupportedMethod? @@ -484,6 +486,11 @@ public struct RxSignInView: View { private func methodPickerStage(_ schema: AuthUISchema) -> some View { let prominentMethods = schema.supportedMethods.filter(\.primary) let alternativeMethods = schema.supportedMethods.filter { !$0.primary } + // Social providers join the alternatives list: they're other ways in, + // not a competing call to action, so they share that shape and sit + // under the same divider. + let identityProviders = schema.identityProviders ?? [] + let hasAlternatives = !alternativeMethods.isEmpty || !identityProviders.isEmpty return VStack(spacing: 0) { GlassEffectContainer(spacing: 16) { @@ -493,7 +500,7 @@ public struct RxSignInView: View { .glassEffectTransition(.materialize) } - if !alternativeMethods.isEmpty { + if hasAlternatives { if !prominentMethods.isEmpty { orDivider .padding(.vertical, 6) @@ -503,6 +510,11 @@ public struct RxSignInView: View { methodButton(method, schema: schema) .glassEffectTransition(.materialize) } + + ForEach(identityProviders) { provider in + identityProviderButton(provider) + .glassEffectTransition(.materialize) + } } } } @@ -716,6 +728,18 @@ public struct RxSignInView: View { } } + private func identityProviderButton(_ provider: AuthUISchema.IdentityProvider) -> some View { + IdentityProviderButton( + provider: provider, + accentColor: appearance.accentColor, + isBusy: manager.isAuthenticating, + isRunning: manager.isAuthenticating && activeIdentityProvider == provider.id, + namespace: glassNamespace + ) { + signIn(with: provider) + } + } + /// Switching between sign-in and sign-up reads as fine print at the bottom /// rather than a second tinted control competing with the call to action. private var modeFooter: some View { @@ -871,6 +895,23 @@ public struct RxSignInView: View { } } + /// Social sign-in never needs typed input: the browser session carries the + /// user to the provider and back with an authorization code, and the + /// server creates the account on first use, so it works from either mode. + private func signIn(with provider: AuthUISchema.IdentityProvider) { + focusedField = nil + activeIdentityProvider = provider.id + Task { + defer { activeIdentityProvider = nil } + do { + try await manager.authenticate(identityProvider: provider) + onAuthSuccess?() + } catch { + onAuthFailed?(error) + } + } + } + /// System-sheet account creation (iOS 26 / macOS 26): no fields, no /// validation — the OS sheet collects email/name from iCloud. Only /// reachable when the server emits the `passkey_account_creation` @@ -1009,6 +1050,10 @@ private struct GroupedMethodsPreview: View { { "id": "password", "label": "Sign In", "primary": true }, { "id": "passkey", "label": "Sign in with Passkey", "primary": false }, { "id": "passkey_account_creation", "label": "Use iCloud Keychain", "primary": false } + ], + "identityProviders": [ + { "id": "github", "label": "Continue with GitHub", "iconUrl": "https://auth.example.com/brand/github-invertocat-black.svg", "darkIconUrl": "https://auth.example.com/brand/github-invertocat-white.svg", "authorizationParameters": { "identity_provider": "github" } }, + { "id": "google", "label": "Continue with Google", "iconUrl": "https://auth.example.com/brand/google-g.svg", "darkIconUrl": "https://auth.example.com/brand/google-g.svg", "authorizationParameters": { "identity_provider": "google" } } ] } """# @@ -1026,7 +1071,7 @@ private struct GroupedMethodsPreview: View { } } -#Preview("Grouped Methods (1 primary + 2 secondary)") { +#Preview("Grouped Methods (1 primary + 2 secondary + social)") { GroupedMethodsPreview() .preferredColorScheme(.dark) } diff --git a/Tests/RxAuthSwiftTests/RxAuthSwiftTests.swift b/Tests/RxAuthSwiftTests/RxAuthSwiftTests.swift index f7b95b4..ded6cb8 100644 --- a/Tests/RxAuthSwiftTests/RxAuthSwiftTests.swift +++ b/Tests/RxAuthSwiftTests/RxAuthSwiftTests.swift @@ -181,3 +181,127 @@ struct OAuthErrorTests { #expect(OAuthError.cancelled.errorDescription != nil) } } + +// MARK: - Identity Provider Tests + +@Suite("Identity Providers") +struct IdentityProviderTests { + private let providerJSON = #""" + { + "flow": "signin", + "title": "Sign in to RxLab", + "submitLabel": "Sign in", + "fields": [], + "supportedMethods": [ + { "id": "password", "label": "Sign in with password", "primary": true } + ], + "identityProviders": [ + { + "id": "github", + "label": "Continue with GitHub", + "iconUrl": "https://auth.rxlab.app/brand/github-invertocat-black.svg", + "darkIconUrl": "https://auth.rxlab.app/brand/github-invertocat-white.svg", + "authorizationParameters": { "identity_provider": "github" } + }, + { + "id": "google", + "label": "Continue with Google", + "iconUrl": "https://auth.rxlab.app/brand/google-g.svg", + "darkIconUrl": "https://auth.rxlab.app/brand/google-g.svg", + "authorizationParameters": { "identity_provider": "google" } + } + ], + "links": [] + } + """# + + @Test func decodesIdentityProvidersFromSchema() throws { + let schema = try JSONDecoder().decode(AuthUISchema.self, from: Data(providerJSON.utf8)) + let providers = try #require(schema.identityProviders) + #expect(providers.map(\.id) == ["github", "google"]) + #expect(providers[0].iconURL(dark: false)?.absoluteString == "https://auth.rxlab.app/brand/github-invertocat-black.svg") + #expect(providers[0].iconURL(dark: true)?.absoluteString == "https://auth.rxlab.app/brand/github-invertocat-white.svg") + #expect(providers[0].authorizationParameters == ["identity_provider": "github"]) + #expect(providers[1].label == "Continue with Google") + } + + @Test func schemaWithoutIdentityProvidersStillDecodes() throws { + let json = #""" + { + "flow": "signup", + "title": "Create account", + "submitLabel": "Create", + "fields": [], + "supportedMethods": [] + } + """# + let schema = try JSONDecoder().decode(AuthUISchema.self, from: Data(json.utf8)) + #expect(schema.identityProviders == nil) + } + + @Test func iconURLFallsBackAndRejectsRelativePaths() { + let noDark = AuthUISchema.IdentityProvider( + id: "okta", + label: "Continue with Okta", + iconUrl: "https://auth.example.com/okta.svg", + authorizationParameters: ["identity_provider": "okta"] + ) + #expect(noDark.iconURL(dark: true)?.absoluteString == "https://auth.example.com/okta.svg") + + let relative = AuthUISchema.IdentityProvider( + id: "okta", + label: "Continue with Okta", + iconUrl: "/brand/okta.svg", + authorizationParameters: [:] + ) + #expect(relative.iconURL(dark: false) == nil) + + let none = AuthUISchema.IdentityProvider( + id: "okta", + label: "Continue with Okta", + authorizationParameters: [:] + ) + #expect(none.iconURL(dark: false) == nil) + } + + @Test @MainActor func authorizationURLCarriesProviderParameters() throws { + let manager = OAuthManager( + configuration: RxAuthConfiguration( + issuer: "https://auth.example.com", + clientID: "client-1", + redirectURI: "myapp://callback" + ), + tokenStorage: InMemoryTokenStorage() + ) + let url = try #require( + manager.buildAuthorizationURL( + codeChallenge: "challenge", + additionalParameters: ["identity_provider": "google"] + ) + ) + let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + #expect(items.first(where: { $0.name == "identity_provider" })?.value == "google") + #expect(items.first(where: { $0.name == "client_id" })?.value == "client-1") + #expect(items.first(where: { $0.name == "code_challenge" })?.value == "challenge") + } + + @Test @MainActor func additionalParametersCannotOverrideCoreOAuthParameters() throws { + let manager = OAuthManager( + configuration: RxAuthConfiguration( + issuer: "https://auth.example.com", + clientID: "client-1", + redirectURI: "myapp://callback" + ), + tokenStorage: InMemoryTokenStorage() + ) + let url = try #require( + manager.buildAuthorizationURL( + codeChallenge: "challenge", + additionalParameters: ["client_id": "evil", "redirect_uri": "evil://x"] + ) + ) + let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + #expect(items.filter { $0.name == "client_id" }.map(\.value) == ["client-1"]) + #expect(items.filter { $0.name == "redirect_uri" }.map(\.value) == ["myapp://callback"]) + } +} diff --git a/docs/code/rxauthswift-core.md b/docs/code/rxauthswift-core.md index b5b7bba..9f3c725 100644 --- a/docs/code/rxauthswift-core.md +++ b/docs/code/rxauthswift-core.md @@ -86,7 +86,8 @@ public init( | Method | Description | | --- | --- | | `checkExistingAuth() async` | Restore a session from stored tokens on launch. | -| `authenticate() async throws` | Browser authorization-code + PKCE flow. | +| `authenticate(additionalAuthorizationParameters:) async throws` | Browser authorization-code + PKCE flow. Extra query items are appended to the authorize URL; core OAuth keys cannot be overridden. Defaults to none, so `authenticate()` still works. | +| `authenticate(identityProvider:) async throws` | Social sign-in (Google, GitHub, …) via a schema-advertised `AuthUISchema.IdentityProvider`. Same browser flow with the provider's `authorizationParameters` attached. | | `authenticate(username:password:) async throws` | Native password grant. | | `authenticateWithPasskey(username:) async throws` | Passkey assertion sign-in. | | `signUp(username:password:name:) async throws -> SignupResult` | Native sign-up. | diff --git a/docs/code/rxauthswiftui.md b/docs/code/rxauthswiftui.md index dde5751..a061163 100644 --- a/docs/code/rxauthswiftui.md +++ b/docs/code/rxauthswiftui.md @@ -17,7 +17,12 @@ surface. It renders from the manager's server-driven `AuthUISchema` when available, falling back to sensible defaults, and automatically loads the schema on first appearance. On macOS it shows native username/password fields and an optional animated gradient background; passkey buttons appear when the -manager reports the matching capability. +manager reports the matching capability. When the sign-in schema lists +`identityProviders`, the native method picker also shows a "Continue with …" +row per provider, with the brand mark streamed from the schema's `iconUrl` / +`darkIconUrl` and rendered by SwiftDraw; tapping one runs +`OAuthManager.authenticate(identityProvider:)` through the system browser +session. ### Simple initializer (appearance struct) diff --git a/docs/guides/server-driven-ui-schema.md b/docs/guides/server-driven-ui-schema.md index 570f7a2..3b2cea6 100644 --- a/docs/guides/server-driven-ui-schema.md +++ b/docs/guides/server-driven-ui-schema.md @@ -7,8 +7,8 @@ description: How RxAuthSwift fetches and renders the sign-in/sign-up form from a # Server-Driven UI Schema `AuthUISchema` lets the backend describe the native sign-in and sign-up forms — -field labels, validation, supported auth methods, and footer links — without -re-shipping the client. `RxSignInView` renders dynamically from these schemas. +field labels, validation, supported auth methods, social identity providers, +and footer links — without re-shipping the client. `RxSignInView` renders dynamically from these schemas. ## Fetching @@ -41,6 +41,7 @@ public struct AuthUISchema: Codable, Sendable, Equatable { public let submitLabel: String public let fields: [Field] public let supportedMethods: [SupportedMethod] + public let identityProviders: [IdentityProvider]? public let links: [Link]? } ``` @@ -76,6 +77,39 @@ Each method carries a `label` and a `primary` flag. The primary method is the prominent button; non-primary methods are grouped together as secondary options. Emit `passkey_account_creation` only on the signup flow. +### IdentityProvider + +```swift +public struct IdentityProvider: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let label: String // "Continue with Google" + public let iconUrl: String? // absolute URL, usually SVG + public let darkIconUrl: String? + public let authorizationParameters: [String: String] + + public func iconURL(dark: Bool) -> URL? // darkIconUrl ?? iconUrl +} +``` + +Social sign-in options. Each entry renders as a "Continue with …" row under +the alternative methods in the native picker. Tapping one calls +`OAuthManager.authenticate(identityProvider:)`, which runs the normal browser +authorization-code + PKCE flow with `authorizationParameters` appended to the +authorize URL (for RxLab Auth that is `identity_provider=`), so the +server hands the user straight to the provider instead of its own login page. +Because the account is created server-side on first use, the same flow serves +both sign-in and sign-up. + +Icons are fetched live from `iconUrl` / `darkIconUrl` (chosen by the current +color scheme) and rendered with [SwiftDraw](https://github.com/swhitty/SwiftDraw), +so a newly enabled provider shows its real mark without a client update. Keep +the SVGs simple — flat paths, gradients, and clip paths render; filters such +as `feGaussianBlur` and masks do not. URLs must be absolute; a missing, +relative, or unrenderable icon falls back to a generic symbol. + +Emit `identityProviders` as an empty array or omit it entirely when no +providers are configured; both decode fine. + ### Link Footer links (`id`, `label`, `href`) — e.g. "Forgot password?" or terms of diff --git a/test/test.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/test/test.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1f8bb6b..16abb65 100644 --- a/test/test.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/test/test.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,6 +9,15 @@ "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", "version" : "1.9.1" } + }, + { + "identity" : "swiftdraw", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swhitty/SwiftDraw.git", + "state" : { + "revision" : "82699f4bdf2f075793cfa0d392eb27c59b41a3fe", + "version" : "0.29.0" + } } ], "version" : 3