Service Auth
AT Protocol backend services -- feed generators, labelers, and other service providers -- authenticate to each other using signed JWTs. The ServiceAuth module in FSharp.ATProto.Core handles creating, parsing, and validating these tokens, and can configure an AtpAgent to attach service auth headers automatically.
Algorithm
type Algorithm = ES256 | ES256K
ES256 corresponds to P-256 ECDSA, ES256K to secp256k1 ECDSA. Most AT Protocol services use ES256.
Claims
Service auth JWTs carry these claims:
type Claims =
{ Iss : Did // Issuer -- your service's DID
Aud : Did // Audience -- the target service's DID
Lxm : Nsid option // Lexicon method being called (optional)
Exp : DateTimeOffset // Expiration time
Iat : DateTimeOffset } // Issued-at time
Creating Tokens
The createToken function takes a signing function (byte[] -> byte[]) that should produce a 64-byte compact ECDSA signature. If you are using FSharp.ATProto.Crypto, this is Signing.sign keyPair.
open FSharp.ATProto.Core
open FSharp.ATProto.Syntax
open FSharp.ATProto.Crypto
let keyPair = Keys.generate Algorithm.P256
let sign = Signing.sign keyPair
let iss = Did.parse "did:web:feed.example.com" |> Result.defaultWith failwith
let aud = Did.parse "did:plc:target-pds" |> Result.defaultWith failwith
// Full control over claims
let claims : ServiceAuth.Claims =
{ Iss = iss
Aud = aud
Exp = DateTimeOffset.UtcNow.AddMinutes 5.0
Iat = DateTimeOffset.UtcNow
Lxm = Nsid.parse "app.bsky.feed.getFeedSkeleton" |> Result.toOption }
let token = ServiceAuth.createToken ServiceAuth.Algorithm.ES256 sign claims
For the common case where you want a token that expires in 60 seconds:
let token2 =
ServiceAuth.createTokenNow
ServiceAuth.Algorithm.ES256
sign
iss
aud
(Nsid.parse "app.bsky.feed.getFeedSkeleton" |> Result.toOption)
Parsing and Validating Tokens
Parse claims from a JWT without verifying the signature:
match ServiceAuth.parseClaims token with
| Ok (claims, alg) ->
printfn "Issuer: %s" (Did.value claims.Iss)
printfn "Algorithm: %A" alg
| Error msg ->
printfn "Parse error: %s" msg
Validate a JWT by verifying both the signature and the expiration:
let verifyFn = Signing.verify (Keys.publicKey keyPair)
match ServiceAuth.validateToken verifyFn token with
| Ok claims -> printfn "Valid token from %s" (Did.value claims.Iss)
| Error msg -> printfn "Invalid: %s" msg
validateToken returns Error if the signature is invalid or the token has expired.
Agent Integration
withServiceAuth configures an AtpAgent to automatically attach a Bearer token to every request. The NSID is extracted from the request URL path, so each request gets a correctly scoped token.
open FSharp.ATProto.Core
open FSharp.ATProto.Crypto
let keyPair = Keys.generate Algorithm.P256
let sign = Signing.sign keyPair
let agent =
AtpAgent.create "https://bsky.social"
|> ServiceAuth.withServiceAuth
ServiceAuth.Algorithm.ES256
sign
iss // your service's DID
aud // the target PDS DID
The agent will generate a fresh JWT for each request with a 60-second expiry and the correct lxm claim.
namespace FSharp
--------------------
namespace Microsoft.FSharp
val int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
val byte: value: 'T -> byte (requires member op_Explicit)
--------------------
type byte = Byte
--------------------
type byte<'Measure> = byte
module Did from FSharp.ATProto.Syntax
<summary> Functions for creating, validating, and extracting data from <see cref="Did" /> values. </summary>
--------------------
type Did = private | Did of string override ToString: unit -> string
<summary> A decentralized identifier (DID) as defined by the AT Protocol. DIDs are the primary stable identifier for accounts. Two methods are currently supported: <c>did:plc:</c> (hosted, managed by PLC directory) and <c>did:web:</c> (self-hosted, DNS-based). </summary>
<remarks> See the AT Protocol specification: https://atproto.com/specs/did and the W3C DID specification: https://www.w3.org/TR/did-core/ </remarks>
<summary> Parse and validate a DID string. </summary>
<param name="s"> A DID string in the format <c>did:<method>:<method-specific-id></c> (e.g. <c>"did:plc:z72i7hdynmk6r22z27h6tvur"</c> or <c>"did:web:example.com"</c>). </param>
<returns><c>Ok</c> with a validated <see cref="Did" />, or <c>Error</c> with a message describing the validation failure. Validation failures include: null input, exceeding the 2048-character limit, invalid DID syntax, or invalid percent-encoding. </returns>
<example><code> match Did.parse "did:plc:z72i7hdynmk6r22z27h6tvur" with | Ok did -> printfn "Valid: %s" (Did.value did) | Error e -> printfn "Invalid: %s" e </code></example>
module Result from Microsoft.FSharp.Core
--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
<summary> Service-to-service JWT authentication for AT Protocol backend services (labelers, feed generators, etc.). </summary>
<summary> Claims in a service auth JWT. </summary>
[<Struct>] type DateTimeOffset = new: date: DateOnly * time: TimeOnly * offset: TimeSpan -> unit + 8 overloads member Add: timeSpan: TimeSpan -> DateTimeOffset member AddDays: days: float -> DateTimeOffset member AddHours: hours: float -> DateTimeOffset member AddMicroseconds: microseconds: float -> DateTimeOffset member AddMilliseconds: milliseconds: float -> DateTimeOffset member AddMinutes: minutes: float -> DateTimeOffset member AddMonths: months: int -> DateTimeOffset member AddSeconds: seconds: float -> DateTimeOffset member AddTicks: ticks: int64 -> DateTimeOffset ...
<summary>Represents a point in time, typically expressed as a date and time of day, relative to Coordinated Universal Time (UTC).</summary>
--------------------
DateTimeOffset ()
DateTimeOffset(dateTime: DateTime) : DateTimeOffset
DateTimeOffset(dateTime: DateTime, offset: TimeSpan) : DateTimeOffset
DateTimeOffset(ticks: int64, offset: TimeSpan) : DateTimeOffset
DateTimeOffset(date: DateOnly, time: TimeOnly, offset: TimeSpan) : DateTimeOffset
DateTimeOffset(year: int, month: int, day: int, hour: int, minute: int, second: int, offset: TimeSpan) : DateTimeOffset
DateTimeOffset(year: int, month: int, day: int, hour: int, minute: int, second: int, millisecond: int, offset: TimeSpan) : DateTimeOffset
DateTimeOffset(year: int, month: int, day: int, hour: int, minute: int, second: int, millisecond: int, calendar: Globalization.Calendar, offset: TimeSpan) : DateTimeOffset
DateTimeOffset(year: int, month: int, day: int, hour: int, minute: int, second: int, millisecond: int, microsecond: int, offset: TimeSpan) : DateTimeOffset
DateTimeOffset(year: int, month: int, day: int, hour: int, minute: int, second: int, millisecond: int, microsecond: int, calendar: Globalization.Calendar, offset: TimeSpan) : DateTimeOffset
<summary>Gets a <see cref="T:System.DateTimeOffset" /> object whose date and time are set to the current Coordinated Universal Time (UTC) date and time and whose offset is <see cref="F:System.TimeSpan.Zero" />.</summary>
<returns>An object whose date and time is the current Coordinated Universal Time (UTC) and whose offset is <see cref="F:System.TimeSpan.Zero" />.</returns>
module Nsid from FSharp.ATProto.Syntax
<summary> Functions for creating, validating, and extracting data from <see cref="Nsid" /> values. </summary>
--------------------
type Nsid = private | Nsid of string override ToString: unit -> string
<summary> A Namespaced Identifier (NSID) used to reference Lexicon schemas in the AT Protocol. NSIDs follow the pattern <c>authority.name</c> where the authority is a reversed domain name and the name identifies the specific schema (e.g. <c>app.bsky.feed.post</c>). Maximum length is 317 characters. </summary>
<remarks> See the AT Protocol specification: https://atproto.com/specs/nsid </remarks>
<summary> Parse and validate an NSID string. </summary>
<param name="s"> An NSID string in the format <c>domain.segments.name</c> (e.g. <c>"app.bsky.feed.post"</c>). Must have at least three segments, where the final segment (name) starts with a letter and the preceding segments form a valid reversed domain authority. </param>
<returns><c>Ok</c> with a validated <see cref="Nsid" />, or <c>Error</c> with a message describing the validation failure. Validation failures include: null input, exceeding the 317-character limit, or invalid NSID syntax. </returns>
<example><code> match Nsid.parse "app.bsky.feed.post" with | Ok nsid -> printfn "Authority: %s, Name: %s" (Nsid.authority nsid) (Nsid.name nsid) | Error e -> printfn "Invalid: %s" e </code></example>
<summary> Create a service auth JWT token. The sign function should produce a 64-byte compact ECDSA signature over the input bytes. </summary>
<summary> JWT signing algorithm. </summary>
<summary> Create a service auth JWT with default timing (issued now, expires in 60 seconds). </summary>
<summary> Parse JWT claims without verifying the signature. </summary>
<summary> Extract the string representation of a DID. </summary>
<param name="did">The DID to extract the value from.</param>
<returns>The full DID string (e.g. <c>"did:plc:z72i7hdynmk6r22z27h6tvur"</c>).</returns>
<summary> Validate a service auth JWT: verify signature, check expiry. The verify function should check a 64-byte compact ECDSA signature against data. </summary>
module AtpAgent from FSharp.ATProto.Core
<summary> Functions for creating and authenticating <see cref="AtpAgent" /> instances. </summary>
--------------------
type AtpAgent = { HttpClient: HttpClient mutable BaseUrl: Uri mutable Session: AtpSession option ExtraHeaders: (string * string) list AuthenticateRequest: (HttpRequestMessage -> unit) option RefreshAuthentication: (unit -> Task<Result<unit,XrpcError>>) option OnSessionChanged: (unit -> unit) option }
<summary> Client agent for communicating with an AT Protocol Personal Data Server (PDS). Holds the HTTP client, base URL, optional authenticated session, and extra headers. </summary>
<remarks> Create an agent with <see cref="AtpAgent.create" /> or <see cref="AtpAgent.createWithClient" />, then authenticate with <see cref="AtpAgent.login" />. The agent's <see cref="Session" /> field is mutable: it is updated automatically on login and token refresh. </remarks>
<example><code> let agent = AtpAgent.create "https://bsky.social" let! session = AtpAgent.login "my-handle.bsky.social" "app-password-here" agent </code></example>
<summary> Creates a new unauthenticated agent pointing at the given PDS base URL. A new <see cref="System.Net.Http.HttpClient" /> is allocated internally. </summary>
<param name="baseUrl"> The PDS base URL (e.g. <c>"https://bsky.social"</c>). A trailing slash is appended if not present. </param>
<returns>An unauthenticated <see cref="AtpAgent" /> ready for <see cref="login" />.</returns>
<example><code> let agent = AtpAgent.create "https://bsky.social" </code></example>
<summary> Configure an AtpAgent to use service auth for requests. The sign function should produce a 64-byte compact ECDSA signature. </summary>