diff --git a/.github/workflows/create-release.yaml b/.github/workflows/create-release.yaml new file mode 100644 index 0000000..5a84027 --- /dev/null +++ b/.github/workflows/create-release.yaml @@ -0,0 +1,26 @@ +name: Create Release +on: + workflow_dispatch: + +jobs: + create-release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Setup Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + - name: Create Release + uses: cycjimmy/semantic-release-action@v6 + if: github.event_name == 'workflow_dispatch' + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + with: + branch: main diff --git a/.github/workflows/upload-docs.yaml b/.github/workflows/upload-docs.yaml new file mode 100644 index 0000000..9516303 --- /dev/null +++ b/.github/workflows/upload-docs.yaml @@ -0,0 +1,26 @@ +name: Upload docs to autopilot +on: + push: + branches: ["main"] + paths: + - "docs/**" + - "scripts/upload_docs.py" + - ".github/workflows/upload-docs.yaml" + workflow_dispatch: +concurrency: + group: upload-docs + cancel-in-progress: false +jobs: + upload: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.x" + - name: Upload docs + env: + DOCS_ENDPOINT: https://autopilot.rxlab.app + DOCS_REPOSITORY_ID: rxtech-lab/RxAuthSwift + DOCS_UPLOAD_TOKEN: ${{ secrets.DOCS_UPLOAD_TOKEN }} + run: python scripts/upload_docs.py diff --git a/.releaserc b/.releaserc new file mode 100644 index 0000000..a966fe6 --- /dev/null +++ b/.releaserc @@ -0,0 +1,7 @@ +{ + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "@semantic-release/github" + ] +} diff --git a/Sources/RxAuthSwiftUI/RxSignInAppearance.swift b/Sources/RxAuthSwiftUI/RxSignInAppearance.swift index 016eafb..5be9202 100644 --- a/Sources/RxAuthSwiftUI/RxSignInAppearance.swift +++ b/Sources/RxAuthSwiftUI/RxSignInAppearance.swift @@ -1,5 +1,19 @@ import SwiftUI +/// Controls how `RxSignInView` collects credentials. +/// +/// On iOS this is honored directly; `.web` (the default) launches the system +/// web authentication session, while `.native` renders the in-app credential +/// form. On macOS the view always uses the native form regardless of this +/// value, since that is its only supported presentation. +public enum RxSignInStyle: Sendable { + /// Presents the system web authentication session (`ASWebAuthenticationSession`). + case web + /// Presents an in-app native credential form (email / password, plus + /// passkeys on platforms where they are available). + case native +} + public struct RxSignInAppearance: @unchecked Sendable { public var icon: SignInIcon public var title: LocalizedStringKey diff --git a/Sources/RxAuthSwiftUI/RxSignInView.swift b/Sources/RxAuthSwiftUI/RxSignInView.swift index ec4beb7..9f63047 100644 --- a/Sources/RxAuthSwiftUI/RxSignInView.swift +++ b/Sources/RxAuthSwiftUI/RxSignInView.swift @@ -8,11 +8,10 @@ public struct RxSignInView: View { @State private var fieldErrors: [String: String] = [:] @State private var isAppearing = false @State private var hasAttemptedSchemaLoad = false - #if os(macOS) @FocusState private var focusedField: String? @Namespace private var glassNamespace - #endif private let appearance: RxSignInAppearance + private let style: RxSignInStyle private let customHeader: Header? private let onAuthSuccess: (() -> Void)? private let onAuthFailed: ((Error) -> Void)? @@ -22,11 +21,13 @@ public struct RxSignInView: View { public init( manager: OAuthManager, appearance: RxSignInAppearance = RxSignInAppearance(), + style: RxSignInStyle = .web, onAuthSuccess: (() -> Void)? = nil, onAuthFailed: ((Error) -> Void)? = nil ) where Header == Never { self.manager = manager self.appearance = appearance + self.style = style self.customHeader = nil self.onAuthSuccess = onAuthSuccess self.onAuthFailed = onAuthFailed @@ -37,12 +38,14 @@ public struct RxSignInView: View { public init( manager: OAuthManager, appearance: RxSignInAppearance = RxSignInAppearance(), + style: RxSignInStyle = .web, onAuthSuccess: (() -> Void)? = nil, onAuthFailed: ((Error) -> Void)? = nil, @ViewBuilder header: () -> Header ) { self.manager = manager self.appearance = appearance + self.style = style self.customHeader = header() self.onAuthSuccess = onAuthSuccess self.onAuthFailed = onAuthFailed @@ -50,27 +53,77 @@ public struct RxSignInView: View { public var body: some View { ZStack { - #if os(macOS) if appearance.showsAnimatedBackground { AnimatedGradientBackground( accentColor: appearance.accentColor, secondaryColor: appearance.secondaryColor ) } - macOSContent - #else - AnimatedGradientBackground( - accentColor: appearance.accentColor, - secondaryColor: appearance.secondaryColor - ) - iOSContent - #endif + content } .animation(.default, value: manager.errorMessage) } - #if os(macOS) - private var macOSContent: some View { + /// Picks the credential presentation. macOS only ships the native form, so + /// the `style` is honored on iOS and ignored on macOS. + @ViewBuilder + private var content: some View { + #if os(macOS) + nativeContent + #else + switch style { + case .web: + webContent + case .native: + nativeContent + } + #endif + } + + // MARK: - Web Flow (iOS) + + #if os(iOS) + private var webContent: some View { + VStack(spacing: 32) { + Spacer() + if let customHeader { + customHeader + } else { + defaultHeader + } + Spacer() + + if let errorMessage = manager.errorMessage { + AuthErrorBanner(message: errorMessage) + .padding(.horizontal, 24) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + VStack(spacing: 12) { + PrimaryAuthButton( + title: appearance.signInButtonTitle, + isLoading: manager.isAuthenticating, + accentColor: appearance.accentColor + ) { + Task { + do { + try await manager.authenticate() + onAuthSuccess?() + } catch { + onAuthFailed?(error) + } + } + } + } + .padding(.horizontal, 32) + .padding(.bottom, 48) + } + } + #endif + + // MARK: - Native Form (macOS + iOS) + + private var nativeContent: some View { ZStack(alignment: .top) { ScrollView { VStack(spacing: 0) { @@ -282,44 +335,6 @@ public struct RxSignInView: View { .padding(.top, 16) .padding(.horizontal, 32) } - #else - private var iOSContent: some View { - VStack(spacing: 32) { - Spacer() - if let customHeader { - customHeader - } else { - defaultHeader - } - Spacer() - - if let errorMessage = manager.errorMessage { - AuthErrorBanner(message: errorMessage) - .padding(.horizontal, 24) - .transition(.move(edge: .top).combined(with: .opacity)) - } - - VStack(spacing: 12) { - PrimaryAuthButton( - title: appearance.signInButtonTitle, - isLoading: manager.isAuthenticating, - accentColor: appearance.accentColor - ) { - Task { - do { - try await manager.authenticate() - onAuthSuccess?() - } catch { - onAuthFailed?(error) - } - } - } - } - .padding(.horizontal, 32) - .padding(.bottom, 48) - } - } - #endif @ViewBuilder private var defaultHeader: some View { @@ -344,7 +359,6 @@ public struct RxSignInView: View { } } - #if os(macOS) private var compactHeader: some View { VStack(spacing: 14) { compactIcon @@ -399,6 +413,22 @@ public struct RxSignInView: View { mode == .signIn ? manager.signInSchema : manager.signUpSchema } + /// Methods to surface in the native form for the current platform. + /// + /// iOS passkey ceremonies are not yet implemented in `OAuthManager` (they + /// throw `.passkeyUnavailable`), so on iOS we only surface password-based + /// methods to avoid presenting buttons that always fail. If filtering would + /// leave nothing, we fall back to the full server-provided list. Remove this + /// filter once an iOS passkey authenticator lands. + private func displayMethods(_ schema: AuthUISchema) -> [AuthUISchema.SupportedMethod] { + #if os(iOS) + let filtered = schema.supportedMethods.filter { $0.id == .password } + return filtered.isEmpty ? schema.supportedMethods : filtered + #else + return schema.supportedMethods + #endif + } + private var nativeCredentialForm: some View { VStack(spacing: 18) { modeTogglePill @@ -415,8 +445,9 @@ public struct RxSignInView: View { GlassEffectContainer(spacing: 16) { VStack(spacing: 14) { - let primaryMethods = schema.supportedMethods.filter { $0.primary } - let secondaryMethods = schema.supportedMethods.filter { !$0.primary } + let methods = displayMethods(schema) + let primaryMethods = methods.filter { $0.primary } + let secondaryMethods = methods.filter { !$0.primary } let orderedMethods = primaryMethods + secondaryMethods ForEach(Array(orderedMethods.enumerated()), id: \.element.id) { index, method in @@ -615,6 +646,11 @@ public struct RxSignInView: View { .focused($focusedField, equals: field.key) .submitLabel(.next) .onSubmit { advanceFocus(from: field.key) } + #if os(iOS) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(field.type == .email ? .emailAddress : .default) + #endif .accessibilityIdentifier("\(field.key)-field") } .padding(.horizontal, 14) @@ -783,7 +819,6 @@ public struct RxSignInView: View { } } } - #endif } private enum NativeAuthMode: Hashable { @@ -847,6 +882,7 @@ private enum NativeAuthMode: Hashable { #if DEBUG private struct GroupedMethodsPreview: View { + private let style: RxSignInStyle @State private var manager: OAuthManager = { let manager = OAuthManager( configuration: RxAuthConfiguration( @@ -876,8 +912,12 @@ private struct GroupedMethodsPreview: View { return manager }() + init(style: RxSignInStyle = .native) { + self.style = style + } + var body: some View { - RxSignInView(manager: manager) + RxSignInView(manager: manager, style: style) } } @@ -885,6 +925,11 @@ private struct GroupedMethodsPreview: View { GroupedMethodsPreview() .preferredColorScheme(.dark) } + +#Preview("iOS Native Form") { + GroupedMethodsPreview(style: .native) + .preferredColorScheme(.dark) +} #endif #Preview("Custom Header") { diff --git a/docs/api/server-endpoints.md b/docs/api/server-endpoints.md new file mode 100644 index 0000000..b36b1e7 --- /dev/null +++ b/docs/api/server-endpoints.md @@ -0,0 +1,111 @@ +--- +slug: api/server-endpoints +title: Backend Endpoint Contract +description: HTTP endpoints RxAuthSwift calls and the request/response shapes a compatible auth server must implement +--- + +# Backend Endpoint Contract + +RxAuthSwift is a client; this page documents what a compatible auth server +must expose. Paths are relative to `issuer` and configurable on +`RxAuthConfiguration`. All token-issuing endpoints must return the standard +OAuth token JSON shape (below). + +## Token response shape + +Every token-issuing endpoint returns: + +```json +{ + "access_token": "…", + "refresh_token": "…", // optional + "expires_in": 3600, // optional, seconds + "token_type": "Bearer" // optional +} +``` + +OAuth-style error responses (`{ "error": "...", "error_description": "..." }`) +are surfaced to the user via `error_description` when present. + +## Authorization code + PKCE + +- `GET {authorizePath}` (default `/api/oauth/authorize`) — standard + authorization endpoint. The client sends `response_type=code`, `client_id`, + `redirect_uri`, `scope`, `code_challenge`, `code_challenge_method=S256`. +- `POST {tokenPath}` (default `/api/oauth/token`), + `application/x-www-form-urlencoded`: + - **authorization_code**: `grant_type`, `code`, `redirect_uri`, `client_id`, + `code_verifier`. + - **refresh_token**: `grant_type`, `refresh_token`, `client_id`. +- `GET {userInfoPath}` (default `/api/oauth/userinfo`) — `Authorization: Bearer + {access_token}`; returns a user object with `id`/`sub`, optional `name`, + `email`, `image`/`picture`. + +## Native password grant + +`POST {nativePasswordTokenPath ?? tokenPath}`, +`application/x-www-form-urlencoded`: `grant_type=password`, `username`, +`password`, `client_id`, `scope`. + +## Native sign-up + +`POST {nativeSignupPath}` (default `/api/oauth/signup`), `application/json`: + +```json +{ "client_id": "…", "username": "…", "password": "…", "name": "…", "scope": "…" } +``` + +Respond with either the token JSON (immediate sign-in) or a +verification-required payload: + +```json +{ "user_id": "…", "email": "…", "email_verification_required": true } +``` + +## Passkey sign-in + +- `POST {passkeyChallengePath}`, `application/json`: + `{ "client_id", "redirect_uri", "username"? }`. Returns a base64url + `challenge`, optional session id (`sessionID`/`sessionId`/`requestID`), + optional relying-party id (`relyingPartyIdentifier`/`relyingPartyId`/`rpId`), + and optional allowed credentials + (`allowedCredentialIDs`/`allowedCredentials`/`allowCredentials`). +- `POST {passkeyVerificationPath}`, `application/json`: `{ "client_id", + "session_id"?, "scope"?, "credential": { id, rawId, type, response: { + clientDataJSON, authenticatorData, signature, userHandle } } }`. Returns + token JSON. + +## Passkey registration + +- `POST {passkeyRegistrationChallengePath}`, `application/json`: + `{ "client_id", "redirect_uri", "username", "name"? }`. Returns base64url + `challenge` and `userID` (flat or nested under `user`), optional + session/username/RP id. +- `POST {passkeyRegistrationVerificationPath}`, `application/json`: + `{ "client_id", "session_id"?, "scope"?, "credential": { id, rawId, type, + response: { clientDataJSON, attestationObject? } } }`. Returns token JSON. + +## Passkey upgrade + +Bearer-authenticated with the current access token. + +- `POST {passkeyUpgradeChallengePath}` — body `{}`, `Authorization: Bearer …`. + Same challenge shape as registration. +- `POST {passkeyUpgradeVerificationPath}` — `Authorization: Bearer …`, + `{ "session_id"?, "name", "registration": { … credential … } }`. + +## System-sheet account creation (iOS 26 / macOS 26) + +- `POST {passkeyAccountCreationOptionsPath}`, `application/json`: + `{ "client_id", "redirect_uri" }`. Same challenge shape as registration + (username/displayName ignored — supplied by the OS). +- `POST {passkeyAccountCreationVerifyPath}`, `application/json`: + `{ "client_id", "session_id"?, "scope"?, "contact_identifier", + "contact_identifier_type", "name"?, "credential": { … } }`. Returns token + JSON. + +## Server-driven UI schema + +`GET {uiSchemaPath}/{flow}?client_id={clientID}` (default +`/api/auth/ui-schema`), `flow` ∈ `signin` | `signup`. Returns an +[`AuthUISchema`](../guides/server-driven-ui-schema.md) JSON document. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..f5c21c7 --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,89 @@ +--- +slug: architecture/overview +title: Architecture Overview +description: High-level design of RxAuthSwift — the core OAuth library and the SwiftUI sign-in module +--- + +# Architecture Overview + +RxAuthSwift is an OAuth 2.0 authentication library for iOS and macOS with PKCE +support and a customizable SwiftUI sign-in UI. It targets iOS 26 / macOS 26 +(building back to iOS 18 / macOS 15 features at runtime) and Swift 6.2. + +## Package layout + +The Swift package exposes two library products: + +| Product | Target | Responsibility | +| --- | --- | --- | +| `RxAuthSwift` | `Sources/RxAuthSwift` | Core OAuth logic: configuration, the `OAuthManager` state machine, token storage, PKCE, passkey/WebAuthn ceremonies, and the server-driven UI schema model. No SwiftUI dependency. | +| `RxAuthSwiftUI` | `Sources/RxAuthSwiftUI` | Drop-in SwiftUI sign-in surface (`RxSignInView`), appearance customization, and supporting components. Depends on `RxAuthSwift`. | + +The only external dependency is [`swift-log`](https://github.com/apple/swift-log), +used by `OAuthManager` for structured logging. + +## Core building blocks + +- **`RxAuthConfiguration`** — an immutable, `Sendable` value type holding the + issuer, client ID, redirect URI, scopes, and every endpoint path. It derives + fully-qualified `URL`s lazily and resolves optional native/passkey endpoints + to `nil` when not configured. This is the single source of truth for which + authentication methods are available. +- **`OAuthManager`** — a `@MainActor`, `@Observable` final class that owns all + authentication state (`authState`, `currentUser`, `errorMessage`, + `infoMessage`, schema). It is the only stateful object the host app holds. +- **`TokenStorageProtocol`** — the persistence seam. The default + implementation is `KeychainTokenStorage`; `InMemoryTokenStorage` is provided + for tests and previews. Hosts can inject a custom backend. +- **`AuthUISchema`** — a server-driven description of the sign-in/sign-up form + (fields, validation, supported methods, links). It lets the backend evolve + the native UI without re-shipping the client. + +## Authentication flows + +The library supports several flows, each gated on the corresponding endpoints +being present in `RxAuthConfiguration`: + +1. **Authorization code + PKCE (browser).** `authenticate()` opens an + `ASWebAuthenticationSession` (platform providers under + `Sources/RxAuthSwift/Platform`), captures the redirect, and exchanges the + code at the token endpoint. This is the universal, always-available flow. +2. **Native password grant.** `authenticate(username:password:)` posts an + OAuth `password` grant directly to `nativePasswordTokenPath` (or + `tokenPath`). Used by the native macOS form so the user never sees a + browser. +3. **Native sign-up.** `signUp(username:password:name:)` POSTs JSON to + `nativeSignupPath`. The server either returns tokens (immediate sign-in) or + signals that email verification is required (`SignupResult`). +4. **Passkey sign-in / registration.** WebAuthn ceremonies via + `AuthenticationServices`, exchanging a server challenge for a platform + assertion/attestation and verifying it for tokens. +5. **Passkey upgrade.** After a password sign-in/sign-up, the OS can silently + provision a passkey (`attemptAutomaticPasskeyUpgrade`, fire-and-forget) or + the UI can prompt with an interactive upgrade (`addPasskeyForCurrentUser`). +6. **System-sheet account creation (iOS 26 / macOS 26).** Apple's + `ASAuthorizationAccountCreationProvider` collects contact details from + iCloud and produces a passkey with no in-app form. + +All token-issuing flows converge on the same path: decode the token response, +persist via `TokenStorageProtocol`, fetch user info, set `authState = +.authenticated`, and start the refresh timer. + +## State & session lifecycle + +`authState` moves through `.unknown` → (`.authenticated` | `.unauthenticated`). +On launch the host calls `checkExistingAuth()`, which restores a session from a +valid access token, refreshes from a refresh token, or falls back to +`.unauthenticated`. A repeating 5-minute `Timer` refreshes tokens in the +background; an unrecoverable refresh failure clears storage, logs the user out, +and posts the `.rxAuthSessionExpired` notification so the host can react. + +## Security notes + +- PKCE (`S256`) is always used for the browser authorization-code flow + (`PKCEHelper`). +- Tokens are stored in the Keychain by default, scoped to + `keychainServiceName`. +- Passkey payloads are encoded/decoded as base64url (`Base64URL`) and the + challenge response decoder tolerates the various server JSON shapes + (flat, nested, SimpleWebAuthn-style) seen across endpoints. diff --git a/docs/code/rxauthswift-core.md b/docs/code/rxauthswift-core.md new file mode 100644 index 0000000..b5b7bba --- /dev/null +++ b/docs/code/rxauthswift-core.md @@ -0,0 +1,169 @@ +--- +slug: code/rxauthswift-core +title: RxAuthSwift Core API Reference +description: Public API of the RxAuthSwift core target — configuration, OAuthManager, token storage, models, and errors +--- + +# RxAuthSwift Core API Reference + +Public symbols in the `RxAuthSwift` target. + +## RxAuthConfiguration + +`public struct RxAuthConfiguration: Sendable` — immutable OAuth configuration. + +### Initializer parameters + +| Parameter | Type | Default | Purpose | +| --- | --- | --- | --- | +| `issuer` | `String` | — | Base URL of the auth server. | +| `clientID` | `String` | — | OAuth client identifier. | +| `redirectURI` | `String` | — | Redirect URI; its scheme is the callback scheme. | +| `scopes` | `[String]` | `["openid", "profile", "email"]` | Requested scopes. | +| `authorizePath` | `String` | `/api/oauth/authorize` | Authorization endpoint. | +| `tokenPath` | `String` | `/api/oauth/token` | Token endpoint. | +| `userInfoPath` | `String` | `/api/oauth/userinfo` | User-info endpoint. | +| `nativePasswordTokenPath` | `String?` | `nil` | Native password-grant endpoint (falls back to `tokenPath`). | +| `nativeSignupPath` | `String?` | `/api/oauth/signup` | Native signup endpoint. | +| `passkeyChallengePath` / `passkeyVerificationPath` | `String?` | `nil` | Passkey sign-in pair. | +| `passkeyRegistrationChallengePath` / `passkeyRegistrationVerificationPath` | `String?` | `nil` | Passkey registration pair. | +| `passkeyUpgradeChallengePath` / `passkeyUpgradeVerificationPath` | `String?` | `nil` | Passkey upgrade pair. | +| `passkeyAccountCreationOptionsPath` / `passkeyAccountCreationVerifyPath` | `String?` | `nil` | System-sheet account-creation pair. | +| `passkeyRelyingPartyIdentifier` | `String?` | `nil` | WebAuthn RP ID (falls back to issuer host). | +| `uiSchemaPath` | `String?` | `/api/auth/ui-schema` | Server-driven UI schema endpoint. | +| `keychainServiceName` | `String` | `com.rxlab.RxAuthSwift` | Keychain service scope. | + +### Computed properties + +Derived `URL?` accessors: `authorizeURL`, `tokenURL`, `userInfoURL`, +`nativePasswordTokenURL`, `nativeSignupURL`, `passkeyChallengeURL`, +`passkeyVerificationURL`, `passkeyRegistrationChallengeURL`, +`passkeyRegistrationVerificationURL`, `passkeyUpgradeChallengeURL`, +`passkeyUpgradeVerificationURL`, `passkeyAccountCreationOptionsURL`, +`passkeyAccountCreationVerifyURL`, and `redirectScheme`. Optional endpoints +resolve to `nil` when their path is unset. + +- `func uiSchemaURL(flow: AuthUISchema.Flow) -> URL?` — builds the schema URL + for a flow, appending `client_id`. + +## OAuthManager + +`@MainActor @Observable public final class OAuthManager` — owns authentication +state and drives every flow. + +### Observable state (read-only) + +| Property | Type | Meaning | +| --- | --- | --- | +| `authState` | `AuthenticationState` | `.unknown` / `.authenticated` / `.unauthenticated`. | +| `currentUser` | `User?` | The signed-in user. | +| `errorMessage` | `String?` | Last user-facing error. | +| `infoMessage` | `String?` | Informational message (e.g. email-verification prompt). | +| `isAuthenticating` | `Bool` | A flow is in progress. | +| `signInSchema` / `signUpSchema` | `AuthUISchema?` | Loaded server-driven schemas. | +| `isLoadingSchema` | `Bool` | Schema fetch in progress. | +| `pendingPasskeyOffer` | `Bool` | A post-signup passkey offer is awaiting resolution. | + +### Capability flags + +`supportsPasskeyAuthentication`, `supportsNativeSignup`, +`supportsPasskeyRegistration`, `supportsPasskeyUpgrade`, +`supportsPasskeyAccountCreation` — each `true` only when the matching +endpoints are configured. + +### Initializer + +```swift +public init( + configuration: RxAuthConfiguration, + tokenStorage: TokenStorageProtocol? = nil, // defaults to KeychainTokenStorage + logger: Logger? = nil // swift-log; defaults to .info +) +``` + +### Methods + +| Method | Description | +| --- | --- | +| `checkExistingAuth() async` | Restore a session from stored tokens on launch. | +| `authenticate() async throws` | Browser authorization-code + PKCE flow. | +| `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. | +| `signUpWithPasskey(username:name:) async throws` | Passkey registration sign-up. | +| `createAccountWithPasskey(acceptedIdentifiers:shouldRequestName:) async throws` | System-sheet account creation (iOS 26 / macOS 26). | +| `addPasskeyForCurrentUser() async throws` | Accept the post-signup passkey offer (interactive). | +| `skipPasskeyUpgradeOffer()` | Decline the post-signup passkey offer and finalize the session. | +| `refreshTokenIfNeeded() async throws` | Manually refresh tokens. | +| `loadUISchema() async -> (signIn:signUp:)` | Fetch both UI schemas. | +| `fetchUISchema(flow:) async -> AuthUISchema?` | Fetch one UI schema. | +| `logout() async` | Clear tokens, reset state. | +| `clearError()` / `clearInfo()` | Clear the corresponding message. | + +A repeating 5-minute timer refreshes tokens while authenticated. On an +unrecoverable refresh failure the manager logs out and posts +`.rxAuthSessionExpired`. + +## SignupResult + +```swift +public enum SignupResult: Sendable, Equatable { + case authenticated + case emailVerificationRequired(email: String) +} +``` + +## AuthenticationState & User + +```swift +public enum AuthenticationState: Sendable { case unknown, authenticated, unauthenticated } + +public struct User: Codable, Identifiable, Sendable, Equatable { + public let id: String // decodes "id" or OIDC "sub" + public let name: String? + public let email: String? + public let image: String? // decodes "image" or OIDC "picture" +} +``` + +## TokenStorageProtocol + +```swift +public protocol TokenStorageProtocol: Sendable { + func saveAccessToken(_ token: String) throws + func getAccessToken() -> String? + func deleteAccessToken() throws + func saveRefreshToken(_ token: String) throws + func getRefreshToken() -> String? + func deleteRefreshToken() throws + func saveExpiresAt(_ date: Date) throws + func getExpiresAt() -> Date? + func isTokenExpired() -> Bool + func clearAll() throws +} +``` + +Built-in implementations: `KeychainTokenStorage` (default, scoped to +`keychainServiceName`) and `InMemoryTokenStorage` (tests / previews). + +## Errors + +`OAuthError: LocalizedError, Sendable` cases: `invalidURL`, +`invalidConfiguration`, `authenticationFailed(String)`, +`tokenExchangeFailed(String)`, `tokenRefreshFailed(String)`, +`networkError(String)`, `userInfoFailed(String)`, `noRefreshToken`, +`invalidCallbackURL`, `cancelled`, `invalidCredentials`, +`invalidSignupDetails`, `passkeyUnavailable`. + +`KeychainError: LocalizedError, Sendable` cases: `saveFailed(OSStatus)`, +`deleteFailed(OSStatus)`, `unexpectedData`. + +## Notifications + +```swift +extension Notification.Name { + public static let rxAuthSessionExpired: Notification.Name +} +``` + +Posted when a token refresh fails unrecoverably and the user is logged out. diff --git a/docs/code/rxauthswiftui.md b/docs/code/rxauthswiftui.md new file mode 100644 index 0000000..dde5751 --- /dev/null +++ b/docs/code/rxauthswiftui.md @@ -0,0 +1,96 @@ +--- +slug: code/rxauthswiftui +title: RxAuthSwiftUI API Reference +description: Public API of the RxAuthSwiftUI target — RxSignInView and RxSignInAppearance +--- + +# RxAuthSwiftUI API Reference + +Public symbols in the `RxAuthSwiftUI` target. This module depends on +`RxAuthSwift` and provides a drop-in SwiftUI sign-in surface driven by an +`OAuthManager`. + +## RxSignInView + +`public struct RxSignInView: View` — the sign-in / sign-up +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. + +### Simple initializer (appearance struct) + +```swift +public init( + manager: OAuthManager, + appearance: RxSignInAppearance = RxSignInAppearance(), + onAuthSuccess: (() -> Void)? = nil, + onAuthFailed: ((Error) -> Void)? = nil +) where Header == Never +``` + +### Advanced initializer (custom header) + +```swift +public init( + manager: OAuthManager, + appearance: RxSignInAppearance = RxSignInAppearance(), + onAuthSuccess: (() -> Void)? = nil, + onAuthFailed: ((Error) -> Void)? = nil, + @ViewBuilder header: () -> Header +) +``` + +The `header` ViewBuilder replaces the default icon/title/subtitle block with +fully custom content. + +### Example + +```swift +RxSignInView(manager: authManager) { + VStack { + Image("Logo").resizable().frame(width: 100, height: 100) + Text("My App").font(.largeTitle.bold()) + } +} +``` + +## RxSignInAppearance + +`public struct RxSignInAppearance: @unchecked Sendable` — styling and copy for +`RxSignInView`. + +### Properties / initializer defaults + +| Property | Type | Default | +| --- | --- | --- | +| `icon` | `SignInIcon` | `.systemImage("lock.shield.fill")` | +| `title` | `LocalizedStringKey` | `"Welcome"` | +| `subtitle` | `LocalizedStringKey` | `"Sign in to continue"` | +| `signInButtonTitle` | `LocalizedStringKey` | `"Sign In"` | +| `signUpButtonTitle` | `LocalizedStringKey` | `"Create Account"` | +| `usernamePlaceholder` | `LocalizedStringKey` | `"Username"` | +| `passwordPlaceholder` | `LocalizedStringKey` | `"Password"` | +| `namePlaceholder` | `LocalizedStringKey` | `"Name"` | +| `passkeyButtonTitle` | `LocalizedStringKey` | `"Continue with Passkey"` | +| `passkeySignupButtonTitle` | `LocalizedStringKey` | `"Create Passkey Account"` | +| `accentColor` | `Color` | `.blue` | +| `secondaryColor` | `Color` | `.purple` | +| `showsAnimatedBackground` | `Bool` | `true` | + +### SignInIcon + +```swift +public enum SignInIcon: Sendable { + case systemImage(String) // SF Symbol + case image(Image) // SwiftUI Image + case assetImage(String, Bundle?) // Asset catalog + case none // No icon +} +``` + +## Glass effect + +UI components use `.glassEffect` on iOS 26+ / macOS 26+ and fall back to +`.ultraThinMaterial` / `.borderedProminent` on older platforms. diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md new file mode 100644 index 0000000..6712669 --- /dev/null +++ b/docs/guides/getting-started.md @@ -0,0 +1,134 @@ +--- +slug: guides/getting-started +title: Getting Started +description: Install RxAuthSwift, configure OAuth, and present the sign-in UI +--- + +# Getting Started + +## Requirements + +- iOS 18+ / macOS 15+ (the Swift package declares iOS 26 / macOS 26 as the + build platforms; newer-API features degrade gracefully at runtime) +- Swift 6.2+ / Xcode 16+ + +## Installation + +Add the package to your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/rxtech-lab/RxAuthSwift.git", from: "1.0.0"), +] +``` + +Then depend on the targets you need: + +```swift +.target( + name: "YourApp", + dependencies: [ + "RxAuthSwift", // Core OAuth logic + "RxAuthSwiftUI", // Sign-in UI components (optional) + ] +) +``` + +## 1. Configure OAuth + +```swift +import RxAuthSwift + +let config = RxAuthConfiguration( + issuer: "https://auth.example.com", + clientID: "your-client-id", + redirectURI: "yourapp://callback", + scopes: ["openid", "profile", "email"] +) +``` + +Endpoint paths default to `/api/oauth/authorize`, `/api/oauth/token`, and +`/api/oauth/userinfo`, and can each be overridden. See +[Configuration reference](../code/rxauthswift-core.md) for every parameter. + +## 2. Create an OAuthManager + +```swift +let authManager = OAuthManager(configuration: config) +``` + +You can inject custom token storage or a logger: + +```swift +let authManager = OAuthManager( + configuration: config, + tokenStorage: MyCustomTokenStorage() +) +``` + +## 3. Restore the session on launch + +```swift +@main +struct MyApp: App { + @State private var authManager = OAuthManager(configuration: config) + + var body: some Scene { + WindowGroup { + ContentView() + .task { await authManager.checkExistingAuth() } + } + } +} +``` + +## 4. Show the sign-in UI + +```swift +import RxAuthSwiftUI + +RxSignInView( + manager: authManager, + appearance: RxSignInAppearance( + icon: .image(Image("MyLogo")), + title: "Welcome", + subtitle: "Sign in to continue", + signInButtonTitle: "Get Started", + accentColor: .purple, + secondaryColor: .pink + ), + onAuthSuccess: { /* navigate home */ }, + onAuthFailed: { error in /* show alert */ } +) +``` + +See the [SwiftUI module reference](../code/rxauthswiftui.md) for the advanced +ViewBuilder initializer and every appearance option. + +## 5. React to auth state + +```swift +switch authManager.authState { +case .unknown: ProgressView() +case .unauthenticated: RxSignInView(manager: authManager) +case .authenticated: HomeView(user: authManager.currentUser) +} +``` + +## 6. Log out + +```swift +await authManager.logout() +``` + +## 7. Handle session expiry + +```swift +NotificationCenter.default.addObserver( + forName: .rxAuthSessionExpired, + object: nil, + queue: .main +) { _ in + // Session could not be refreshed; route back to sign-in. +} +``` diff --git a/docs/guides/passkeys.md b/docs/guides/passkeys.md new file mode 100644 index 0000000..2e384b5 --- /dev/null +++ b/docs/guides/passkeys.md @@ -0,0 +1,107 @@ +--- +slug: guides/passkeys +title: Passkeys & Native Auth +description: Configure and use passkey sign-in, registration, automatic upgrade, and system-sheet account creation +--- + +# Passkeys & Native Authentication + +RxAuthSwift can run several passwordless and native flows in addition to the +browser-based authorization-code flow. Each flow is enabled purely by +configuring the matching endpoint pair on `RxAuthConfiguration` — if the +endpoints are absent, the corresponding `supports…` flag on `OAuthManager` +returns `false` and the UI hides that method. + +## Native password sign-in & sign-up + +For native macOS sign-in, `RxSignInView` renders username/password fields +instead of launching a browser. Password sign-in posts an OAuth `password` +grant to `nativePasswordTokenPath` (falling back to `tokenPath`): + +```swift +let config = RxAuthConfiguration( + issuer: "https://auth.example.com", + clientID: "your-client-id", + redirectURI: "yourapp://callback", + nativePasswordTokenPath: "/api/oauth/token", + nativeSignupPath: "/api/oauth/signup" +) +``` + +- `authManager.authenticate(username:password:)` performs the password grant. +- `authManager.signUp(username:password:name:)` POSTs JSON + (`client_id`, `username`, `password`, optional `name`, `scope`) to the signup + endpoint and returns a `SignupResult`: + - `.authenticated` — tokens were issued and the user is signed in. + - `.emailVerificationRequired(email:)` — the account was created but the + server requires email verification first. `OAuthManager` surfaces a message + via `infoMessage`. + +## Passkey sign-in & registration + +Enable passkey authentication and registration by configuring the challenge / +verification endpoint pairs: + +```swift +let config = RxAuthConfiguration( + issuer: "https://auth.example.com", + clientID: "your-client-id", + redirectURI: "yourapp://callback", + passkeyChallengePath: "/api/passkeys/authentication/options", + passkeyVerificationPath: "/api/passkeys/authentication/verify", + passkeyRegistrationChallengePath: "/api/passkeys/registration/options", + passkeyRegistrationVerificationPath: "/api/passkeys/registration/verify", + passkeyRelyingPartyIdentifier: "auth.example.com" +) +``` + +- `authManager.authenticateWithPasskey(username:)` runs a WebAuthn assertion + ceremony and exchanges it for tokens. +- `authManager.signUpWithPasskey(username:name:)` runs a registration ceremony. + +The authentication challenge endpoint returns a base64url WebAuthn challenge, +an optional `requestID`/session, an optional relying-party identifier, and +optional allowed credential IDs. The registration challenge endpoint returns a +base64url challenge and user ID plus optional session/username/RP identifier. +The relying-party identifier is resolved in this order: the value in the +challenge response → `passkeyRelyingPartyIdentifier` → the host of `issuer`. + +> Passkey ceremonies are implemented for macOS. On other platforms the +> passkey methods throw `OAuthError.passkeyUnavailable`. + +## Automatic passkey upgrade + +After a successful password sign-in or sign-up, the library can silently ask +the OS to provision a passkey so the next sign-in is passwordless. Configure +the upgrade endpoint pair: + +```swift +passkeyUpgradeChallengePath: "/api/passkeys/upgrade/options", +passkeyUpgradeVerificationPath: "/api/passkeys/upgrade/verify", +``` + +- **Automatic (fire-and-forget):** triggered after `authenticate(username:password:)`. + Errors are swallowed by design — the user never sees an upgrade prompt. +- **Interactive (post-signup offer):** when `supportsPasskeyUpgrade` is true, + a successful `signUp(...)` sets `pendingPasskeyOffer = true` and holds + `authState` at `.unauthenticated` so the host can present an "Add a passkey" + prompt. Resolve it by calling either: + - `addPasskeyForCurrentUser()` — runs the interactive registration ceremony + (Bearer-authenticated with the just-issued token), then finalizes the + session, or + - `skipPasskeyUpgradeOffer()` — finalizes the session without a passkey. + +## System-sheet account creation (iOS 26 / macOS 26) + +`createAccountWithPasskey(acceptedIdentifiers:shouldRequestName:)` drives +Apple's `ASAuthorizationAccountCreationProvider`. The OS presents a native +sheet that pulls email/phone/name from iCloud, confirms with biometrics, and +produces a passkey — no in-app form. Enable it with: + +```swift +passkeyAccountCreationOptionsPath: "/api/passkeys/account-creation/options", +passkeyAccountCreationVerifyPath: "/api/passkeys/account-creation/verify", +``` + +Emit the `passkey_account_creation` supported method only on the **signup** +flow of your UI schema. diff --git a/docs/guides/server-driven-ui-schema.md b/docs/guides/server-driven-ui-schema.md new file mode 100644 index 0000000..570f7a2 --- /dev/null +++ b/docs/guides/server-driven-ui-schema.md @@ -0,0 +1,88 @@ +--- +slug: guides/server-driven-ui-schema +title: Server-Driven UI Schema +description: How RxAuthSwift fetches and renders the sign-in/sign-up form from a backend-provided schema +--- + +# 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. + +## Fetching + +The schema is fetched from: + +``` +GET {issuer}{uiSchemaPath}/{flow}?client_id={clientID} +``` + +`uiSchemaPath` defaults to `/api/auth/ui-schema`; `flow` is `signin` or +`signup`. Load both flows at once with: + +```swift +let (signIn, signUp) = await authManager.loadUISchema() +``` + +Results are stored on the manager as `signInSchema` and `signUpSchema`, with +`isLoadingSchema` reflecting progress. A single flow can be fetched with +`fetchUISchema(flow:)`. A failed or missing schema returns `nil` and the UI +falls back to its defaults rather than erroring. + +## Schema shape + +```swift +public struct AuthUISchema: Codable, Sendable, Equatable { + public enum Flow: String { case signin, signup } + + public let flow: Flow + public let title: String + public let submitLabel: String + public let fields: [Field] + public let supportedMethods: [SupportedMethod] + public let links: [Link]? +} +``` + +### Field + +| Property | Type | Notes | +| --- | --- | --- | +| `key` | `String` | Unique field key (also its `id`). | +| `label` | `String` | Display label. | +| `placeholder` | `String?` | Optional placeholder. | +| `type` | `text` \| `email` \| `password` \| `name` | Keyboard / semantics. | +| `isPassword` | `Bool` | Secure entry. | +| `required` | `Bool` | Validation. | +| `autocomplete` | `String?` | Text-content type hint. | +| `validation` | `Validation?` | `minLength`, `maxLength`, `pattern`, `patternMessage`. | + +`Field.validate(_:)` returns a user-facing error string when a value fails its +rules, or `nil` when it passes. Required-but-empty, length, and regex checks +are applied in that order. + +### SupportedMethod + +```swift +public enum MethodID: String { + case password + case passkey + case passkeyAccountCreation = "passkey_account_creation" +} +``` + +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. + +### Link + +Footer links (`id`, `label`, `href`) — e.g. "Forgot password?" or terms of +service. + +## Previewing + +In `DEBUG` builds, `OAuthManager._previewInject(signIn:signUp:)` injects +pre-baked schemas so SwiftUI previews can render the native form without +hitting the network. diff --git a/scripts/upload_docs.py b/scripts/upload_docs.py new file mode 100755 index 0000000..82cd615 --- /dev/null +++ b/scripts/upload_docs.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Upload markdown docs under docs/ to the autopilot docs service. + +Stdlib-only. Walks docs/, parses YAML-ish frontmatter, keeps files that +declare a `slug`, errors on duplicate slugs, and POSTs the documents in +batches to the docs service. + +Env config: + DOCS_ENDPOINT default https://autopilot.rxlab.app + DOCS_REPOSITORY_ID e.g. owner/repo (required unless --dry-run) + DOCS_UPLOAD_TOKEN bearer token (required unless --dry-run) + +Usage: + python scripts/upload_docs.py [--dry-run] [--docs-dir docs] +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +BATCH_SIZE = 50 +DEFAULT_ENDPOINT = "https://autopilot.rxlab.app" + + +def find_repo_root() -> str: + # scripts/ lives at the repo root; docs are resolved relative to it. + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def parse_frontmatter(text: str): + """Return (metadata: dict, body: str). + + Supports a leading `---` fenced YAML block with simple `key: value` + pairs (no nested structures, which the doc frontmatter never uses). + Returns ({}, text) when no frontmatter is present. + """ + if not text.startswith("---"): + return {}, text + + lines = text.splitlines(keepends=True) + # lines[0] is the opening fence. + end = None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + end = i + break + if end is None: + return {}, text + + meta = {} + for raw in lines[1:end]: + line = raw.rstrip("\n") + if not line.strip() or line.lstrip().startswith("#"): + continue + if ":" not in line: + continue + key, _, value = line.partition(":") + value = value.strip() + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + meta[key.strip()] = value + + body = "".join(lines[end + 1 :]).lstrip("\n") + return meta, body + + +def collect_documents(docs_dir: str): + documents = [] + slug_sources: dict[str, str] = {} + + for root, _dirs, files in os.walk(docs_dir): + for name in sorted(files): + if not name.endswith((".md", ".markdown")): + continue + path = os.path.join(root, name) + with open(path, "r", encoding="utf-8") as fh: + text = fh.read() + meta, body = parse_frontmatter(text) + slug = meta.get("slug") + if not slug: + rel = os.path.relpath(path, docs_dir) + print(f" skip (no slug): {rel}", file=sys.stderr) + continue + if slug in slug_sources: + raise SystemExit( + f"Duplicate slug '{slug}' in {os.path.relpath(path, docs_dir)} " + f"and {os.path.relpath(slug_sources[slug], docs_dir)}" + ) + slug_sources[slug] = path + documents.append({"docId": slug, "content": body}) + + return documents + + +def batched(items, size): + for i in range(0, len(items), size): + yield items[i : i + size] + + +def post_batch(endpoint: str, repo_id: str, token: str, batch): + url = ( + f"{endpoint.rstrip('/')}/api/v1/docs/repositories/" + f"{urllib.parse.quote(repo_id, safe='')}/documents" + ) + payload = json.dumps({"documents": batch}).encode("utf-8") + request = urllib.request.Request(url, data=payload, method="POST") + request.add_header("Content-Type", "application/json") + request.add_header("Authorization", f"Bearer {token}") + + with urllib.request.urlopen(request) as response: + status = response.getcode() + body = response.read().decode("utf-8", errors="replace") + return status, body + + +def main() -> int: + parser = argparse.ArgumentParser(description="Upload docs to autopilot") + parser.add_argument( + "--dry-run", + action="store_true", + help="Parse and batch without making any network call", + ) + parser.add_argument( + "--docs-dir", + default=os.path.join(find_repo_root(), "docs"), + help="Directory to scan for markdown docs (default: docs/)", + ) + args = parser.parse_args() + + endpoint = os.environ.get("DOCS_ENDPOINT", DEFAULT_ENDPOINT) + repo_id = os.environ.get("DOCS_REPOSITORY_ID") + token = os.environ.get("DOCS_UPLOAD_TOKEN") + + if not os.path.isdir(args.docs_dir): + raise SystemExit(f"Docs directory not found: {args.docs_dir}") + + documents = collect_documents(args.docs_dir) + if not documents: + raise SystemExit("No documents with a `slug` frontmatter were found.") + + batches = list(batched(documents, BATCH_SIZE)) + print( + f"Collected {len(documents)} document(s) in {len(batches)} batch(es) " + f"of up to {BATCH_SIZE}." + ) + for doc in documents: + print(f" - {doc['docId']} ({len(doc['content'])} chars)") + + if args.dry_run: + print("Dry run: no network call made.") + return 0 + + if not repo_id: + raise SystemExit("DOCS_REPOSITORY_ID is required (e.g. owner/repo).") + if not token: + raise SystemExit("DOCS_UPLOAD_TOKEN is required.") + + for index, batch in enumerate(batches, start=1): + try: + status, body = post_batch(endpoint, repo_id, token, batch) + except urllib.error.HTTPError as error: + detail = error.read().decode("utf-8", errors="replace") + raise SystemExit( + f"Batch {index}/{len(batches)} failed: HTTP {error.code} {detail}" + ) + except urllib.error.URLError as error: + raise SystemExit(f"Batch {index}/{len(batches)} failed: {error.reason}") + print(f"Batch {index}/{len(batches)} -> HTTP {status} {body}".rstrip()) + + print("Upload complete.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())