an atproto pds written in F# (.NET 9) 🦒
pds fsharp giraffe dotnet atproto

feat: add authentication module

+216
PDSharp.Core/Auth.fs
···
··· 1 + namespace PDSharp.Core 2 + 3 + open System 4 + open System.Text 5 + open Org.BouncyCastle.Crypto.Digests 6 + open Org.BouncyCastle.Crypto.Macs 7 + open Org.BouncyCastle.Crypto.Parameters 8 + open Org.BouncyCastle.Security 9 + 10 + /// Authentication module for sessions and accounts 11 + /// TODO: Migrate account storage from in-memory to SQLite/Postgres for production 12 + module Auth = 13 + /// Hash a password with a random salt using SHA-256 14 + /// 15 + /// Returns: base64(salt)$base64(hash) 16 + let hashPassword (password : string) : string = 17 + let salt = Array.zeroCreate<byte> 16 18 + SecureRandom().NextBytes(salt) 19 + 20 + let passwordBytes = Encoding.UTF8.GetBytes(password) 21 + let toHash = Array.append salt passwordBytes 22 + 23 + let digest = Sha256Digest() 24 + digest.BlockUpdate(toHash, 0, toHash.Length) 25 + let hash = Array.zeroCreate<byte> (digest.GetDigestSize()) 26 + digest.DoFinal(hash, 0) |> ignore 27 + 28 + $"{Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}" 29 + 30 + /// Verify a password against a stored hash 31 + let verifyPassword (password : string) (storedHash : string) : bool = 32 + let parts = storedHash.Split('$') 33 + 34 + if parts.Length <> 2 then 35 + false 36 + else 37 + try 38 + let salt = Convert.FromBase64String(parts.[0]) 39 + let expectedHash = Convert.FromBase64String(parts.[1]) 40 + 41 + let passwordBytes = Encoding.UTF8.GetBytes(password) 42 + let toHash = Array.append salt passwordBytes 43 + 44 + let digest = Sha256Digest() 45 + digest.BlockUpdate(toHash, 0, toHash.Length) 46 + let actualHash = Array.zeroCreate<byte> (digest.GetDigestSize()) 47 + digest.DoFinal(actualHash, 0) |> ignore 48 + 49 + actualHash = expectedHash 50 + with _ -> 51 + false 52 + 53 + let private base64UrlEncode (bytes : byte[]) : string = 54 + Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=') 55 + 56 + let private base64UrlDecode (str : string) : byte[] = 57 + let padded = 58 + match str.Length % 4 with 59 + | 2 -> str + "==" 60 + | 3 -> str + "=" 61 + | _ -> str 62 + 63 + Convert.FromBase64String(padded.Replace('-', '+').Replace('_', '/')) 64 + 65 + let private hmacSha256 (secret : byte[]) (data : byte[]) : byte[] = 66 + let hmac = HMac(Sha256Digest()) 67 + hmac.Init(KeyParameter(secret)) 68 + hmac.BlockUpdate(data, 0, data.Length) 69 + let result = Array.zeroCreate<byte> (hmac.GetMacSize()) 70 + hmac.DoFinal(result, 0) |> ignore 71 + result 72 + 73 + /// Token type for domain separation per AT Protocol spec 74 + type TokenType = 75 + | Access // typ: at+jwt 76 + | Refresh // typ: refresh+jwt 77 + 78 + /// Create a JWT token 79 + let createToken (secret : string) (tokenType : TokenType) (did : string) (expiresIn : TimeSpan) : string = 80 + let now = DateTimeOffset.UtcNow 81 + let exp = now.Add(expiresIn) 82 + 83 + let typ = 84 + match tokenType with 85 + | Access -> "at+jwt" 86 + | Refresh -> "refresh+jwt" 87 + 88 + let jti = Guid.NewGuid().ToString("N") 89 + 90 + let header = $"""{{ "alg": "HS256", "typ": "{typ}" }}""" 91 + let headerB64 = base64UrlEncode (Encoding.UTF8.GetBytes(header)) 92 + 93 + let payload = 94 + $"""{{ "sub": "{did}", "iat": {now.ToUnixTimeSeconds()}, "exp": {exp.ToUnixTimeSeconds()}, "jti": "{jti}" }}""" 95 + 96 + let payloadB64 = base64UrlEncode (Encoding.UTF8.GetBytes(payload)) 97 + 98 + let signingInput = $"{headerB64}.{payloadB64}" 99 + let secretBytes = Encoding.UTF8.GetBytes(secret) 100 + let signature = hmacSha256 secretBytes (Encoding.UTF8.GetBytes(signingInput)) 101 + let signatureB64 = base64UrlEncode signature 102 + 103 + $"{headerB64}.{payloadB64}.{signatureB64}" 104 + 105 + /// Create an access token (short-lived) 106 + let createAccessToken (secret : string) (did : string) : string = 107 + createToken secret Access did (TimeSpan.FromMinutes(15.0)) 108 + 109 + /// Create a refresh token (longer-lived) 110 + let createRefreshToken (secret : string) (did : string) : string = 111 + createToken secret Refresh did (TimeSpan.FromDays(7.0)) 112 + 113 + /// Validation result 114 + type TokenValidation = 115 + | Valid of did : string * tokenType : TokenType * exp : int64 116 + | Invalid of reason : string 117 + 118 + /// Validate a JWT token and extract claims 119 + let validateToken (secret : string) (token : string) : TokenValidation = 120 + let parts = token.Split('.') 121 + 122 + if parts.Length <> 3 then 123 + Invalid "Invalid token format" 124 + else 125 + try 126 + let headerB64, payloadB64, signatureB64 = parts.[0], parts.[1], parts.[2] 127 + 128 + let signingInput = $"{headerB64}.{payloadB64}" 129 + let secretBytes = Encoding.UTF8.GetBytes(secret) 130 + let expectedSig = hmacSha256 secretBytes (Encoding.UTF8.GetBytes(signingInput)) 131 + let actualSig = base64UrlDecode signatureB64 132 + 133 + if expectedSig <> actualSig then 134 + Invalid "Invalid signature" 135 + else 136 + let payloadJson = Encoding.UTF8.GetString(base64UrlDecode payloadB64) 137 + let headerJson = Encoding.UTF8.GetString(base64UrlDecode headerB64) 138 + 139 + let typMatch = 140 + System.Text.RegularExpressions.Regex.Match(headerJson, "\"typ\"\\s*:\\s*\"([^\"]+)\"") 141 + 142 + let tokenType = 143 + if typMatch.Success then 144 + match typMatch.Groups.[1].Value with 145 + | "at+jwt" -> Access 146 + | "refresh+jwt" -> Refresh 147 + | _ -> Access 148 + else 149 + Access 150 + 151 + let subMatch = 152 + System.Text.RegularExpressions.Regex.Match(payloadJson, "\"sub\"\\s*:\\s*\"([^\"]+)\"") 153 + 154 + let expMatch = 155 + System.Text.RegularExpressions.Regex.Match(payloadJson, "\"exp\"\\s*:\\s*([0-9]+)") 156 + 157 + if not subMatch.Success || not expMatch.Success then 158 + Invalid "Missing claims" 159 + else 160 + let did = subMatch.Groups.[1].Value 161 + let exp = Int64.Parse(expMatch.Groups.[1].Value) 162 + let now = DateTimeOffset.UtcNow.ToUnixTimeSeconds() 163 + 164 + if now > exp then 165 + Invalid "Token expired" 166 + else 167 + Valid(did, tokenType, exp) 168 + with ex -> 169 + Invalid $"Parse error: {ex.Message}" 170 + 171 + /// Account record 172 + type Account = { 173 + Did : string 174 + Handle : string 175 + PasswordHash : string 176 + Email : string option 177 + CreatedAt : DateTimeOffset 178 + } 179 + 180 + let mutable private accounts : Map<string, Account> = Map.empty 181 + let mutable private handleIndex : Map<string, string> = Map.empty 182 + 183 + let createAccount (handle : string) (password : string) (email : string option) : Result<Account, string> = 184 + if Map.containsKey handle handleIndex then 185 + Error "Handle already taken" 186 + else 187 + let did = $"did:web:{handle}" 188 + 189 + if Map.containsKey did accounts then 190 + Error "Account already exists" 191 + else 192 + let account = { 193 + Did = did 194 + Handle = handle 195 + PasswordHash = hashPassword password 196 + Email = email 197 + CreatedAt = DateTimeOffset.UtcNow 198 + } 199 + 200 + accounts <- Map.add did account accounts 201 + handleIndex <- Map.add handle did handleIndex 202 + Ok account 203 + 204 + /// Get account by handle 205 + let getAccountByHandle (handle : string) : Account option = 206 + handleIndex 207 + |> Map.tryFind handle 208 + |> Option.bind (fun did -> Map.tryFind did accounts) 209 + 210 + /// Get account by DID 211 + let getAccountByDid (did : string) : Account option = Map.tryFind did accounts 212 + 213 + /// Clear all accounts (for testing) 214 + let resetAccounts () = 215 + accounts <- Map.empty 216 + handleIndex <- Map.empty
+6 -1
PDSharp.Core/Config.fs
··· 1 namespace PDSharp.Core 2 3 module Config = 4 - type AppConfig = { PublicUrl : string; DidHost : string }
··· 1 namespace PDSharp.Core 2 3 module Config = 4 + type AppConfig = { 5 + PublicUrl : string 6 + DidHost : string 7 + /// HS256 signing key for session tokens 8 + JwtSecret : string 9 + }
+1
PDSharp.Core/PDSharp.Core.fsproj
··· 14 <Compile Include="Car.fs" /> 15 <Compile Include="AtUri.fs" /> 16 <Compile Include="Repository.fs" /> 17 <Compile Include="Firehose.fs" /> 18 <Compile Include="DidResolver.fs" /> 19 <Compile Include="Library.fs" />
··· 14 <Compile Include="Car.fs" /> 15 <Compile Include="AtUri.fs" /> 16 <Compile Include="Repository.fs" /> 17 + <Compile Include="Auth.fs" /> 18 <Compile Include="Firehose.fs" /> 19 <Compile Include="DidResolver.fs" /> 20 <Compile Include="Library.fs" />
+77
PDSharp.Docs/auth.md
···
··· 1 + # AT Protocol Session & Account Authentication 2 + 3 + ## Session Authentication (Legacy Bearer JWT) 4 + 5 + Based on the [XRPC Spec](https://atproto.com/specs/xrpc#authentication): 6 + 7 + ### Token Types 8 + 9 + | Token | JWT `typ` Header | Lifetime | Purpose | 10 + | ------------- | ---------------- | --------------------------- | ------------------------------ | 11 + | Access Token | `at+jwt` | Short (~2min refresh cycle) | Authenticate most API requests | 12 + | Refresh Token | `refresh+jwt` | Longer (~2 months) | Obtain new access tokens | 13 + 14 + ### Endpoints 15 + 16 + - **`createSession`**: Login with identifier (handle/email) + password → returns `{accessJwt, refreshJwt, handle, did}` 17 + - **`refreshSession`**: Uses refresh JWT in Bearer header → returns new `{accessJwt, refreshJwt, handle, did}` 18 + - **`createAccount`**: Register new account → returns session tokens + creates DID 19 + 20 + ### JWT Claims (Server-Generated) 21 + 22 + Servers should implement **domain separation** using the `typ` header field: 23 + 24 + - Access: `typ: at+jwt` (per [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html)) 25 + - Refresh: `typ: refresh+jwt` 26 + 27 + Standard JWT claims: `sub` (DID), `iat`, `exp`, `jti` (nonce) 28 + 29 + ### Configuration Required 30 + 31 + Yes, JWT signing requires a **secret key** for HMAC-SHA256 (HS256). This should be: 32 + 33 + - Loaded from configuration/environment variable (e.g., `PDS_JWT_SECRET`) 34 + - At least 32 bytes of cryptographically random data 35 + - Never hardcoded or committed to source control 36 + 37 + ## Account Storage 38 + 39 + ### Reference PDS Approach 40 + 41 + The Bluesky reference PDS uses: 42 + 43 + - **SQLite database per user** (recent architecture) 44 + - `account.sqlite` contains: handle, email, DID, password hash 45 + - Accounts indexed by DID (primary) and handle (unique) 46 + 47 + ## App Passwords 48 + 49 + App passwords are a security feature allowing restricted access: 50 + 51 + - Format: `xxxx-xxxx-xxxx-xxxx` 52 + - Created/revoked independently from main password 53 + - Grants limited permissions (no auth settings changes) 54 + 55 + ## Inter-Service Auth (Different from Session Auth) 56 + 57 + For service-to-service requests, different mechanism: 58 + 59 + - Uses **asymmetric signing** (ES256/ES256K) with account's signing key 60 + - Short-lived tokens (~60sec) 61 + - Validated against DID document 62 + 63 + ## Summary: Implementation Decisions 64 + 65 + | Aspect | Decision | Rationale | 66 + | --------------- | -------------------------- | ------------------------------------ | 67 + | Token signing | HS256 (symmetric) | Simpler, standard for session tokens | 68 + | Secret storage | Config/env var | Required for security | 69 + | Account storage | In-memory (initial) | Matches existing patterns | 70 + | Password hash | SHA-256 + salt | Uses existing Crypto.fs | 71 + | Token lifetimes | Access: 15min, Refresh: 7d | Conservative defaults | 72 + 73 + ## References 74 + 75 + - [XRPC Authentication Spec](https://atproto.com/specs/xrpc#authentication) 76 + - [RFC 9068 - JWT Access Tokens](https://www.rfc-editor.org/rfc/rfc9068.html) 77 + - [Bluesky PDS GitHub](https://github.com/bluesky-social/pds)
+101
PDSharp.Tests/Auth.Tests.fs
···
··· 1 + module Auth.Tests 2 + 3 + open Xunit 4 + open PDSharp.Core.Auth 5 + 6 + [<Fact>] 7 + let ``Password hashing produces salt$hash format`` () = 8 + let hash = hashPassword "mypassword" 9 + Assert.Contains("$", hash) 10 + let parts = hash.Split('$') 11 + Assert.Equal(2, parts.Length) 12 + 13 + [<Fact>] 14 + let ``Password verification succeeds for correct password`` () = 15 + let hash = hashPassword "mypassword" 16 + Assert.True(verifyPassword "mypassword" hash) 17 + 18 + [<Fact>] 19 + let ``Password verification fails for wrong password`` () = 20 + let hash = hashPassword "mypassword" 21 + Assert.False(verifyPassword "wrongpassword" hash) 22 + 23 + [<Fact>] 24 + let ``Password verification fails for invalid hash format`` () = 25 + Assert.False(verifyPassword "password" "invalidhash") 26 + Assert.False(verifyPassword "password" "") 27 + 28 + [<Fact>] 29 + let ``JWT access token creation and validation`` () = 30 + let secret = "test-secret-key-minimum-32-chars!" 31 + let did = "did:web:test.example" 32 + 33 + let token = createAccessToken secret did 34 + 35 + let parts = token.Split('.') 36 + Assert.Equal(3, parts.Length) 37 + 38 + match validateToken secret token with 39 + | Valid(extractedDid, tokenType, _) -> 40 + Assert.Equal(did, extractedDid) 41 + Assert.Equal(Access, tokenType) 42 + | Invalid reason -> Assert.Fail $"Token should be valid, got: {reason}" 43 + 44 + [<Fact>] 45 + let ``JWT refresh token has correct type`` () = 46 + let secret = "test-secret-key-minimum-32-chars!" 47 + let did = "did:web:test.example" 48 + 49 + let token = createRefreshToken secret did 50 + 51 + match validateToken secret token with 52 + | Valid(_, tokenType, _) -> Assert.Equal(Refresh, tokenType) 53 + | Invalid reason -> Assert.Fail $"Token should be valid, got: {reason}" 54 + 55 + [<Fact>] 56 + let ``JWT validation fails with wrong secret`` () = 57 + let secret = "test-secret-key-minimum-32-chars!" 58 + let wrongSecret = "wrong-secret-key-minimum-32-chars!" 59 + let did = "did:web:test.example" 60 + 61 + let token = createAccessToken secret did 62 + 63 + match validateToken wrongSecret token with 64 + | Invalid _ -> Assert.True(true) 65 + | Valid _ -> Assert.Fail "Token should be invalid with wrong secret" 66 + 67 + [<Fact>] 68 + let ``Account creation and lookup by handle`` () = 69 + resetAccounts () 70 + 71 + match createAccount "test.user" "password123" (Some "test@example.com") with 72 + | Error msg -> Assert.Fail msg 73 + | Ok account -> 74 + Assert.Equal("test.user", account.Handle) 75 + Assert.Equal("did:web:test.user", account.Did) 76 + Assert.Equal(Some "test@example.com", account.Email) 77 + 78 + match getAccountByHandle "test.user" with 79 + | None -> Assert.Fail "Account should be found" 80 + | Some found -> Assert.Equal(account.Did, found.Did) 81 + 82 + [<Fact>] 83 + let ``Account creation fails for duplicate handle`` () = 84 + resetAccounts () 85 + 86 + createAccount "duplicate.user" "password" None |> ignore 87 + 88 + match createAccount "duplicate.user" "password2" None with 89 + | Error msg -> Assert.Contains("already", msg.ToLower()) 90 + | Ok _ -> Assert.Fail "Should fail for duplicate handle" 91 + 92 + [<Fact>] 93 + let ``Account lookup by DID`` () = 94 + resetAccounts () 95 + 96 + match createAccount "did.user" "password123" None with 97 + | Error msg -> Assert.Fail msg 98 + | Ok account -> 99 + match getAccountByDid account.Did with 100 + | None -> Assert.Fail "Account should be found by DID" 101 + | Some found -> Assert.Equal(account.Handle, found.Handle)
+1
PDSharp.Tests/PDSharp.Tests.fsproj
··· 13 <Compile Include="Repository.Tests.fs" /> 14 <Compile Include="Car.Tests.fs" /> 15 <Compile Include="Firehose.Tests.fs" /> 16 <Compile Include="Program.fs" /> 17 </ItemGroup> 18
··· 13 <Compile Include="Repository.Tests.fs" /> 14 <Compile Include="Car.Tests.fs" /> 15 <Compile Include="Firehose.Tests.fs" /> 16 + <Compile Include="Auth.Tests.fs" /> 17 <Compile Include="Program.fs" /> 18 </ItemGroup> 19
+1
PDSharp.Tests/Tests.fs
··· 16 let config = { 17 PublicUrl = "https://example.com" 18 DidHost = "did:web:example.com" 19 } 20 21 Assert.Equal("did:web:example.com", config.DidHost)
··· 16 let config = { 17 PublicUrl = "https://example.com" 18 DidHost = "did:web:example.com" 19 + JwtSecret = "test-secret-key-for-testing-only" 20 } 21 22 Assert.Equal("did:web:example.com", config.DidHost)
+220 -1
PDSharp/Program.fs
··· 17 open PDSharp.Core.Mst 18 open PDSharp.Core.Crypto 19 open PDSharp.Core.Firehose 20 21 module App = 22 /// Repo state per DID: MST root, collections, current rev, head commit CID ··· 98 let signed = signCommit key unsigned 99 let commitBytes = serializeCommit signed 100 let! commitCid = (blockStore :> IBlockStore).Put(commitBytes) 101 - return (signed, commitCid) 102 } 103 104 [<CLIMutable>] ··· 133 } 134 135 json response next ctx 136 137 let createRecordHandler : HttpHandler = 138 fun next ctx -> task { ··· 584 GET 585 >=> route "/xrpc/com.atproto.server.describeServer" 586 >=> describeServerHandler 587 POST >=> route "/xrpc/com.atproto.repo.createRecord" >=> createRecordHandler 588 GET >=> route "/xrpc/com.atproto.repo.getRecord" >=> getRecordHandler 589 POST >=> route "/xrpc/com.atproto.repo.putRecord" >=> putRecordHandler
··· 17 open PDSharp.Core.Mst 18 open PDSharp.Core.Crypto 19 open PDSharp.Core.Firehose 20 + open PDSharp.Core.Auth 21 22 module App = 23 /// Repo state per DID: MST root, collections, current rev, head commit CID ··· 99 let signed = signCommit key unsigned 100 let commitBytes = serializeCommit signed 101 let! commitCid = (blockStore :> IBlockStore).Put(commitBytes) 102 + return signed, commitCid 103 } 104 105 [<CLIMutable>] ··· 134 } 135 136 json response next ctx 137 + 138 + [<CLIMutable>] 139 + type CreateAccountRequest = { 140 + handle : string 141 + email : string option 142 + password : string 143 + inviteCode : string option 144 + } 145 + 146 + [<CLIMutable>] 147 + type CreateSessionRequest = { 148 + /// Handle or email 149 + identifier : string 150 + password : string 151 + } 152 + 153 + type SessionResponse = { 154 + accessJwt : string 155 + refreshJwt : string 156 + handle : string 157 + did : string 158 + email : string option 159 + } 160 + 161 + /// POST /xrpc/com.atproto.server.createAccount 162 + let createAccountHandler : HttpHandler = 163 + fun next ctx -> task { 164 + let config = ctx.GetService<AppConfig>() 165 + let! body = ctx.ReadBodyFromRequestAsync() 166 + 167 + let request = 168 + JsonSerializer.Deserialize<CreateAccountRequest>( 169 + body, 170 + JsonSerializerOptions(PropertyNameCaseInsensitive = true) 171 + ) 172 + 173 + if 174 + String.IsNullOrWhiteSpace(request.handle) 175 + || String.IsNullOrWhiteSpace(request.password) 176 + then 177 + ctx.SetStatusCode 400 178 + 179 + return! 180 + json 181 + { 182 + error = "InvalidRequest" 183 + message = "handle and password are required" 184 + } 185 + next 186 + ctx 187 + else 188 + match createAccount request.handle request.password request.email with 189 + | Error msg -> 190 + ctx.SetStatusCode 400 191 + return! json { error = "AccountExists"; message = msg } next ctx 192 + | Ok account -> 193 + let accessJwt = createAccessToken config.JwtSecret account.Did 194 + let refreshJwt = createRefreshToken config.JwtSecret account.Did 195 + 196 + ctx.SetStatusCode 200 197 + 198 + return! 199 + json 200 + { 201 + accessJwt = accessJwt 202 + refreshJwt = refreshJwt 203 + handle = account.Handle 204 + did = account.Did 205 + email = account.Email 206 + } 207 + next 208 + ctx 209 + } 210 + 211 + /// POST /xrpc/com.atproto.server.createSession 212 + let createSessionHandler : HttpHandler = 213 + fun next ctx -> task { 214 + let config = ctx.GetService<AppConfig>() 215 + let! body = ctx.ReadBodyFromRequestAsync() 216 + 217 + let request = 218 + JsonSerializer.Deserialize<CreateSessionRequest>( 219 + body, 220 + JsonSerializerOptions(PropertyNameCaseInsensitive = true) 221 + ) 222 + 223 + if 224 + String.IsNullOrWhiteSpace(request.identifier) 225 + || String.IsNullOrWhiteSpace(request.password) 226 + then 227 + ctx.SetStatusCode 400 228 + 229 + return! 230 + json 231 + { 232 + error = "InvalidRequest" 233 + message = "identifier and password are required" 234 + } 235 + next 236 + ctx 237 + else 238 + match getAccountByHandle request.identifier with 239 + | None -> 240 + ctx.SetStatusCode 401 241 + 242 + return! 243 + json 244 + { 245 + error = "AuthenticationRequired" 246 + message = "Invalid identifier or password" 247 + } 248 + next 249 + ctx 250 + | Some account -> 251 + if not (verifyPassword request.password account.PasswordHash) then 252 + ctx.SetStatusCode 401 253 + 254 + return! 255 + json 256 + { 257 + error = "AuthenticationRequired" 258 + message = "Invalid identifier or password" 259 + } 260 + next 261 + ctx 262 + else 263 + let accessJwt = createAccessToken config.JwtSecret account.Did 264 + let refreshJwt = createRefreshToken config.JwtSecret account.Did 265 + 266 + ctx.SetStatusCode 200 267 + 268 + return! 269 + json 270 + { 271 + accessJwt = accessJwt 272 + refreshJwt = refreshJwt 273 + handle = account.Handle 274 + did = account.Did 275 + email = account.Email 276 + } 277 + next 278 + ctx 279 + } 280 + 281 + /// Extract Bearer token from Authorization header 282 + let private extractBearerToken (ctx : HttpContext) : string option = 283 + match ctx.Request.Headers.TryGetValue("Authorization") with 284 + | true, values -> 285 + let header = values.ToString() 286 + 287 + if header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) then 288 + Some(header.Substring(7)) 289 + else 290 + None 291 + | _ -> None 292 + 293 + /// POST /xrpc/com.atproto.server.refreshSession 294 + let refreshSessionHandler : HttpHandler = 295 + fun next ctx -> task { 296 + let config = ctx.GetService<AppConfig>() 297 + 298 + match extractBearerToken ctx with 299 + | None -> 300 + ctx.SetStatusCode 401 301 + 302 + return! 303 + json 304 + { 305 + error = "AuthenticationRequired" 306 + message = "Missing or invalid Authorization header" 307 + } 308 + next 309 + ctx 310 + | Some token -> 311 + match validateToken config.JwtSecret token with 312 + | Invalid reason -> 313 + ctx.SetStatusCode 401 314 + return! json { error = "ExpiredToken"; message = reason } next ctx 315 + | Valid(did, tokenType, _) -> 316 + if tokenType <> Refresh then 317 + ctx.SetStatusCode 400 318 + 319 + return! 320 + json 321 + { 322 + error = "InvalidRequest" 323 + message = "Refresh token required" 324 + } 325 + next 326 + ctx 327 + else 328 + match getAccountByDid did with 329 + | None -> 330 + ctx.SetStatusCode 401 331 + return! json { error = "AccountNotFound"; message = "Account not found" } next ctx 332 + | Some account -> 333 + let accessJwt = createAccessToken config.JwtSecret account.Did 334 + let refreshJwt = createRefreshToken config.JwtSecret account.Did 335 + 336 + ctx.SetStatusCode 200 337 + 338 + return! 339 + json 340 + { 341 + accessJwt = accessJwt 342 + refreshJwt = refreshJwt 343 + handle = account.Handle 344 + did = account.Did 345 + email = account.Email 346 + } 347 + next 348 + ctx 349 + } 350 351 let createRecordHandler : HttpHandler = 352 fun next ctx -> task { ··· 798 GET 799 >=> route "/xrpc/com.atproto.server.describeServer" 800 >=> describeServerHandler 801 + POST >=> route "/xrpc/com.atproto.server.createAccount" >=> createAccountHandler 802 + POST >=> route "/xrpc/com.atproto.server.createSession" >=> createSessionHandler 803 + POST 804 + >=> route "/xrpc/com.atproto.server.refreshSession" 805 + >=> refreshSessionHandler 806 POST >=> route "/xrpc/com.atproto.repo.createRecord" >=> createRecordHandler 807 GET >=> route "/xrpc/com.atproto.repo.getRecord" >=> getRecordHandler 808 POST >=> route "/xrpc/com.atproto.repo.putRecord" >=> putRecordHandler
+2 -1
PDSharp/appsettings.json
··· 1 { 2 "PublicUrl": "http://localhost:5000", 3 - "DidHost": "did:web:localhost" 4 }
··· 1 { 2 "PublicUrl": "http://localhost:5000", 3 + "DidHost": "did:web:localhost", 4 + "JwtSecret": "change-this-secret-in-production-minimum-32-chars" 5 }
+26 -3
roadmap.txt
··· 49 -------------------------------------------------------------------------------- 50 Milestone H: Account + Sessions 51 -------------------------------------------------------------------------------- 52 - - Implement: server.createAccount, server.createSession, refreshSession 53 - - Password/app-password hashing + JWT issuance 54 DoD: Authenticate and write records with accessJwt 55 -------------------------------------------------------------------------------- 56 Milestone I: Lexicon Validation + Conformance ··· 58 - Lexicon validation for writes (app.bsky.* records) 59 - Conformance testing: diff CIDs/CARs/signatures vs reference PDS 60 DoD: Same inputs → same outputs for repo/sync surfaces 61 ================================================================================ 62 PHASE 2: DEPLOYMENT (Self-Host) 63 ================================================================================ ··· 121 [x] putRecord + blockstore operational 122 [x] CAR export + sync endpoints 123 [x] subscribeRepos firehose 124 - [ ] Authentication (createAccount, createSession) 125 [ ] Lexicon validation 126 [ ] Domain + TLS configured 127 [ ] PDS deployed and reachable
··· 49 -------------------------------------------------------------------------------- 50 Milestone H: Account + Sessions 51 -------------------------------------------------------------------------------- 52 + - [x] Implement: server.createAccount, server.createSession, refreshSession 53 + - [x] Password/app-password hashing + JWT issuance 54 DoD: Authenticate and write records with accessJwt 55 -------------------------------------------------------------------------------- 56 Milestone I: Lexicon Validation + Conformance ··· 58 - Lexicon validation for writes (app.bsky.* records) 59 - Conformance testing: diff CIDs/CARs/signatures vs reference PDS 60 DoD: Same inputs → same outputs for repo/sync surfaces 61 + -------------------------------------------------------------------------------- 62 + Milestone J: Persistence + Backups (Self-hosted PDS) 63 + -------------------------------------------------------------------------------- 64 + Deliverables: 65 + - BackupOps module in Core (scheduler unit / cron / scripts, plus Litestream config) 66 + Backups (SQLite) 67 + [ ] Set PDS_SQLITE_DISABLE_WAL_AUTO_CHECKPOINT=true (Litestream-friendly) 68 + [ ] Run a scheduled backup/replication job that: 69 + - finds recently updated DBs 70 + - backs up /pds/actors/* and PDS-wide DBs 71 + - runs on SIGTERM during deploys (avoid missing last writes) 72 + Backups (Blobs) 73 + [ ] Configurable Options (app settings): 74 + (A) Disk blobs: include /pds/blocks in backups 75 + (B) S3-compatible blobstore: rely on object-store durability 76 + Guardrails 77 + [ ] Uptime check: https://<pds>/xrpc/_health 78 + [ ] Alert if “latest backup” is older than N minutes. 79 + [ ] Alert on disk pressure for /pds. 80 + DoD: 81 + - You can restore onto a fresh host and pass the P3 verification checklist. 82 + - Backups run automatically and are observable (“last successful backup”). 83 + - Backup set is explicitly documented (DBs + blobs decision). 84 ================================================================================ 85 PHASE 2: DEPLOYMENT (Self-Host) 86 ================================================================================ ··· 144 [x] putRecord + blockstore operational 145 [x] CAR export + sync endpoints 146 [x] subscribeRepos firehose 147 + [x] Authentication (createAccount, createSession) 148 [ ] Lexicon validation 149 [ ] Domain + TLS configured 150 [ ] PDS deployed and reachable