+216
PDSharp.Core/Auth.fs
+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
+6
-1
PDSharp.Core/Config.fs
+1
PDSharp.Core/PDSharp.Core.fsproj
+1
PDSharp.Core/PDSharp.Core.fsproj
+77
PDSharp.Docs/auth.md
+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
+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
+1
PDSharp.Tests/PDSharp.Tests.fsproj
+1
PDSharp.Tests/Tests.fs
+1
PDSharp.Tests/Tests.fs
+220
-1
PDSharp/Program.fs
+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
+2
-1
PDSharp/appsettings.json
+26
-3
roadmap.txt
+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