diff --git a/Sources/EffemKit/EffemKit.docc/Authentication.md b/Sources/EffemKit/EffemKit.docc/Authentication.md new file mode 100644 index 0000000..644e47f --- /dev/null +++ b/Sources/EffemKit/EffemKit.docc/Authentication.md @@ -0,0 +1,200 @@ +# Authentication + +Sign users in with AT Protocol OAuth and manage sessions. + +## Overview + +EffemKit re-exports CoreATProtocol's full OAuth stack under `Effem*` typealiases. You can import only EffemKit and get everything needed for authentication — no separate CoreATProtocol import required. + +The OAuth flow uses AT Protocol's standard DPoP-based OAuth with PKCE. EffemKit handles identity resolution (handle to DID to PDS to auth server), token signing, and automatic token refresh. + +## Configure OAuth + +Create an ``EffemOAuthConfig`` with your app's client metadata URL, redirect URI, and requested scopes: + +```swift +let config = EffemOAuthConfig( + clientMetadataURL: "https://effem.xyz/client-metadata.json", + redirectURI: "effem://oauth/callback", + scopes: ["atproto", "transition:generic"] +) +``` + +The `clientMetadataURL` must point to a publicly accessible JSON document that describes your OAuth client, as specified by the AT Protocol OAuth spec. + +## Run the Authentication Flow + +``EffemOAuth`` orchestrates the full sign-in process. Provide a user authenticator callback that presents the authorization URL (typically via `ASWebAuthenticationSession`) and returns the callback URL containing the authorization code: + +```swift +import EffemKit +import AuthenticationServices + +@APActor +func signIn(handle: String) async throws -> EffemAuthResult { + let config = EffemOAuthConfig( + clientMetadataURL: "https://effem.xyz/client-metadata.json", + redirectURI: "effem://oauth/callback", + scopes: ["atproto", "transition:generic"] + ) + + let oauth = EffemOAuth(config: config) + + let result = try await oauth.authenticate( + identifier: handle, + authenticator: { authURL in + // Present authURL to the user (e.g., ASWebAuthenticationSession) + // Return the callback URL after the user authorizes + return try await presentAuthSession(url: authURL) + } + ) + + // result.did — the user's DID + // result.handle — the user's handle + // result.accessToken — bearer token (also set in APEnvironment) + // result.pdsEndpoint — the user's PDS URL (also set in APEnvironment) + return result +} +``` + +After ``EffemOAuth/authenticate(identifier:authenticator:)`` completes, CoreATProtocol's `APEnvironment` is fully configured: + +- `APEnvironment.current.host` is set to the user's PDS +- `APEnvironment.current.accessToken` holds the bearer token +- `APEnvironment.current.dpopPrivateKey` holds the DPoP signing key + +Both ``EffemService`` and ``EffemRepoService`` are ready to use immediately. + +## Provide Auth Storage + +Implement ``EffemAuthStorage`` to persist tokens and private keys across app launches. The protocol requires async methods for storing and retrieving `Login` and `PrivateKey` data: + +```swift +final class MyAuthStorage: EffemAuthStorage { + func store(login: Login) async throws { + // Persist to Keychain or secure storage + } + + func retrieveLogin() async throws -> Login? { + // Load from Keychain or secure storage + } + + func store(privateKey: Data) async throws { + // Persist the DPoP private key (PEM format) + } + + func retrievePrivateKey() async throws -> Data? { + // Load the DPoP private key + } +} +``` + +Pass storage when creating the OAuth client: + +```swift +let storage = MyAuthStorage() +let oauth = EffemOAuth(config: config, storage: storage) +``` + +## Refresh Tokens + +CoreATProtocol's networking layer (`APRouterDelegate`) automatically detects 401/403 responses and attempts token refresh using the stored refresh token and DPoP key. This happens transparently — you don't need to handle token expiry manually. + +To force a token refresh: + +```swift +@APActor +func forceRefresh() async throws { + let oauth = EffemOAuth(config: config, storage: storage) + try await oauth.refreshLoginIfNeeded(force: true) +} +``` + +## Handle Errors + +Authentication can fail at multiple stages. ``EffemOAuthError`` covers all cases: + +```swift +@APActor +func handleSignIn(handle: String) async { + do { + let result = try await signIn(handle: handle) + // Success — store result.did for future API calls + } catch let error as EffemOAuthError { + switch error { + case .identityResolutionFailed: + // Could not resolve handle to DID/PDS + print("Could not find user: \(handle)") + case .tokenRequestFailed: + // OAuth token exchange failed + print("Authentication failed") + default: + print(error.localizedDescription) + } + } catch let error as EffemIdentityError { + // Handle/DID resolution specific errors + print("Identity error: \(error.localizedDescription)") + } catch { + print("Unexpected error: \(error.localizedDescription)") + } +} +``` + +## Full App Example + +Putting it all together — configure the AppView, authenticate, then use both services: + +```swift +import SwiftUI +import EffemKit + +@main +struct EffemApp: App { + init() { + Task { @APActor in + setup(appViewHost: "https://appview.effem.xyz") + } + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +@Observable +@APActor +final class AppState { + var userDID: String? + var isAuthenticated: Bool { userDID != nil } + + private let oauthConfig = EffemOAuthConfig( + clientMetadataURL: "https://effem.xyz/client-metadata.json", + redirectURI: "effem://oauth/callback", + scopes: ["atproto", "transition:generic"] + ) + + func signIn(handle: String, presentURL: @escaping (URL) async throws -> URL) async throws { + let oauth = EffemOAuth(config: oauthConfig) + let result = try await oauth.authenticate( + identifier: handle, + authenticator: presentURL + ) + userDID = result.did + } + + func loadTrending() async throws -> [PodcastResult] { + let service = EffemService() + let response = try await service.getTrending(max: 20) + return response.feeds + } + + func subscribe(feedId: Int) async throws { + guard let did = userDID else { return } + let repoService = EffemRepoService() + let podcast = PodcastRef(feedId: feedId) + _ = try await repoService.subscribe(to: podcast, repo: did) + } +} +``` diff --git a/Sources/EffemKit/EffemKit.docc/EffemKit.md b/Sources/EffemKit/EffemKit.docc/EffemKit.md index d110848..d2f38b8 100644 --- a/Sources/EffemKit/EffemKit.docc/EffemKit.md +++ b/Sources/EffemKit/EffemKit.docc/EffemKit.md @@ -19,6 +19,15 @@ All public API is isolated to the `@APActor` global actor from CoreATProtocol, p - ``setup(appViewHost:)`` - ``EffemEnvironment`` +### Authentication + +- +- ``EffemOAuth`` +- ``EffemOAuthConfig`` +- ``EffemAuthStorage`` +- ``EffemAuthResult`` +- ``EffemUserAuthenticator`` + ### Reading Data - @@ -79,3 +88,6 @@ All public API is isolated to the `@APActor` global actor from CoreATProtocol, p - ``EffemKitConfigurationError`` - ``EffemRepoError`` +- ``EffemOAuthError`` +- ``EffemIdentityError`` +- ``EffemErrorMessage`` diff --git a/Sources/EffemKit/EffemKit.docc/GettingStarted.md b/Sources/EffemKit/EffemKit.docc/GettingStarted.md index a28166a..4e4dc91 100644 --- a/Sources/EffemKit/EffemKit.docc/GettingStarted.md +++ b/Sources/EffemKit/EffemKit.docc/GettingStarted.md @@ -39,14 +39,31 @@ struct EffemApp: App { ## Authenticate the User -EffemKit relies on CoreATProtocol for OAuth authentication. Once the user signs in through CoreATProtocol, the PDS host is automatically available and ``EffemRepoService`` can write records: +EffemKit re-exports CoreATProtocol's OAuth types under `Effem*` names so you only need `import EffemKit`. Create an ``EffemOAuth`` instance, configure it, and run the authentication flow: ```swift -// After CoreATProtocol OAuth flow completes, -// APEnvironment.current.host is set to the user's PDS. -// No additional EffemKit configuration is needed for writes. +import EffemKit + +@APActor +func signIn(presentURL: @escaping (URL) async -> URL) async throws -> EffemAuthResult { + let config = EffemOAuthConfig( + clientMetadataURL: "https://effem.xyz/client-metadata.json", + redirectURI: "effem://oauth/callback", + scopes: ["atproto", "transition:generic"] + ) + + let oauth = EffemOAuth(config: config) + return try await oauth.authenticate( + identifier: "alice.bsky.social", + authenticator: presentURL + ) +} ``` +After authentication succeeds, CoreATProtocol's `APEnvironment` is automatically configured with the user's PDS host, access token, and DPoP keys. ``EffemRepoService`` can immediately write records — no additional setup needed. + +For a full walkthrough of the OAuth flow, token refresh, and session persistence, see . + ## Read Data with EffemService ``EffemService`` provides all read-only queries. Create an instance and call any method: