Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -31,7 +32,10 @@ let package = Package(
),
.target(
name: "RxAuthSwiftUI",
dependencies: ["RxAuthSwift"]
dependencies: [
"RxAuthSwift",
.product(name: "SwiftDraw", package: "SwiftDraw"),
]
),
.testTarget(
name: "RxAuthSwiftTests",
Expand Down
38 changes: 34 additions & 4 deletions Sources/RxAuthSwift/OAuthManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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
}

Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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
}

Expand Down
61 changes: 61 additions & 0 deletions Sources/RxAuthSwift/UISchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions Sources/RxAuthSwiftUI/Components/IdentityProviderButton.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading