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
22 changes: 22 additions & 0 deletions docs/docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,25 @@ if (client.TokenProvider is { } provider)
};
}
```

## When a Session Ends

Both `SessionTokenProvider` and the OAuth `DPoPTokenProvider` implement `INotifySessionInvalidated`.
`SessionInvalidated` is raised once when the server rejects the refresh token (for example
`ExpiredToken`, `InvalidToken` or `invalid_grant`). The provider drops its tokens first; delete any
stored session and ask the user to sign in again. Network errors, 5xx responses and rate limits do
not raise it.

```csharp
if (client.TokenProvider is INotifySessionInvalidated notifier)
{
notifier.SessionInvalidated += (sender, args) =>
{
// args.Did, args.Reason
RemoveStoredSession(args.Did);
};
}
```

`RefreshAsync` always refreshes, even when the current access token has not expired yet; concurrent
callers share one refresh.
20 changes: 20 additions & 0 deletions docs/docs/common-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,26 @@ foreach (var item in timeline.Feed)
}
```

## Upload and Download Blobs

Blob calls use the client's own authentication, so they work with app-password and OAuth (DPoP)
clients alike. The upload streams the content without buffering it.

```csharp
using CarpaNet.Blob;
using CarpaNet.Http;

await using var file = File.OpenRead("photo.jpg");
var progress = new Progress<long>(sent => Console.WriteLine($"{sent} bytes"));
var blobRef = await client.UploadBlobAsync(new ProgressReportingStream(file, progress), "image/jpeg");

// Generated records use ATBlob
ATBlob image = blobRef.ToATBlob();

// Another account's blob is fetched from that account's PDS, without your credentials.
byte[] data = await client.DownloadBlobAsync(new ATDid(ownerDid), image.Ref);
```

## AT Protocol Types

CarpaNet provides strongly-typed wrappers for AT Protocol identifiers:
Expand Down
59 changes: 59 additions & 0 deletions docs/docs/creating-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ var client = ATProtoClientFactory.Create(new ATProtoClientOptions
});
```

When CarpaNet creates the `HttpClient` itself, the rate-limit, timeout and `UserAgent` options are
applied to it. When you pass your own `HttpClient`, compose its handlers yourself (for example with
`HttpClientFactory.Create(new HttpClientFactoryOptions { ... })`); `UserAgent` is then added to
each request unless the `HttpClient` already sets one.

## Restoring a Session

```csharp
Expand Down Expand Up @@ -78,3 +83,57 @@ if (client.TokenProvider is { } provider)
};
}
```

## Per-Request Options: Proxies, Labelers, Headers and Other Services

`WithRequestOptions` (and the shortcuts below) return a `ScopedATProtoClient` that shares the
session and HTTP pipeline of the client it wraps. Every call made through it, including the
generated API methods, uses the options.

```csharp
// Send app.bsky calls to the Bluesky AppView through the PDS.
var appview = client.WithProxy("did:web:api.bsky.app#bsky_appview");

// Let the PDS answer itself (no atproto-proxy header), e.g. for preferences.
var pds = client.WithoutProxy();

// Extra headers for one kind of call.
var feeds = appview.WithHeader("Accept-Language", "en,de");

// Accepted labelers for this scope only.
var scoped = appview.WithAcceptLabelers(new[] { AcceptLabelersHeader.Redact(modDid), myLabelerDid });

// A different service. Session credentials are never sent there; add any token yourself.
var video = client
.WithServiceUrl(new Uri("https://video.bsky.app"))
.WithHeader("Authorization", $"Bearer {serviceAuthToken}");
```

Options set on an outer scope win over inner scopes and over the proxy a generated method would use
(the `chat.bsky.*` methods proxy to the chat service by default).

To change the accepted labelers for every request on a client, call `SetLabelerDids`:

```csharp
client.SetLabelerDids(new[] { AcceptLabelersHeader.Redact(modDid), subscribedLabelerDid });
```

### Where credentials are sent

The access token (or DPoP proof) is attached only to requests that go to the session's own PDS.
A query with a `repo` parameter for another account is sent to that account's PDS without
credentials, and so is anything sent with `WithServiceUrl`.

### Binary bodies and responses

Procedures with a non-JSON body (`com.atproto.repo.uploadBlob`, `app.bsky.video.uploadPart`) and
queries with a non-JSON response (`com.atproto.sync.getBlob`) are generated to take a `Stream` and
return `byte[]`. They can also be called directly:

```csharp
var output = await client.PostBinaryAsync<MyOutput>(nsid, proxyServiceDid: null, parameters, stream, "video/mp4");
byte[] car = await client.GetBytesAsync("com.atproto.sync.getRepo", null, parameters);
```

A body from a seekable stream is replayed after a token refresh; a non-seekable body is sent once.
Wrap a stream in `ProgressReportingStream` to report upload progress.
46 changes: 46 additions & 0 deletions docs/docs/identity-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,49 @@ var didDoc2 = await resolver.ResolveAsync("did:plc:z72i7hdynmk6r22z27h6tvur");
```

The `ATProtoClient` creates an `IdentityResolver` automatically (configurable via `ATProtoClientOptions.CreateIdentityResolver`).

## Handle resolution methods

A handle is resolved to a DID with these methods. They are tried in order, and the first method that returns a DID wins:

1. **DNS** – the TXT record at `_atproto.<handle>`, through an `IDnsResolver`.
2. **Well-known** – `https://<handle>/.well-known/atproto-did`.
3. **XRPC** – `com.atproto.identity.resolveHandle` on a service that you configure, such as your PDS or `https://public.api.bsky.app`. This method is skipped if no service URL is set.

Use `IdentityResolverOptions` to change the order or to add the XRPC service:

```csharp
var resolver = new IdentityResolver(httpClient, new IdentityResolverOptions
{
Cache = new MemoryIdentityCache(),
HandleResolutionServiceUrl = IdentityResolverOptions.PublicBlueskyAppViewUrl,
// Optional. The default order is Dns, WellKnown, Xrpc.
HandleResolutionOrder = new[] { HandleResolutionMethod.Xrpc },
});
```

> **Trust:** A DID from the XRPC method is only as trustworthy as the service. The client does not check the handle's DNS record or well-known file. `ResolveAsync` still checks that the DID document claims the handle (`alsoKnownAs`), for all methods.

## DNS resolvers

| Resolver | Transport | Use |
| --- | --- | --- |
| `DefaultDnsResolver` | Raw UDP to `1.1.1.1` and `8.8.8.8` | Desktop, server and mobile |
| `DnsOverHttpsResolver` | HTTPS JSON API (`application/dns-json`) to Cloudflare, then Google | Browsers (WebAssembly), and networks that block UDP |

If you do not give a DNS resolver, `IdentityResolver` calls `DnsResolverDefaults.CreateDefault(httpClient)`. This returns `DnsOverHttpsResolver` in a browser or under WASI, and `DefaultDnsResolver` on other platforms.

```csharp
// Force DNS-over-HTTPS, with custom endpoints and a 3-second timeout for each endpoint
var dns = new DnsOverHttpsResolver(
httpClient,
new[] { DnsOverHttpsResolver.GoogleEndpoint, DnsOverHttpsResolver.CloudflareEndpoint },
TimeSpan.FromSeconds(3));
var resolver = new IdentityResolver(httpClient, new IdentityResolverOptions { DnsResolver = dns });
```

`DnsOverHttpsResolver` tries the next endpoint if a request fails, times out, returns malformed JSON or returns a DNS error such as SERVFAIL. An NXDOMAIN answer returns an empty list.

### Browsers

In a browser, the well-known request is usually blocked by CORS. For reliable handle resolution, set `HandleResolutionServiceUrl` to your PDS or to an AppView.
50 changes: 50 additions & 0 deletions docs/docs/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,56 @@ var authUrl = await oauthSession.AuthorizeAsync(userHandle);
var atClient = await oauthSession.CallbackAsync(Request.Url.ToString());
```

## Building the Scope

`OAuthClientConfig.Scope` is a space-separated string. You can build it with `ScopeSet` (namespace `CarpaNet.OAuth.Scopes`). `ScopeSet` formats each permission in the normalized atproto scope syntax.

```csharp
using CarpaNet.OAuth.Scopes;

var scopes = new ScopeSet()
.AddAtproto() // atproto (required)
.AddRepo("app.bsky.feed.post", RepoActions.Create) // repo:app.bsky.feed.post?action=create
.AddBlob("image/*") // blob:image/*
.AddRpc("app.bsky.actor.getProfile",
"did:web:api.bsky.app#bsky_appview") // rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview
.AddAccount(AccountAttribute.Email) // account:email
.AddIdentity(IdentityAttribute.Handle) // identity:handle
.AddInclude("com.example.authBasic"); // include:com.example.authBasic

config.SetScope(scopes); // throws if "atproto" is missing
```

To read or check scopes:

- `AtprotoScope.IsValid(value)` and `AtprotoScope.Normalize(scope)` validate and normalize scope strings.
- `RepoPermission.TryParse`, `RpcPermission.TryParse`, `BlobPermission.TryParse`, `AccountPermission.TryParse`, `IdentityPermission.TryParse` and `IncludeScope.TryParse` parse one scope value.
- `ScopeSet.Parse(grantedScope).MatchesRepo("app.bsky.feed.post", RepoActions.Create)` checks a granted scope. `MatchesRpc`, `MatchesBlob`, `MatchesAccount` and `MatchesIdentity` do the same for the other resources.

The library does not expand `include:` scopes into the permissions of their lexicon permission set.

## Callback Validation

`CallbackAsync` validates the authorization response before it creates the session. When a check fails, it throws `OAuthCallbackException` (with `AppState` set) and does not store a session.

- **`iss` parameter (RFC 9207).** If the callback has an `iss` parameter, it must equal the issuer that the flow started with (`issuer_mismatch`). If the server metadata has `authorization_response_iss_parameter_supported: true`, the parameter is required (`missing_iss`). These checks occur before the code is exchanged.
- **Token subject.** The `sub` of the token response must be an atproto DID (`invalid_sub`). If you started the flow with a handle or DID, `sub` must be that account's DID (`sub_mismatch`). The library resolves the DID document of `sub` and reads the protected resource metadata of its PDS. The `authorization_servers` list must contain the issuer that issued the tokens (`sub_issuer_mismatch`). When one of these checks fails, the library revokes the tokens.
- **PDS URL.** The session uses the PDS from the DID document of `sub`. This is also true when you start the flow with a PDS or entryway URL (for example `https://bsky.social`).

If you implement a persistent `IOAuthStateStore`, also store `OAuthStateData.ExpectedSub`. If it is not stored, the `sub_mismatch` check does not occur.

## Token Refresh

Before each refresh, the session resolves the account's DID document again (bypassing the cache) and
checks that its PDS still names the same authorization server. If the account has moved to a PDS
behind another authorization server, the refresh is not attempted and `SessionInvalidated` is raised
with the reason `issuer_mismatch`; a failed lookup only fails that refresh. The refreshed tokens use
the PDS from the DID document as their audience.

DPoP proofs are single use. When the session's `HttpClient` includes `RateLimitHandler`, the OAuth
client registers a callback (`RateLimitHandler.SetRetryPreparer`) so each 429 retry is signed with a
new proof. A DPoP-signed request without such a callback is not retried by the handler.

## Restoring an OAuth Session

```csharp
Expand Down
6 changes: 4 additions & 2 deletions docs/docs/project-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ This scans your lexicons for `ref` fields pointing to external NSIDs, resolves t
|----------|---------|-------------|
| `CarpaNet_JsonContextName` | `ATProtoJsonContext` | Name of the generated JSON serializer context |
| `CarpaNet_CborContextName` | `ATProtoCborContext` | Name of the generated CBOR serializer context |
| `CarpaNet_SourceGen_RootNamespace` | Project namespace | Root namespace for generated code |
| `CarpaNet_SourceGen_EmitValidationAttributes` | `false` | Emit `[ATStringLength]`, `[Range]` attributes |
| `CarpaNet_RootNamespace` | None (from NSID) | Root namespace prefix for generated code |
| `CarpaNet_EmitValidationAttributes` | `true` | Emit `[ATStringLength]`, `[ATRange]` validation attributes |
| `CarpaNet_LexiconAutoResolve` | `false` | Auto-resolve transitive lexicon dependencies |
| `CarpaNet_LexiconAutoResolveMaxDepth` | `10` | Max iterations for transitive resolution |
| `CarpaNet_LexiconCacheDir` | `obj/lexicon-cache/` | Cache directory for resolved lexicons |
Expand All @@ -87,6 +87,8 @@ This scans your lexicons for `ref` fields pointing to external NSIDs, resolves t
| `CarpaNet_PlcDirectoryUrl` | `https://plc.directory` | PLC directory URL |
| `CarpaNet_DnsServers` | (empty) | Semicolon-separated DNS server IPs |

The older `CarpaNet_SourceGen_RootNamespace`, `CarpaNet_SourceGen_JsonContextName`, `CarpaNet_SourceGen_CborContextName` and `CarpaNet_SourceGen_EmitValidationAttributes` names still work. If both names are set, the `CarpaNet_*` name wins.

## Inspecting Generated Code

Roslyn allows for emiting the compiler generated files. This makes it easy to debug (and for LLMs to inspect, as it were.)
Expand Down
19 changes: 17 additions & 2 deletions skills/carpanet/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ This scans your lexicons for `ref` fields pointing to external NSIDs, resolves t
|----------|---------|-------------|
| `CarpaNet_JsonContextName` | `ATProtoJsonContext` | Name of the generated JSON serializer context |
| `CarpaNet_CborContextName` | `ATProtoCborContext` | Name of the generated CBOR serializer context |
| `CarpaNet_SourceGen_RootNamespace` | Project namespace | Root namespace for generated code |
| `CarpaNet_SourceGen_EmitValidationAttributes` | `false` | Emit `[ATStringLength]`, `[Range]` attributes |
| `CarpaNet_RootNamespace` | None (from NSID) | Root namespace prefix for generated code |
| `CarpaNet_EmitValidationAttributes` | `true` | Emit `[ATStringLength]`, `[ATRange]` validation attributes |
| `CarpaNet_LexiconAutoResolve` | `false` | Auto-resolve transitive lexicon dependencies |
| `CarpaNet_LexiconAutoResolveMaxDepth` | `10` | Max iterations for transitive resolution |
| `CarpaNet_LexiconCacheDir` | `obj/lexicon-cache/` | Cache directory for resolved lexicons |
Expand All @@ -102,6 +102,8 @@ This scans your lexicons for `ref` fields pointing to external NSIDs, resolves t
| `CarpaNet_PlcDirectoryUrl` | `https://plc.directory` | PLC directory URL |
| `CarpaNet_DnsServers` | (empty) | Semicolon-separated DNS server IPs |

The older `CarpaNet_SourceGen_RootNamespace`, `CarpaNet_SourceGen_JsonContextName`, `CarpaNet_SourceGen_CborContextName` and `CarpaNet_SourceGen_EmitValidationAttributes` names still work. If both names are set, the `CarpaNet_*` name wins.

### Inspecting Generated Code

```xml
Expand Down Expand Up @@ -547,6 +549,19 @@ var didDoc2 = await resolver.ResolveAsync("did:plc:z72i7hdynmk6r22z27h6tvur");

The `ATProtoClient` creates an `IdentityResolver` automatically (configurable via `ATProtoClientOptions.CreateIdentityResolver`).

Handle resolution tries DNS TXT, then HTTPS well-known, then the `com.atproto.identity.resolveHandle` XRPC method (only if a service URL is set). In browsers (WASM), use DNS-over-HTTPS and an XRPC service, because UDP is unavailable and well-known requests are usually blocked by CORS:

```csharp
var resolver = new IdentityResolver(httpClient, new IdentityResolverOptions
{
Cache = new MemoryIdentityCache(),
DnsResolver = new DnsOverHttpsResolver(httpClient), // default in browsers via DnsResolverDefaults.CreateDefault
HandleResolutionServiceUrl = IdentityResolverOptions.PublicBlueskyAppViewUrl, // or the user's PDS
});
```

A DID from the XRPC method is only as trustworthy as the service. `ResolveAsync` still checks that the DID document claims the handle.

---

## Repository & CAR File Reading
Expand Down
Loading
Loading