OAuth
AT Protocol uses OAuth 2.0 with two mandatory security extensions: DPoP (Demonstration of Proof-of-Possession, RFC 9449) binds access tokens to a cryptographic key pair so stolen tokens cannot be replayed, and PKCE (Proof Key for Code Exchange, RFC 7636) prevents authorization code interception. FSharp.ATProto provides both an OAuth client for applications that authenticate users and an OAuth server for PDS operators.
OAuth Client
FSharp.ATProto.OAuth implements the full client-side OAuth flow. Use this when building an application that needs to authenticate Bluesky users through their browser.
open FSharp.ATProto.OAuth
open System.Net.Http
Discovery
Before starting an authorization flow, discover the authorization server for the user's PDS. Discovery.discover performs two-step discovery: it fetches the PDS's protected resource metadata (RFC 9728) to find the authorization server, then fetches the authorization server metadata (RFC 8414):
let httpClient = new HttpClient()
let! serverMetadata = Discovery.discover httpClient "https://bsky.social"
You can also discover each step separately with Discovery.discoverProtectedResource and Discovery.discoverAuthorizationServer.
Authorization Flow
The flow has three phases: start, user approval, and completion.
1. Start the authorization flow. OAuthClient.startAuthorization generates a PKCE challenge and DPoP key pair, submits a Pushed Authorization Request (PAR) if the server supports it, and returns the URL to redirect the user to:
let clientMetadata : ClientMetadata =
{ ClientId = "https://myapp.example.com/client-metadata.json"
ClientUri = Some "https://myapp.example.com"
RedirectUris = [ "https://myapp.example.com/callback" ]
Scope = "atproto transition:generic"
GrantTypes = [ "authorization_code"; "refresh_token" ]
ResponseTypes = [ "code" ]
TokenEndpointAuthMethod = "none"
ApplicationType = "web"
DpopBoundAccessTokens = true }
// serverMetadata is the AuthorizationServerMetadata from discovery
let! result = OAuthClient.startAuthorization httpClient clientMetadata serverMetadata "https://myapp.example.com/callback"
let authorizationUrl, authState = result
// Redirect the user's browser to authorizationUrl
// Save authState for the callback
The AuthorizationState record holds the PKCE verifier, DPoP key pair, and server metadata. You must persist this between the redirect and the callback.
2. User approves in their browser. The authorization server redirects back to your redirect_uri with a code and state parameter.
3. Exchange the code for tokens. OAuthClient.completeAuthorization sends the authorization code with the PKCE verifier and a DPoP proof to get an access token:
let! session = OAuthClient.completeAuthorization httpClient clientMetadata authState authorizationCode
This returns an OAuthSession containing DPoP-bound tokens:
type OAuthSession = {
AccessToken: string
RefreshToken: string option
ExpiresAt: DateTimeOffset
Did: Did
DpopKeyPair: ECDsa
TokenEndpoint: string
}
Using the Session with AtpAgent
OAuthBridge.resumeSession bridges an OAuthSession to an AtpAgent, so all convenience functions (Bluesky.post, Bluesky.like, etc.) work with OAuth authentication. It configures DPoP proof generation on every request and automatic token refresh on 401:
open FSharp.ATProto.Core
open FSharp.ATProto.Bluesky
let agent = AtpAgent.create "https://bsky.social"
let authedAgent =
agent
|> OAuthBridge.resumeSession clientMetadata session (Some (fun newSession ->
// Persist the refreshed session to disk or database
saveSession newSession))
// Now use any convenience function
taskResult {
let! postRef = Bluesky.post authedAgent "Hello from OAuth!"
return postRef
}
The optional onSessionUpdate callback fires whenever the token is refreshed, so you can persist the new session.
Refreshing Tokens
If you manage token lifecycle yourself instead of using OAuthBridge, call OAuthClient.refreshToken directly:
let! newSession = OAuthClient.refreshToken httpClient clientMetadata session
Check expiration with OAuthBridge.isExpired:
if OAuthBridge.isExpired session then
// refresh the token
()
DPoP Utilities
The DPoP module provides low-level utilities if you need to work with DPoP proofs directly:
Function |
Purpose |
|---|---|
|
Generate an ES256 (P-256) key pair |
|
Generate a PKCE verifier and S256 challenge |
|
Create a DPoP proof JWT |
|
SHA-256 hash for the |
Error Handling
All OAuth operations return Result<'T, OAuthError>:
type OAuthError =
| DiscoveryFailed of message: string
| TokenRequestFailed of error: string * description: string option
| DPoPError of message: string
| InvalidState of message: string
| NetworkError of message: string
OAuth Server
FSharp.ATProto.OAuthServer implements an AT Protocol-compliant authorization server. Use this if you are running your own PDS and need to issue tokens to client applications.
The server enforces the AT Protocol's mandatory security requirements: Pushed Authorization Requests (PAR), DPoP-bound tokens, and PKCE with S256.
open FSharp.ATProto.OAuthServer
Endpoints
The server exposes these routes:
Route |
Method |
Purpose |
|---|---|---|
|
GET |
Server metadata (RFC 8414) |
|
GET |
Protected resource metadata (RFC 9728) |
|
GET |
Public signing keys |
|
POST |
Pushed Authorization Requests |
|
GET |
Authorization endpoint |
|
POST |
Token exchange and refresh |
|
POST |
Token revocation |
|
POST |
Consent UI: user authentication |
|
POST |
Consent UI: approve authorization |
|
POST |
Consent UI: deny authorization |
Pluggable Storage
The server defines four store interfaces for persistence. All have in-memory implementations for development; swap them out for your production database:
Interface |
Purpose |
|---|---|
|
Issued access and refresh tokens |
|
Pending authorization requests |
|
DPoP nonce replay detection |
|
User authentication and account lookup |
Configuration
Use the builder pattern to configure and launch the server:
let app =
OAuthServer.defaults
|> OAuthServer.withIssuer "https://auth.example.com"
|> OAuthServer.withPort 4000
|> OAuthServer.withAccountStore myAccountStore
|> OAuthServer.withTokenStore myTokenStore
|> OAuthServer.configure
app.Run()
All builder functions:
Function |
Purpose |
|---|---|
|
Set the issuer URL (required) |
|
Set the listening port |
|
ES256 key for signing tokens (auto-generated if omitted) |
|
Set the service DID |
|
Custom token persistence |
|
Custom request persistence |
|
Custom replay detection |
|
Custom account authentication |
|
Token expiry (default: 5 minutes) |
|
Refresh token expiry (default: 90 days) |
|
Supported scopes (default: |
|
Path for the consent UI (default: |
If you omit the signing key, token store, request store, replay store, or account store, the server uses in-memory defaults. This is convenient for development but not suitable for production -- tokens and sessions are lost on restart.
namespace FSharp
--------------------
namespace Microsoft.FSharp
type HttpClient = inherit HttpMessageInvoker new: unit -> unit + 2 overloads member CancelPendingRequests: unit -> unit member DeleteAsync: requestUri: string -> Task<HttpResponseMessage> + 3 overloads member GetAsync: requestUri: string -> Task<HttpResponseMessage> + 7 overloads member GetByteArrayAsync: requestUri: string -> Task<byte array> + 3 overloads member GetStreamAsync: requestUri: string -> Task<Stream> + 3 overloads member GetStringAsync: requestUri: string -> Task<string> + 3 overloads member PatchAsync: requestUri: string * content: HttpContent -> Task<HttpResponseMessage> + 3 overloads member PostAsync: requestUri: string * content: HttpContent -> Task<HttpResponseMessage> + 3 overloads ...
<summary>Provides a class for sending HTTP requests and receiving HTTP responses from a resource identified by a URI.</summary>
--------------------
HttpClient() : HttpClient
HttpClient(handler: HttpMessageHandler) : HttpClient
HttpClient(handler: HttpMessageHandler, disposeHandler: bool) : HttpClient
<summary> Authorization server metadata (RFC 8414). Discovered from the authorization server's well-known endpoint. </summary>
<summary> State for an in-progress authorization flow. Save this between the redirect to the authorization server and the callback. </summary>
val string: value: 'T -> string
--------------------
type string = String
<summary> A completed OAuth session with DPoP-bound tokens. </summary>
<summary> Client metadata for OAuth registration. Describes the client application to the authorization server. </summary>
val string: value: 'T -> string
--------------------
type string = System.String
<summary> Bridges OAuth sessions with AtpAgent, enabling all convenience functions to work with DPoP-authenticated OAuth sessions. </summary>
<summary> Check whether the OAuth session's access token has expired. </summary>
<summary> Store for user account authentication and lookup. </summary>
<summary> Store for authorization tokens. </summary>